#459 General held 76 of ~110 settings (11 screens). Its sections now live on pages named for what people come to do: Account, Appearance, Messages (incl. editor, date and time), Notifications, Calls & audio, Privacy & security (privacy options + the former Devices page), Data & storage (storage, sync, your data), Desktop (desktop app only), Emojis & stickers, Advanced (the former Developer Tools + "show hidden events" / "legacy username colour"), About. The longest page is now ~3 screens (Appearance). Settings opens on Account. Deep links keep working: "devices" lands on Privacy & security, the old "general" on Appearance, the update notice on Desktop. Text that pointed at the old page names is updated. The push-rule editor gets one line explaining the Matrix terms. Remaining Title Case labels on Account and Devices are sentence case. #460 Chat background, seasonal theme and avatar decoration grids sit behind one row: the current choice and a Change button (the first two close after a choice; decorations keep their Save step). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
148 lines
5.6 KiB
TypeScript
148 lines
5.6 KiB
TypeScript
/**
|
|
* [Gitea #110, #123] One "security" nudge for this device, in priority order:
|
|
*
|
|
* - verify-device: the account has cross-signing but THIS device isn't signed.
|
|
* It can't unlock backed-up history and shows as untrusted to others.
|
|
* Verifying with the recovery key also connects the backup, so it comes first.
|
|
* - connect-backup: a key backup exists on the server but this device isn't
|
|
* backing up to it (`getActiveSessionBackupVersion()` null).
|
|
* - setup-backup: no key backup at all (includes accounts that never set up
|
|
* cross-signing: the setup flow does both).
|
|
*
|
|
* - stale-sessions (#113, lowest priority): sessions of this account not used
|
|
* for 90+ days; Review opens Settings → Privacy & security with them ticked to sign out.
|
|
*
|
|
* Anything not yet known (crypto still loading) → no nudge. Timing: not in
|
|
* the first 24 h on a device (onboarding), "Not now" snoozes 7 days, three
|
|
* dismissals of a nudge stop it for good; a healthy device never sees one.
|
|
*/
|
|
|
|
export type NudgeKind = 'verify-device' | 'connect-backup' | 'setup-backup' | 'stale-sessions';
|
|
|
|
export type SecurityState = {
|
|
/** m.cross_signing.master account data present. */
|
|
crossSigning: boolean;
|
|
/** This device cross-signing-verified; undefined while crypto isn't ready. */
|
|
deviceVerified: boolean | undefined;
|
|
/** A key backup version on the server; undefined while loading. */
|
|
backupOnServer: boolean | undefined;
|
|
/** This device is backing up to it; undefined while loading. */
|
|
backupActive: boolean | undefined;
|
|
};
|
|
|
|
export const nudgeFor = (s: SecurityState): NudgeKind | undefined => {
|
|
if (s.crossSigning) {
|
|
if (s.deviceVerified === undefined) return undefined;
|
|
if (!s.deviceVerified) return 'verify-device';
|
|
}
|
|
if (s.backupOnServer === undefined) return undefined;
|
|
if (!s.backupOnServer) return 'setup-backup';
|
|
if (s.backupActive === undefined) return undefined;
|
|
return s.backupActive ? undefined : 'connect-backup';
|
|
};
|
|
|
|
/** [Gitea #113] A session counts as stale after this long unused. */
|
|
export const STALE_SESSION_MS = 90 * 24 * 60 * 60 * 1000;
|
|
|
|
type SessionLike = { device_id: string; last_seen_ts?: number | null };
|
|
|
|
/**
|
|
* Pure: other sessions of this account not seen for STALE_SESSION_MS. Never
|
|
* the current session; a session with no last-seen time is left alone (we
|
|
* can't tell it's unused).
|
|
*/
|
|
export const staleSessions = <T extends SessionLike>(
|
|
devices: T[],
|
|
currentDeviceId: string | undefined,
|
|
now: number,
|
|
): T[] =>
|
|
devices.filter(
|
|
(d) =>
|
|
d.device_id !== currentDeviceId &&
|
|
typeof d.last_seen_ts === 'number' &&
|
|
now - d.last_seen_ts >= STALE_SESSION_MS,
|
|
);
|
|
|
|
/** "1 session" / "3 sessions you haven't used in 3 months". */
|
|
export const staleSessionsText = (count: number): string =>
|
|
`You have ${count === 1 ? 'a session' : `${count} sessions`} you haven't used in 3 months. Signing ${
|
|
count === 1 ? 'it' : 'them'
|
|
} out keeps your account safe if an old device is lost.`;
|
|
|
|
/**
|
|
* The nudge to show: the device-security one first, then stale sessions, each
|
|
* only if its own snooze allows (so a snoozed security nudge doesn't hide the
|
|
* stale-sessions one, and vice versa).
|
|
*/
|
|
export const pickNudge = (
|
|
security: NudgeKind | undefined,
|
|
staleCount: number,
|
|
record: NudgeRecord,
|
|
now: number,
|
|
): NudgeKind | undefined => {
|
|
const candidates: NudgeKind[] = [];
|
|
if (security) candidates.push(security);
|
|
if (staleCount > 0) candidates.push('stale-sessions');
|
|
return candidates.find((k) => shouldShowNudge(k, record, now));
|
|
};
|
|
|
|
export const NUDGE_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
export const NUDGE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
export const NUDGE_MAX_DISMISSALS = 3;
|
|
|
|
export type NudgeRecord = {
|
|
/** When this device was first seen by the nudge (ms). */
|
|
firstSeen: number;
|
|
dismissals: Partial<Record<NudgeKind, { count: number; last: number }>>;
|
|
};
|
|
|
|
export const shouldShowNudge = (kind: NudgeKind, record: NudgeRecord, now: number): boolean => {
|
|
if (now - record.firstSeen < NUDGE_GRACE_MS) return false;
|
|
const d = record.dismissals[kind];
|
|
if (!d) return true;
|
|
return d.count < NUDGE_MAX_DISMISSALS && now - d.last >= NUDGE_SNOOZE_MS;
|
|
};
|
|
|
|
export const dismissNudge = (kind: NudgeKind, record: NudgeRecord, now: number): NudgeRecord => ({
|
|
...record,
|
|
dismissals: {
|
|
...record.dismissals,
|
|
[kind]: { count: (record.dismissals[kind]?.count ?? 0) + 1, last: now },
|
|
},
|
|
});
|
|
|
|
export const NUDGE_KEY_PREFIX = 'lotus-security-nudge-';
|
|
|
|
/** Parse a stored record; anything unexpected starts a fresh one (grace period from now). */
|
|
export const parseNudgeRecord = (raw: string | null, now: number): NudgeRecord => {
|
|
try {
|
|
const v = JSON.parse(raw ?? '');
|
|
if (v && typeof v.firstSeen === 'number' && v.dismissals && typeof v.dismissals === 'object') {
|
|
return { firstSeen: v.firstSeen, dismissals: v.dismissals };
|
|
}
|
|
} catch {
|
|
/* fresh record below */
|
|
}
|
|
return { firstSeen: now, dismissals: {} };
|
|
};
|
|
|
|
export const NUDGE_COPY: Record<NudgeKind, { text: string; action: string }> = {
|
|
'verify-device': {
|
|
text: 'Verify this device so you can read your older encrypted messages and others see it as trusted.',
|
|
action: 'Verify',
|
|
},
|
|
'connect-backup': {
|
|
text: "This device isn't using your key backup yet. Connect it so your encrypted messages stay readable if you lose it.",
|
|
action: 'Connect',
|
|
},
|
|
'stale-sessions': {
|
|
// The text depends on the count: see staleSessionsText.
|
|
text: '',
|
|
action: 'Review',
|
|
},
|
|
'setup-backup': {
|
|
text: "Your encryption keys aren't backed up. Set up key backup so you don't lose your encrypted messages if you log out or lose this device.",
|
|
action: 'Set up',
|
|
},
|
|
};
|