Prompt users to set up recovery (#30075)

* Show indicator in settings dialog when user doesn't have recovery set up

* Update settings headers to use red dot for recommended settings

* update recovery setup toast and remember if the user dismisses it

* update playwright snapshots

* use typed event emitters

* reverse logic for the account data flag

* fix comment and type
This commit is contained in:
Hubert Chathi 2025-06-18 12:20:17 -04:00 committed by GitHub
parent 2034f8b6bb
commit af984c0e80
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 269 additions and 33 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

After

Width:  |  Height:  |  Size: 272 KiB

View File

@ -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);
}
}
}

View File

@ -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);
}
}

View File

@ -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 {

View File

@ -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<void> {
await this.client?.setAccountData(RECOVERY_ACCOUNT_DATA_KEY, { enabled: false });
}
private async ensureDeviceIdsAtStartPopulated(): Promise<void> {
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<boolean> {
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).

View File

@ -29,6 +29,7 @@ export class Tab<T extends string> {
* @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<T extends string> {
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<T extends string> {
}
function TabLabel<T extends string>({ tab, isActive, showToolip, onClick }: ITabLabelProps<T>): JSX.Element {
const classes = classNames("mx_TabbedView_tabLabel", {
const classes = classNames("mx_TabbedView_tabLabel", tab.labelClassName, {
mx_TabbedView_tabLabel_active: isActive,
});

View File

@ -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<boolean> => {
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<Tab<UserTab>> => {
const tabs: Tab<UserTab>[] = [];
@ -196,6 +218,7 @@ export default function UserSettingsDialog(props: IProps): JSX.Element {
<KeyIcon />,
<EncryptionUserSettingsTab initialState={initialEncryptionState} />,
"UserSettingsEncryption",
showSetupRecoveryIndicator ? "mx_SettingsDialog_tabLabelsAlert" : undefined,
),
);

View File

@ -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 (
<Heading className="mx_SettingsHeader" as="h2" size="sm" weight="semibold">
{label} {hasRecommendedTag && <span>{_t("common|recommended")}</span>}
<Heading className={classes} as="h2" size="sm" weight="semibold">
{label}
</Heading>
);
}

View File

@ -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);

View File

@ -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<T>(
useEventEmitter(emitter, eventName, handler);
return value;
}
/**
* The return value of the callback function for `useEventEmitterAsyncState`.
*/
export type AsyncStateCallbackResult<T> = Promise<T | NoChange>;
/**
* 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<T, Events extends string, Arguments extends ListenerMap<Events>>(
emitter: TypedEventEmitter<Events, Arguments> | undefined,
eventName: string | symbol,
fn: Mapper<AsyncStateCallbackResult<T>>,
deps: DependencyList,
initialValue: T,
): T;
export function useEventEmitterAsyncState<T, Events extends string, Arguments extends ListenerMap<Events>>(
emitter: TypedEventEmitter<Events, Arguments> | undefined,
eventName: string | symbol,
fn: Mapper<AsyncStateCallbackResult<T>>,
deps: DependencyList,
initialValue?: T,
): T | undefined;
export function useEventEmitterAsyncState<T, Events extends string, Arguments extends ListenerMap<Events>>(
emitter: TypedEventEmitter<Events, Arguments> | undefined,
eventName: string | symbol,
fn: Mapper<AsyncStateCallbackResult<T>>,
deps: DependencyList,
initialValue?: T,
): T | undefined {
const [value, setValue] = useState<T | undefined>(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 {}

View File

@ -968,7 +968,6 @@
},
"reset_all_button": "Forgotten or lost all recovery methods? <a>Reset all</a>",
"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",

View File

@ -88,7 +88,7 @@ const getPrimaryButtonIcon = (kind: Kind): ComponentType<React.SVGAttributes<SVG
const getSecondaryButtonLabel = (kind: Kind): string => {
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();
}

View File

@ -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({

View File

@ -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

View File

@ -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("<UserSettingsDialog />", () => {
// 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();
});
});
});

View File

@ -5,7 +5,7 @@ exports[`<SettingsHeader /> should render the component 1`] = `
<h2
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93 mx_SettingsHeader"
>
Settings Header
Settings Header
</h2>
</DocumentFragment>
`;
@ -13,12 +13,9 @@ exports[`<SettingsHeader /> should render the component 1`] = `
exports[`<SettingsHeader /> should render the component with the recommended tag 1`] = `
<DocumentFragment>
<h2
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93 mx_SettingsHeader"
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93 mx_SettingsHeader mx_SettingsHeader_recommended"
>
Settings Header
<span>
Recommended
</span>
Settings Header
</h2>
</DocumentFragment>
`;

View File

@ -104,12 +104,14 @@ describe("<ChangeRecoveryKey />", () => {
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();
});

View File

@ -12,7 +12,7 @@ exports[`<RecoveryPanel /> should allow to change the recovery key when everythi
<h2
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93 mx_SettingsHeader"
>
Recovery
Recovery
</h2>
Recover your cryptographic identity and message history with a recovery key if youve lost all your existing devices.
</div>
@ -51,12 +51,9 @@ exports[`<RecoveryPanel /> should ask to set up a recovery key when there is no
class="mx_SettingsSection_header"
>
<h2
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93 mx_SettingsHeader"
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93 mx_SettingsHeader mx_SettingsHeader_recommended"
>
Recovery
<span>
Recommended
</span>
Recovery
</h2>
Recover your cryptographic identity and message history with a recovery key if youve lost all your existing devices.
</div>
@ -97,7 +94,7 @@ exports[`<RecoveryPanel /> should be in loading state when checking the recovery
<h2
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93 mx_SettingsHeader"
>
Recovery
Recovery
</h2>
Recover your cryptographic identity and message history with a recovery key if youve lost all your existing devices.
</div>

View File

@ -12,7 +12,7 @@ exports[`<RecoveyPanelOutOfSync /> should render 1`] = `
<h2
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93 mx_SettingsHeader"
>
Recovery
Recovery
</h2>
<div
class="mx_SettingsSubheader"

View File

@ -18,7 +18,7 @@ exports[`<EncryptionUserSettingsTab /> should display a verify button when the e
<h2
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93 mx_SettingsHeader"
>
Device not verified
Device not verified
</h2>
<div
class="mx_SettingsSubheader"
@ -100,7 +100,7 @@ exports[`<EncryptionUserSettingsTab /> should display the recovery out of sync p
<h2
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93 mx_SettingsHeader"
>
Recovery
Recovery
</h2>
<div
class="mx_SettingsSubheader"

View File

@ -36,14 +36,16 @@ describe("SetupEncryptionToast", () => {
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();
});
});