diff --git a/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png b/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png index 3b4031063c..677473dded 100644 Binary files a/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png and b/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png differ diff --git a/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png b/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png index e0e46682a3..da4d594e23 100644 Binary files a/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png and b/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png index 20518942b0..67e047eb50 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png index a847075a4d..65258303c9 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png differ diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-recovery-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-recovery-linux.png index e26d001a90..39e74833b6 100644 Binary files a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-recovery-linux.png and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-recovery-linux.png differ diff --git a/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png b/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png index c484a47fc9..dca96a1f4f 100644 Binary files a/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png and b/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png differ diff --git a/res/css/views/dialogs/_SettingsDialog.pcss b/res/css/views/dialogs/_SettingsDialog.pcss index 186a82c0f5..2b65bff63b 100644 --- a/res/css/views/dialogs/_SettingsDialog.pcss +++ b/res/css/views/dialogs/_SettingsDialog.pcss @@ -30,4 +30,28 @@ Please see LICENSE files in the repository root for full details. /* colliding harshly with the dialog when scrolled down. */ padding-bottom: 100px; } + + .mx_SettingsDialog_tabLabelsAlert::after { + display: inline-block; + content: ""; + width: 8px; + height: 8px; + background-color: var(--cpd-color-icon-critical-primary); + clip-path: circle(4px); + position: absolute; + right: var(--cpd-space-4x); + } +} + +/* On narrow viewports, the tab labels are hidden, so we need to shift the indicator so it isn't over the tab icon. */ +@media (max-width: 1024px) { + .mx_UserSettingsDialog, + .mx_RoomSettingsDialog, + .mx_SpaceSettingsDialog, + .mx_SpacePreferencesDialog { + .mx_SettingsDialog_tabLabelsAlert::after { + right: var(--cpd-space-1x); + top: var(--cpd-space-1x); + } + } } diff --git a/res/css/views/settings/_SettingsHeader.pcss b/res/css/views/settings/_SettingsHeader.pcss index a705deda6c..e1c470214f 100644 --- a/res/css/views/settings/_SettingsHeader.pcss +++ b/res/css/views/settings/_SettingsHeader.pcss @@ -16,4 +16,13 @@ font: var(--cpd-font-body-sm-medium); color: var(--cpd-color-text-action-accent); } + + &.mx_SettingsHeader_recommended::after { + display: inline-block; + content: ""; + width: 8px; + height: 8px; + background-color: var(--cpd-color-icon-critical-primary); + clip-path: circle(4px); + } } diff --git a/src/@types/matrix-js-sdk.d.ts b/src/@types/matrix-js-sdk.d.ts index e1f8c4562e..ad75ca95f0 100644 --- a/src/@types/matrix-js-sdk.d.ts +++ b/src/@types/matrix-js-sdk.d.ts @@ -92,6 +92,9 @@ declare module "matrix-js-sdk/src/types" { // MSC4155: Invite filtering [INVITE_RULES_ACCOUNT_DATA_TYPE]: InviteConfigAccountData; "io.element.msc4278.media_preview_config": MediaPreviewConfig; + + // Indicate whether recovery is enabled or disabled + "io.element.recovery": { enabled: boolean }; } export interface AudioContent { diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index e13d296bc1..f64bc91968 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -57,6 +57,11 @@ const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000; */ 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"; + const logger = baseLogger.getChild("DeviceListener:"); export default class DeviceListener { @@ -165,6 +170,13 @@ export default class DeviceListener { 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 }); + } + private async ensureDeviceIdsAtStartPopulated(): Promise { if (this.ourDeviceIdsAtStart === null) { this.ourDeviceIdsAtStart = await this.getDeviceIds(); @@ -220,7 +232,8 @@ export default class DeviceListener { 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() === BACKUP_DISABLED_ACCOUNT_DATA_KEY || + ev.getType() === RECOVERY_ACCOUNT_DATA_KEY ) { this.recheck(); } @@ -332,6 +345,9 @@ export default class DeviceListener { crossSigningStatus.privateKeysCachedLocally.userSigningKey; const defaultKeyId = await cli.secretStorage.getDefaultKeyId(); + const recoveryDisabled = await this.recheckRecoveryDisabled(cli); + + const recoveryIsOk = secretStorageReady || recoveryDisabled; const isCurrentDeviceTrusted = crossSigningReady && @@ -346,8 +362,7 @@ export default class DeviceListener { // said we are OK with that. const keyBackupIsOk = keyBackupUploadActive || backupDisabled; - const allSystemsReady = - crossSigningReady && keyBackupIsOk && secretStorageReady && allCrossSigningSecretsCached; + const allSystemsReady = crossSigningReady && keyBackupIsOk && recoveryIsOk && allCrossSigningSecretsCached; await this.reportCryptoSessionStateToAnalytics(cli); @@ -384,7 +399,10 @@ export default class DeviceListener { // 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 (keyBackupUploadActive) { + if (recoveryDisabled) { + logSpan.info("Recovery disabled: no toast needed"); + hideSetupEncryptionToast(); + } else if (keyBackupUploadActive) { logSpan.info("No default 4S key: showing SET_UP_RECOVERY toast"); showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY); } else { @@ -482,6 +500,20 @@ export default class DeviceListener { 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; + } + /** * Reports current recovery state to analytics. * Checks if the session is verified and if the recovery is correctly set up (i.e all secrets known locally and in 4S). diff --git a/src/components/structures/TabbedView.tsx b/src/components/structures/TabbedView.tsx index b01f160551..029226fc87 100644 --- a/src/components/structures/TabbedView.tsx +++ b/src/components/structures/TabbedView.tsx @@ -29,6 +29,7 @@ export class Tab { * @param {string|JSX.Element} icon An SVG element to use for the tab icon. Can also be a string for legacy icons, in which case it is the class for the tab icon. This should be a simple mask. * @param {JSX.Element} body The JSX for the tab container. * @param {string} screenName The screen name to report to Posthog. + * @param {string} labelClassName Additional class to add to the tab label. */ public constructor( public readonly id: T, @@ -36,6 +37,7 @@ export class Tab { public readonly icon: string | JSX.Element | null, public readonly body: JSX.Element, public readonly screenName?: ScreenName, + public readonly labelClassName?: string, ) {} } @@ -85,7 +87,7 @@ interface ITabLabelProps { } function TabLabel({ tab, isActive, showToolip, onClick }: ITabLabelProps): JSX.Element { - const classes = classNames("mx_TabbedView_tabLabel", { + const classes = classNames("mx_TabbedView_tabLabel", tab.labelClassName, { mx_TabbedView_tabLabel_active: isActive, }); diff --git a/src/components/views/dialogs/UserSettingsDialog.tsx b/src/components/views/dialogs/UserSettingsDialog.tsx index bb68051dfc..4d8b8ad3c0 100644 --- a/src/components/views/dialogs/UserSettingsDialog.tsx +++ b/src/components/views/dialogs/UserSettingsDialog.tsx @@ -7,6 +7,7 @@ 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 { ClientEvent, type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { Toast } from "@vector-im/compound-web"; import React, { type JSX, useState } from "react"; import UserProfileIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-profile"; @@ -44,6 +45,7 @@ import { UserTab } from "./UserTab"; import { type NonEmptyArray } from "../../../@types/common"; import { SDKContext, type SdkContextClass } from "../../../contexts/SDKContext"; import { useSettingValue } from "../../../hooks/useSettings"; +import { NoChange, useEventEmitterAsyncState, type AsyncStateCallbackResult } from "../../../hooks/useEventEmitter"; import { ToastContext, useActiveToast } from "../../../contexts/ToastContext"; import { EncryptionUserSettingsTab, type State } from "../settings/tabs/user/EncryptionUserSettingsTab"; @@ -100,6 +102,26 @@ export default function UserSettingsDialog(props: IProps): JSX.Element { const [showMsc4108QrCode, setShowMsc4108QrCode] = useState(props.showMsc4108QrCode); const [initialEncryptionState, setInitialEncryptionState] = useState(props.initialEncryptionState); + // If the user doesn't have Recovery set up (no default Secret Storage key), + // we show an indicator on the Encryption tab. + const showSetupRecoveryIndicator = useEventEmitterAsyncState( + props.sdkContext.client, + ClientEvent.AccountData, + async (event?: MatrixEvent): AsyncStateCallbackResult => { + if (event === undefined || event.getType() === "m.secret_storage.default_key") { + const client = props.sdkContext.client; + if (!client) { + return false; + } + + return !(await client.secretStorage.getDefaultKeyId()); + } + return new NoChange(); + }, + [], + false, + ); + const getTabs = (): NonEmptyArray> => { const tabs: Tab[] = []; @@ -196,6 +218,7 @@ export default function UserSettingsDialog(props: IProps): JSX.Element { , , "UserSettingsEncryption", + showSetupRecoveryIndicator ? "mx_SettingsDialog_tabLabelsAlert" : undefined, ), ); diff --git a/src/components/views/settings/SettingsHeader.tsx b/src/components/views/settings/SettingsHeader.tsx index 10534958f4..1db7fc9027 100644 --- a/src/components/views/settings/SettingsHeader.tsx +++ b/src/components/views/settings/SettingsHeader.tsx @@ -6,10 +6,9 @@ */ import React, { type JSX } from "react"; +import classNames from "classnames"; import { Heading } from "@vector-im/compound-web"; -import { _t } from "../../../languageHandler"; - /** * The heading for a settings section. */ @@ -25,9 +24,12 @@ interface SettingsHeaderProps { } export function SettingsHeader({ hasRecommendedTag = false, label }: SettingsHeaderProps): JSX.Element { + const classes = classNames("mx_SettingsHeader", { + mx_SettingsHeader_recommended: hasRecommendedTag, + }); return ( - - {label} {hasRecommendedTag && {_t("common|recommended")}} + + {label} ); } diff --git a/src/components/views/settings/encryption/ChangeRecoveryKey.tsx b/src/components/views/settings/encryption/ChangeRecoveryKey.tsx index 58efc80afb..b096708947 100644 --- a/src/components/views/settings/encryption/ChangeRecoveryKey.tsx +++ b/src/components/views/settings/encryption/ChangeRecoveryKey.tsx @@ -29,6 +29,7 @@ import { initialiseDehydrationIfEnabled } from "../../../../utils/device/dehydra import { withSecretStorageKeyCache } from "../../../../SecurityManager"; import { EncryptionCardButtons } from "./EncryptionCardButtons"; import { logErrorAndShowErrorDialog } from "../../../../utils/ErrorUtils.tsx"; +import { RECOVERY_ACCOUNT_DATA_KEY } from "../../../../DeviceListener"; /** * The possible states of the component. @@ -131,6 +132,10 @@ export function ChangeRecoveryKey({ }); await initialiseDehydrationIfEnabled(matrixClient, { createNewKey: true }); }); + + // Record the fact that the user explicitly enabled recovery. + await matrixClient.setAccountData(RECOVERY_ACCOUNT_DATA_KEY, { enabled: true }); + onFinish(); } catch (e) { logErrorAndShowErrorDialog("Failed to set up secret storage", e); diff --git a/src/hooks/useEventEmitter.ts b/src/hooks/useEventEmitter.ts index 67049195a0..12bb57a1ee 100644 --- a/src/hooks/useEventEmitter.ts +++ b/src/hooks/useEventEmitter.ts @@ -6,7 +6,7 @@ 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 { useRef, useEffect, useState, useCallback } from "react"; +import { useRef, useEffect, useState, useCallback, type DependencyList } from "react"; import { type ListenerMap, type TypedEventEmitter } from "matrix-js-sdk/src/matrix"; import type { EventEmitter } from "events"; @@ -93,3 +93,100 @@ export function useEventEmitterState( useEventEmitter(emitter, eventName, handler); return value; } + +/** + * The return value of the callback function for `useEventEmitterAsyncState`. + */ +export type AsyncStateCallbackResult = Promise; + +/** + * Creates a state, which is computed asynchronously, and can be updated by events. + * + * Similar to `useEventEmitterState`, but the callback is `async`. + * + * If the event is emitted while the callback is running, it will wait until + * after the callback completes before calling the callback again. If the event + * is emitted multiple times while the callback is running, the callback will be + * called once for each time the event was emitted, in the order that the events + * were emitted. + * + * @param emitter The emitter sending the event + * @param eventName Event name to listen for + * @param fn The callback function, that should return the state value. + * It should have the signature of the event callback, except that all + * parameters are optional. If the params are not set, a default value + * for the state should be returned. If the state value should not + * change from its previous value, the function can return a `NoChange` + * object. + * @param deps The dependencies of the callback function. + * @param initialValue The initial value of the state, before the callback finishes its initial run. + * @returns State + */ +export function useEventEmitterAsyncState>( + emitter: TypedEventEmitter | undefined, + eventName: string | symbol, + fn: Mapper>, + deps: DependencyList, + initialValue: T, +): T; +export function useEventEmitterAsyncState>( + emitter: TypedEventEmitter | undefined, + eventName: string | symbol, + fn: Mapper>, + deps: DependencyList, + initialValue?: T, +): T | undefined; +export function useEventEmitterAsyncState>( + emitter: TypedEventEmitter | undefined, + eventName: string | symbol, + fn: Mapper>, + deps: DependencyList, + initialValue?: T, +): T | undefined { + const [value, setValue] = useState(initialValue); + + let running = false; + // If the handler is called while it's already running, we remember the + // arguments that it was called with, and call the handler again when the + // first call is done. + const rerunArgs: any[] = []; + + const handler = useCallback( + (...args: any[]) => { + if (running) { + // We're already running, so remember the arguments we were + // called with, so that we can call the handler again when we're + // done. + rerunArgs.push(args); + return; + } + running = true; // eslint-disable-line react-hooks/exhaustive-deps + // Note: We need to use .then notation instead of async/await, + // because async/await would cause this function to return a + // promise, which `useEffect` doesn't like. + fn(...args) + .then((v) => { + if (!(v instanceof NoChange)) { + setValue(v); + } + }) + .finally(() => { + running = false; + if (rerunArgs.length != 0) { + handler(...rerunArgs.shift()); + } + }); + }, + [fn, ...deps], // eslint-disable-line react-compiler/react-compiler + ); + + // re-run when the emitter changes + useEffect(handler, [emitter, handler, ...deps]); + useEventEmitter(emitter, eventName, handler); + return value; +} + +/** + * Indicates that the callback for `useEventEmitterAsyncState` is not changing the value of the state. + */ +export class NoChange {} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 56f22dc6d7..abcca3744e 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -968,7 +968,6 @@ }, "reset_all_button": "Forgotten or lost all recovery methods? Reset all", "set_up_recovery": "Set up recovery", - "set_up_recovery_later": "Not now", "set_up_recovery_toast_description": "Generate a recovery key that can be used to restore your encrypted message history in case you lose access to your devices.", "set_up_toast_description": "Safeguard against losing access to encrypted messages & data", "set_up_toast_title": "Set up Secure Backup", diff --git a/src/toasts/SetupEncryptionToast.ts b/src/toasts/SetupEncryptionToast.ts index 802da5b895..197cdfb9bf 100644 --- a/src/toasts/SetupEncryptionToast.ts +++ b/src/toasts/SetupEncryptionToast.ts @@ -88,7 +88,7 @@ const getPrimaryButtonIcon = (kind: Kind): ComponentType { switch (kind) { case Kind.SET_UP_RECOVERY: - return _t("encryption|set_up_recovery_later"); + return _t("action|dismiss"); case Kind.SET_UP_ENCRYPTION: case Kind.VERIFY_THIS_SESSION: return _t("encryption|verification|unverified_sessions_toast_reject"); @@ -201,6 +201,11 @@ export const showToast = (kind: Kind): void => { await deviceListener.recordKeyBackupDisabled(); deviceListener.dismissEncryptionSetup(); } + } else if (kind === Kind.SET_UP_RECOVERY) { + // Record that the user doesn't want to set up recovery + const deviceListener = DeviceListener.sharedInstance(); + await deviceListener.recordRecoveryDisabled(); + deviceListener.dismissEncryptionSetup(); } else { DeviceListener.sharedInstance().dismissEncryptionSetup(); } diff --git a/test/test-utils/client.ts b/test/test-utils/client.ts index cdda7b7dea..658536825b 100644 --- a/test/test-utils/client.ts +++ b/test/test-utils/client.ts @@ -153,7 +153,11 @@ export const mockClientMethodsCrypto = (): Partial< > => ({ isKeyBackupKeyStored: jest.fn(), getCrossSigningCacheCallbacks: jest.fn().mockReturnValue({ getCrossSigningKeyCache: jest.fn() }), - secretStorage: { hasKey: jest.fn(), isStored: jest.fn().mockResolvedValue(null) }, + secretStorage: { + hasKey: jest.fn(), + isStored: jest.fn().mockResolvedValue(null), + getDefaultKeyId: jest.fn().mockResolvedValue(null), + }, getCrypto: jest.fn().mockReturnValue({ getUserDeviceInfo: jest.fn(), getCrossSigningStatus: jest.fn().mockResolvedValue({ diff --git a/test/unit-tests/DeviceListener-test.ts b/test/unit-tests/DeviceListener-test.ts index 62221d0665..c7e1d75724 100644 --- a/test/unit-tests/DeviceListener-test.ts +++ b/test/unit-tests/DeviceListener-test.ts @@ -480,6 +480,15 @@ describe("DeviceListener", () => { }); }); + it("sets the recovery account data when we call recordRecoveryDisabled", async () => { + const instance = await createAndStart(); + await instance.recordRecoveryDisabled(); + + expect(mockClient.setAccountData).toHaveBeenCalledWith("io.element.recovery", { + enabled: false, + }); + }); + describe("when crypto is in use and set up", () => { beforeEach(() => { // Encryption is in use diff --git a/test/unit-tests/components/views/dialogs/UserSettingsDialog-test.tsx b/test/unit-tests/components/views/dialogs/UserSettingsDialog-test.tsx index 8ffe9e3de6..6582bd4d3a 100644 --- a/test/unit-tests/components/views/dialogs/UserSettingsDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/UserSettingsDialog-test.tsx @@ -7,9 +7,9 @@ Please see LICENSE files in the repository root for full details. */ import React, { type ReactElement } from "react"; -import { render, screen } from "jest-matrix-react"; +import { render, screen, waitFor } from "jest-matrix-react"; import { mocked, type MockedObject } from "jest-mock"; -import { type MatrixClient } from "matrix-js-sdk/src/matrix"; +import { ClientEvent, MatrixEvent, type MatrixClient } from "matrix-js-sdk/src/matrix"; import SettingsStore, { type CallbackFn } from "../../../../../src/settings/SettingsStore"; import SdkConfig from "../../../../../src/SdkConfig"; @@ -250,4 +250,28 @@ describe("", () => { // unwatches settings on unmount expect(mockSettingsStore.unwatchSetting).toHaveBeenCalledWith("mock-watcher-id-feature_mjolnir"); }); + + it("displays an indicator when user needs to set up recovery", async () => { + // Initially, the user doesn't have secret storage, so it should display + // an indicator. + mockClient.secretStorage.getDefaultKeyId.mockResolvedValue(null); + + const { container } = render(getComponent()); + + await waitFor(() => { + expect(container.querySelector(".mx_SettingsDialog_tabLabelsAlert")).toBeInTheDocument(); + }); + + // Test that the handler ignores unknown account data + mockClient.emit(ClientEvent.AccountData, new MatrixEvent({ type: "bar" })); + + // The user now has secret storage. Trigger an update and check that + // the indicator disappears. + mockClient.secretStorage.getDefaultKeyId.mockResolvedValue("foo"); + mockClient.emit(ClientEvent.AccountData, new MatrixEvent({ type: "m.secret_storage.default_key" })); + + await waitFor(() => { + expect(container.querySelector(".mx_SettingsDialog_tabLabelsAlert")).not.toBeInTheDocument(); + }); + }); }); diff --git a/test/unit-tests/components/views/settings/__snapshots__/SettingsHeader-test.tsx.snap b/test/unit-tests/components/views/settings/__snapshots__/SettingsHeader-test.tsx.snap index 676233259f..a70b227482 100644 --- a/test/unit-tests/components/views/settings/__snapshots__/SettingsHeader-test.tsx.snap +++ b/test/unit-tests/components/views/settings/__snapshots__/SettingsHeader-test.tsx.snap @@ -5,7 +5,7 @@ exports[` should render the component 1`] = `

- Settings Header + Settings Header

`; @@ -13,12 +13,9 @@ exports[` should render the component 1`] = ` exports[` should render the component with the recommended tag 1`] = `

- Settings Header - - Recommended - + Settings Header

`; diff --git a/test/unit-tests/components/views/settings/encryption/ChangeRecoveryKey-test.tsx b/test/unit-tests/components/views/settings/encryption/ChangeRecoveryKey-test.tsx index 706efb2b90..3286e943b4 100644 --- a/test/unit-tests/components/views/settings/encryption/ChangeRecoveryKey-test.tsx +++ b/test/unit-tests/components/views/settings/encryption/ChangeRecoveryKey-test.tsx @@ -104,12 +104,14 @@ describe("", () => { expect(screen.getByText("The recovery key you entered is not correct.")).toBeInTheDocument(); expect(asFragment()).toMatchSnapshot(); + const setAccountDataSpy = jest.spyOn(matrixClient, "setAccountData"); await userEvent.clear(input); // If the user enters the correct recovery key, the finish button should be enabled await userEvent.type(input, "encoded private key"); await waitFor(() => expect(finishButton).not.toHaveAttribute("aria-disabled", "true")); await user.click(finishButton); + expect(setAccountDataSpy).toHaveBeenCalledWith("io.element.recovery", { enabled: true }); expect(onFinish).toHaveBeenCalledWith(); }); diff --git a/test/unit-tests/components/views/settings/encryption/__snapshots__/RecoveryPanel-test.tsx.snap b/test/unit-tests/components/views/settings/encryption/__snapshots__/RecoveryPanel-test.tsx.snap index fda6b29aa1..d283890689 100644 --- a/test/unit-tests/components/views/settings/encryption/__snapshots__/RecoveryPanel-test.tsx.snap +++ b/test/unit-tests/components/views/settings/encryption/__snapshots__/RecoveryPanel-test.tsx.snap @@ -12,7 +12,7 @@ exports[` should allow to change the recovery key when everythi

- Recovery + Recovery

Recover your cryptographic identity and message history with a recovery key if you’ve lost all your existing devices. @@ -51,12 +51,9 @@ exports[` should ask to set up a recovery key when there is no class="mx_SettingsSection_header" >

- Recovery - - Recommended - + Recovery

Recover your cryptographic identity and message history with a recovery key if you’ve lost all your existing devices. @@ -97,7 +94,7 @@ exports[` should be in loading state when checking the recovery

- Recovery + Recovery

Recover your cryptographic identity and message history with a recovery key if you’ve lost all your existing devices. diff --git a/test/unit-tests/components/views/settings/encryption/__snapshots__/RecoveryPanelOutOfSync-test.tsx.snap b/test/unit-tests/components/views/settings/encryption/__snapshots__/RecoveryPanelOutOfSync-test.tsx.snap index edf7de5af9..ca4914e198 100644 --- a/test/unit-tests/components/views/settings/encryption/__snapshots__/RecoveryPanelOutOfSync-test.tsx.snap +++ b/test/unit-tests/components/views/settings/encryption/__snapshots__/RecoveryPanelOutOfSync-test.tsx.snap @@ -12,7 +12,7 @@ exports[` should render 1`] = `

- Recovery + Recovery

should display a verify button when the e

- Device not verified + Device not verified

should display the recovery out of sync p

- Recovery + Recovery

{ expect(await screen.findByRole("heading", { name: "Set up recovery" })).toBeInTheDocument(); }); - it("should dismiss the toast when 'not now' button clicked", async () => { + it("should dismiss the toast when 'Dismiss' button clicked, and remember it", async () => { + jest.spyOn(DeviceListener.sharedInstance(), "recordRecoveryDisabled"); jest.spyOn(DeviceListener.sharedInstance(), "dismissEncryptionSetup"); showToast(Kind.SET_UP_RECOVERY); const user = userEvent.setup(); - await user.click(await screen.findByRole("button", { name: "Not now" })); + await user.click(await screen.findByRole("button", { name: "Dismiss" })); + expect(DeviceListener.sharedInstance().recordRecoveryDisabled).toHaveBeenCalled(); expect(DeviceListener.sharedInstance().dismissEncryptionSetup).toHaveBeenCalled(); }); });