Skip to content

Prompt users to set up recovery #30075

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jun 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 24 additions & 0 deletions res/css/views/dialogs/_SettingsDialog.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
9 changes: 9 additions & 0 deletions res/css/views/settings/_SettingsHeader.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
3 changes: 3 additions & 0 deletions src/@types/matrix-js-sdk.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
40 changes: 36 additions & 4 deletions src/DeviceListener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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 &&
Expand All @@ -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);

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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).
Expand Down
4 changes: 3 additions & 1 deletion src/components/structures/TabbedView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ 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,
public readonly label: TranslationKey,
public readonly icon: string | JSX.Element | null,
public readonly body: JSX.Element,
public readonly screenName?: ScreenName,
public readonly labelClassName?: string,
) {}
}

Expand Down Expand Up @@ -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,
});

Expand Down
23 changes: 23 additions & 0 deletions src/components/views/dialogs/UserSettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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>[] = [];

Expand Down Expand Up @@ -196,6 +218,7 @@ export default function UserSettingsDialog(props: IProps): JSX.Element {
<KeyIcon />,
<EncryptionUserSettingsTab initialState={initialEncryptionState} />,
"UserSettingsEncryption",
showSetupRecoveryIndicator ? "mx_SettingsDialog_tabLabelsAlert" : undefined,
),
);

Expand Down
10 changes: 6 additions & 4 deletions src/components/views/settings/SettingsHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
99 changes: 98 additions & 1 deletion src/hooks/useEventEmitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {}
1 change: 0 additions & 1 deletion src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading