Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 378b40fabe feat(settings): export / import your data (#114)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m38s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 14s
CI / Playwright smoke (e2e) (pull_request) Successful in 22m30s
Settings -> General -> "Your data": Export writes one JSON file of the
account's Lotus data (synced settings, bookmarks, private notes, room names
and sections, reminders, status presets, thread notification settings,
timed mutes, soundboard (clips stay on the server), emoji packs, avatar
decoration, and this device's settings). Not messages or encryption keys:
they have their own exports, and keys are secret.

Import merges: new items are added; things that differ are listed per
category and kept unless the user picks "Use the file's". Only our own
account-data types are read from a file. Files from a newer Lotus Chat
are refused with a plain message; the version stamp leaves room to
migrate older ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-25 01:08:28 -04:00
30 changed files with 851 additions and 494 deletions
+1 -1
View File
@@ -83,7 +83,7 @@ test.describe('security nudge (#110, #123)', () => {
const nudge = strip(page);
await expect(nudge).toContainText("Your encryption keys aren't backed up");
await nudge.getByRole('button', { name: 'Set up' }).click();
await expect(page.getByText('Device verification').first()).toBeVisible();
await expect(page.getByText('Device Verification').first()).toBeVisible();
});
test('a dismissed announcement no longer hides the nudge (#307)', async ({ page }) => {
-98
View File
@@ -1,98 +0,0 @@
import { test, expect, Page } from '@playwright/test';
import { HS, PASSWORD, api, ensureUser, hsReachable, loginUI, uniq } from './localHs';
// [Gitea #113] Sessions unused for 90+ days: a nudge (after the device-security
// one), and Review opens Settings → Devices with them ticked for sign-out.
// Synapse won't backdate last-seen, so the session list is served with one
// real extra session made 120 days old.
const DAY = 86_400_000;
const strip = (page: Page, text: RegExp) => page.getByRole('status').filter({ hasText: text });
async function pastGrace(page: Page) {
await page.evaluate(() => {
const { deviceId } = JSON.parse(localStorage.getItem('cinny_session_v1') ?? '{}');
localStorage.setItem(
`lotus-security-nudge-${deviceId}`,
JSON.stringify({ firstSeen: Date.now() - 2 * 86_400_000, dismissals: {} }),
);
});
await page.reload();
}
async function settled(page: Page) {
await page
.getByText('Connecting...')
.first()
.waitFor({ timeout: 30_000 })
.catch(() => undefined);
await page.getByText('Connecting...').first().waitFor({ state: 'detached', timeout: 90_000 });
}
test.describe('stale sessions (#113)', () => {
test.beforeAll(async () => {
test.skip(!(await hsReachable()), 'local homeserver not running');
});
test('nudge after the security one, Review ticks the old session, sign-out works', async ({
page,
}) => {
test.setTimeout(180_000);
const user = await ensureUser(uniq('ss'));
// Two extra sessions: one we'll make look 4 months old, one recent.
const login = (device: string) =>
api<{ device_id: string }>('POST', '/_matrix/client/v3/login', undefined, {
type: 'm.login.password',
identifier: { type: 'm.id.user', user: user.localpart },
password: PASSWORD,
device_id: device,
initial_device_display_name: device,
});
await login('OLDLAPTOP');
await login('RECENTPHONE');
await page.route(/\/_matrix\/client\/v3\/devices(\?.*)?$/, async (route) => {
const res = await route.fetch();
const body = await res.json();
for (const d of body.devices ?? []) {
if (d.device_id === 'OLDLAPTOP') d.last_seen_ts = Date.now() - 120 * DAY;
else d.last_seen_ts = Date.now() - DAY;
}
await route.fulfill({ response: res, json: body });
});
await loginUI(page, user);
await pastGrace(page);
await settled(page);
// A fresh account has no key backup: that nudge comes first.
const security = strip(page, /encryption keys/);
await expect(security).toBeVisible({ timeout: 30_000 });
await security.getByRole('button', { name: 'Not now' }).click();
const stale = strip(page, /a session you haven't used in 3 months/);
await expect(stale).toBeVisible({ timeout: 30_000 });
await stale.getByRole('button', { name: 'Review' }).click();
const dialog = page.getByRole('dialog').first();
await expect(dialog.getByText('Inactive for 3+ months')).toHaveCount(1);
await expect(dialog.getByText(/Logout from selected devices\. \(1 selected\)/)).toBeVisible();
// The sticky "Logout from selected devices" bar comes last.
await dialog.getByRole('button', { name: 'Logout', exact: true }).last().click();
await page.locator('input[name="passwordInput"]').fill(PASSWORD);
await page.getByRole('button', { name: 'Continue' }).click();
await expect(dialog.getByText(/Logout from selected devices/)).toHaveCount(0, {
timeout: 30_000,
});
const { devices } = await api<{ devices: { device_id: string }[] }>(
'GET',
'/_matrix/client/v3/devices',
user.token,
);
const ids = devices.map((d) => d.device_id);
expect(ids).not.toContain('OLDLAPTOP');
expect(ids).toContain('RECENTPHONE');
expect(HS).toBeTruthy();
});
});
+98
View File
@@ -0,0 +1,98 @@
import fs from 'node:fs';
import { test, expect } from '@playwright/test';
import { api, ensureUser, hsReachable, loginUI, uniq } from './localHs';
// [Gitea #114] Export / import "your data": a round trip through the file, the
// merge summary, and the keep-mine / use-the-file's choice.
const enc = encodeURIComponent;
test.describe('your data (#114)', () => {
test.beforeAll(async () => {
test.skip(!(await hsReachable()), 'local homeserver not running');
});
test('export, change things, import merges with the chosen conflict rule', async ({ page }) => {
test.setTimeout(150_000);
const user = await ensureUser(uniq('yd'));
const put = (type: string, content: unknown) =>
api(
'PUT',
`/_matrix/client/v3/user/${enc(user.userId)}/account_data/${type}`,
user.token,
content,
);
const get = (type: string) =>
api<Record<string, unknown>>(
'GET',
`/_matrix/client/v3/user/${enc(user.userId)}/account_data/${type}`,
user.token,
);
const bm = (eventId: string) => ({ roomId: '!r:localhost', eventId, savedAt: 1 });
await put('io.lotus.bookmarks', { bookmarks: [bm('$one'), bm('$two')] });
await put('io.lotus.user_notes', { '@friend:localhost': 'met at the LAN party' });
await loginUI(page, user);
await page
.getByText('Connecting...')
.first()
.waitFor({ state: 'detached', timeout: 90_000 })
.catch(() => undefined);
const openGeneral = async () => {
await page.getByRole('button', { name: 'User Settings' }).click();
await page.getByRole('dialog').getByText('General', { exact: true }).first().click();
await page
.getByRole('dialog')
.getByText('Your data', { exact: true })
.scrollIntoViewIfNeeded();
};
await openGeneral();
// Export.
const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByRole('dialog').getByRole('button', { name: 'Export', exact: true }).click(),
]);
const path = await download.path();
const file = JSON.parse(fs.readFileSync(path!, 'utf8'));
expect(file.format).toBe('lotus-chat-data');
expect(file.accountData['io.lotus.bookmarks'].bookmarks).toHaveLength(2);
expect(file.local.settings).toBeTruthy();
// Lose a bookmark, change the note.
await put('io.lotus.bookmarks', { bookmarks: [bm('$one')] });
await put('io.lotus.user_notes', { '@friend:localhost': 'changed my mind' });
await page.waitForTimeout(3000); // let sync deliver the new account data
const importFile = async () => {
await page
.locator('input[type="file"][aria-label="Lotus Chat data file"]')
.setInputFiles(path!);
await expect(page.getByRole('region', { name: 'Import summary' })).toBeVisible();
};
await importFile();
const summary = page.getByRole('region', { name: 'Import summary' });
await expect(summary).toContainText('Bookmarks: 1 new');
await expect(summary).toContainText('Private notes about people: 1 differ');
// Keep mine: the bookmark comes back, the note stays as it is now.
await summary.getByRole('button', { name: 'Import', exact: true }).click();
await expect(page.getByText('Imported.')).toBeVisible({ timeout: 20_000 });
expect(((await get('io.lotus.bookmarks')).bookmarks as unknown[]).length).toBe(2);
expect((await get('io.lotus.user_notes'))['@friend:localhost']).toBe('changed my mind');
// Again, using the file's: the note goes back.
await page.waitForTimeout(2000);
await importFile();
await summary.getByText("Use the file's").click();
await summary.getByRole('button', { name: 'Import', exact: true }).click();
await expect(page.getByText('Imported.')).toBeVisible({ timeout: 20_000 });
expect((await get('io.lotus.user_notes'))['@friend:localhost']).toBe('met at the LAN party');
// Wrong files are refused with a plain message.
fs.writeFileSync(`${path}.bad`, '{"format":"something-else"}');
await page
.locator('input[type="file"][aria-label="Lotus Chat data file"]')
.setInputFiles(`${path}.bad`);
await expect(page.getByText("This isn't a Lotus Chat data file.")).toBeVisible();
});
});
+1 -1
View File
@@ -128,7 +128,7 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
const editor = useEditor();
const thread = useThreadInstance(room, threadId);
const [privateReadReceipts] = useSetting(settingsAtom, 'privateReadReceipts');
// "Hide typing and read receipts" must also make thread receipts private (matches markAsRead).
// "Hide Typing & Read Receipts" must also make thread receipts private (matches markAsRead).
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const fileDropContainerRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
@@ -23,11 +23,8 @@ import {
nudgeFor,
NudgeRecord,
parseNudgeRecord,
pickNudge,
staleSessions,
staleSessionsText,
shouldShowNudge,
} from '../../utils/securityNudge';
import { preselectSignOutAtom } from '../../state/staleSessions';
import { StatusStrip } from '../server-status/ServerStatusBanner';
/** This device's backup connection: undefined until known, then true/false. */
@@ -43,35 +40,6 @@ const useBackupActive = (crypto: CryptoApi): boolean | undefined => {
return active;
};
/**
* [Gitea #113] Ids of this account's sessions unused for 90+ days. Fetched
* once sync has settled, then hourly; [] until known or on error.
*/
const useStaleSessionIds = (enabled: boolean): string[] => {
const mx = useMatrixClient();
const alive = useAlive();
const [ids, setIds] = useState<string[]>([]);
useEffect(() => {
if (!enabled) return undefined;
const load = () =>
mx
.getDevices()
.then(({ devices }) => {
if (alive())
setIds(
staleSessions(devices, mx.getDeviceId() ?? undefined, Date.now()).map(
(d) => d.device_id,
),
);
})
.catch(() => undefined);
load();
const t = window.setInterval(load, 60 * 60 * 1000);
return () => window.clearInterval(t);
}, [mx, enabled, alive]);
return ids;
};
const readRecord = (key: string): NudgeRecord => {
let raw: string | null = null;
try {
@@ -116,15 +84,12 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
let deviceVerified: boolean | undefined;
if (status === VerificationStatus.Verified) deviceVerified = true;
else if (status === VerificationStatus.Unverified) deviceVerified = false;
const securityKind = nudgeFor({
const kind = nudgeFor({
crossSigning,
deviceVerified,
backupOnServer: backupInfo === undefined ? undefined : backupInfo !== null,
backupActive,
});
const staleIds = useStaleSessionIds(settled);
const setPreselect = useSetAtom(preselectSignOutAtom);
const kind = pickNudge(securityKind, staleIds.length, record, Date.now());
// One strip at a time: outages, maintenance and a lost connection win.
// [Gitea #307] Pass what the user dismissed: a dismissed announcement isn't
@@ -132,12 +97,10 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
const otherStrip =
syncLost ||
!!pickBanner(serverStatus, { syncLost, where: 'client', dismissed: new Set(statusDismissed) });
if (!settled || !deviceId || !kind || otherStrip) return null;
if (!settled || !deviceId || !kind || otherStrip || !shouldShowNudge(kind, record, Date.now()))
return null;
const copy =
kind === 'stale-sessions'
? { ...NUDGE_COPY[kind], text: staleSessionsText(staleIds.length) }
: NUDGE_COPY[kind];
const copy = NUDGE_COPY[kind];
const dismiss = () => {
const next = dismissNudge(kind, record, Date.now());
try {
@@ -150,15 +113,8 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
return (
<StatusStrip
banner={{ key: kind, tone: 'Primary', text: copy.text, dismissable: true }}
action={{
label: copy.action,
onClick: () => {
// Review: the Devices page opens with these ticked for sign-out.
if (kind === 'stale-sessions') setPreselect(staleIds);
requestSettings('devices');
},
}}
icon={kind === 'stale-sessions' ? Icons.Category : Icons.ShieldUser}
icon={Icons.ShieldUser}
action={{ label: copy.action, onClick: () => requestSettings('devices') }}
dismissLabel="Not now"
onDismiss={dismiss}
/>
+1 -1
View File
@@ -205,7 +205,7 @@ export function About({ requestClose }: AboutProps) {
gap="400"
>
<SettingTile
title="Clear cache and reload"
title="Clear Cache & Reload"
description="Clear all your locally stored data and reload from server. This also deletes this device's encryption keys — older encrypted messages stay unreadable here unless key backup is set up."
after={
<Button
@@ -22,14 +22,14 @@ export function ContactInformation() {
return (
<Box direction="Column" gap="100">
<Text size="L400">Contact information</Text>
<Text size="L400">Contact Information</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile title="Email address" description="Email address attached to your account.">
<SettingTile title="Email Address" description="Email address attached to your account.">
<Box>
{emailIds?.map((email) => (
<Chip key={email.address} as="span" variant="Secondary" radii="Pill">
@@ -136,7 +136,7 @@ export function IgnoredUserList() {
return (
<Box direction="Column" gap="100">
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="L400">Blocked users</Text>
<Text size="L400">Blocked Users</Text>
</Box>
<SequenceCard
className={SequenceCardStyle}
@@ -145,8 +145,8 @@ export function IgnoredUserList() {
gap="400"
>
<SettingTile
title="Block someone"
description="Stop someone's messages and invites reaching you. Enter their Matrix ID, e.g. @name:server."
title="Select User"
description="Prevent receiving messages or invites from user by adding their userId."
>
<Box direction="Column" gap="300">
<IgnoreUserInput userList={ignoredUsers} />
@@ -31,7 +31,7 @@ export function OidcManageAccount() {
return (
<Box direction="Column" gap="100">
<Text size="L400">Account management</Text>
<Text size="L400">Account Management</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
@@ -72,7 +72,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
gap="400"
>
<SettingTile
title="Enable developer tools"
title="Enable Developer Tools"
after={
<Switch
variant="Primary"
@@ -90,7 +90,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
gap="400"
>
<SettingTile
title="Access token"
title="Access Token"
description="Copy access token to clipboard."
after={
<Button
@@ -35,7 +35,7 @@ export function CryptoDiagnostics() {
return (
<Box direction="Column" gap="100">
<Text size="L400">Crypto diagnostics</Text>
<Text size="L400">Crypto Diagnostics</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
@@ -43,8 +43,8 @@ export function CryptoDiagnostics() {
gap="400"
>
<SettingTile
title="Crypto diagnostics"
description="Captures encryption error signatures from this session. Ring-buffers up to 200 matched console warnings/errors for the KE-1..KE-4 bug cluster. Local only — no network calls. The downloaded report includes the matched log lines as evidence."
title="Crypto Diagnostics — captures E2EE error signatures this session"
description="Ring-buffers up to 200 matched console warnings/errors for the KE-1..KE-4 bug cluster. Local only — no network calls. The downloaded report includes the matched log lines as evidence."
after={
<Box alignItems="Center" gap="200" shrink="No">
<Badge variant={count > 0 ? 'Critical' : 'Secondary'} fill="Solid" radii="Pill">
@@ -93,8 +93,8 @@ export function Devices({ requestClose }: DevicesProps) {
gap="400"
>
<SettingTile
title="Device verification"
description="Verify this device so it can read your encrypted messages and others can trust it."
title="Device Verification"
description="To verify device identity and grant access to encrypted messages."
after={
<>
<EnableVerification visible={!crossSigningActive} />
@@ -130,8 +130,8 @@ function ExportKeysTile() {
return (
<>
<SettingTile
title="Export encryption keys"
description="Save a password-protected file of your encryption keys. Useful when moving to a device without key backup, or as an offline copy."
title="Export Messages Data"
description="Save password protected copy of encryption data on your device to decrypt messages later."
after={
<Box>
<Button
@@ -260,8 +260,8 @@ function ImportKeysTile() {
return (
<>
<SettingTile
title="Import encryption keys"
description="Load a password-protected encryption key file to read older encrypted messages on this device."
title="Import Messages Data"
description="Load password protected copy of encryption data from device to decrypt your messages."
after={
<Box>
{file ? (
@@ -307,7 +307,7 @@ function ImportKeysTile() {
export function LocalBackup() {
return (
<Box direction="Column" gap="100">
<Text size="L400">Encryption key backup file</Text>
<Text size="L400">Local Backup</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
@@ -1,5 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useAtom } from 'jotai';
import React, { useCallback, useState } from 'react';
import { Box, Button, config, Menu, Spinner, Text } from 'folds';
import { AuthDict, IMyDevice, MatrixError } from 'matrix-js-sdk';
import { SequenceCard } from '../../../components/sequence-card';
@@ -16,8 +15,6 @@ import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
import { withSearchParam } from '../../../pages/pathUtils';
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
import { SettingTile } from '../../../components/setting-tile';
import { preselectSignOutAtom } from '../../../state/staleSessions';
import { staleSessions } from '../../../utils/securityNudge';
type OtherDevicesProps = {
devices: IMyDevice[];
@@ -32,23 +29,6 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
const [deleted, setDeleted] = useState<Set<string>>(new Set());
// [Gitea #113] The stale-sessions nudge's Review: tick those sessions for
// sign-out (one confirm below). OIDC accounts sign out on the provider's
// dashboard instead, so nothing is ticked there.
const [preselect, setPreselect] = useAtom(preselectSignOutAtom);
useEffect(() => {
if (preselect.length === 0) return;
if (!authMetadata) {
const present = new Set(devices.map((d) => d.device_id));
setDeleted(new Set(preselect.filter((id) => present.has(id))));
}
setPreselect([]);
}, [preselect, setPreselect, devices, authMetadata]);
const staleIds = new Set(
staleSessions(devices, mx.getDeviceId() ?? undefined, Date.now()).map((d) => d.device_id),
);
const handleDashboardOIDC = useCallback(() => {
const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
if (!authUrl) return;
@@ -135,7 +115,7 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
gap="400"
>
<SettingTile
title="Device dashboard"
title="Device Dashboard"
description="Manage your devices on OIDC dashboard."
after={
<Button
@@ -152,20 +132,8 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
/>
</SequenceCard>
)}
{staleIds.size > 0 && (
<Text size="T200" priority="300">
{staleIds.size === 1
? "1 session hasn't been used in 3 months or more (marked Inactive)."
: `${staleIds.size} sessions haven't been used in 3 months or more (marked Inactive).`}{' '}
Sign out the ones you no longer use.
</Text>
)}
{[...devices]
// [Gitea #113] Inactive (90+ days) sessions first, then most recent.
{devices
.sort((d1, d2) => {
const s1 = staleIds.has(d1.device_id) ? 0 : 1;
const s2 = staleIds.has(d2.device_id) ? 0 : 1;
if (s1 !== s2) return s1 - s2;
if (!d1.last_seen_ts || !d2.last_seen_ts) return 0;
return d1.last_seen_ts < d2.last_seen_ts ? 1 : -1;
})
@@ -177,11 +145,6 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
direction="Column"
gap="400"
>
{staleIds.has(device.device_id) && (
<Text size="L400" style={{ color: 'inherit' }}>
Inactive for 3+ months
</Text>
)}
<DeviceTile
device={device}
deleted={deleted.has(device.device_id)}
@@ -423,7 +423,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
return (
<>
<Box direction="Column" gap="100">
<Text size="L400">Favourite packs</Text>
<Text size="L400">Favorite Packs</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
@@ -431,7 +431,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
gap="400"
>
<SettingTile
title="Add a pack"
title="Select Pack"
description="Pick emojis and stickers pack from rooms to use in all rooms."
after={
<>
@@ -31,7 +31,7 @@ export function UserPack({ onViewPack }: UserPackProps) {
return (
<Box direction="Column" gap="100">
<Text size="L400">Default pack</Text>
<Text size="L400">Default Pack</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
+104 -153
View File
@@ -61,6 +61,7 @@ import {
} from '../../../utils/lotusDenoiseUtils';
import { useSetting } from '../../../state/hooks/settings';
import { useSettingsSyncActions } from '../../../hooks/useSettingsSync';
import { YourDataSection } from './YourData';
import {
CallAudioBitrate,
ChatBackground,
@@ -155,7 +156,7 @@ function DesktopChromeSetting() {
return (
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Custom window chrome (beta)"
title="Custom Window Chrome (Beta)"
description="Replace the system title bar with a Lotus-styled one. Desktop only — reloads to apply."
after={<Switch variant="Primary" value={customChrome} onChange={handleToggle} />}
/>
@@ -263,7 +264,7 @@ function KeychainMirrorSetting() {
}
return (
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title="Keep your sign-in in the system keychain" description={description} />
<SettingTile title="Login in the system keychain" description={description} />
</SequenceCard>
);
}
@@ -294,18 +295,12 @@ const ThemeSelector = as<'div', ThemeSelectorProps>(
),
);
function SelectTheme({ disabled, kind }: { disabled?: boolean; kind?: ThemeKind }) {
const allThemes = useThemes();
const themes = kind ? allThemes.filter((theme) => theme.kind === kind) : allThemes;
function SelectTheme({ disabled }: { disabled?: boolean }) {
const themes = useThemes();
const themeNames = useThemeNames();
const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId');
const [, setLightThemeId] = useSetting(settingsAtom, 'lightThemeId');
const [, setDarkThemeId] = useSetting(settingsAtom, 'darkThemeId');
const [menuCords, setMenuCords] = useState<RectCords>();
const selectedTheme =
themes.find((theme) => theme.id === themeId) ??
themes[0] ??
(kind === ThemeKind.Dark ? DarkTheme : LightTheme);
const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme;
const handleThemeMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
@@ -313,10 +308,6 @@ function SelectTheme({ disabled, kind }: { disabled?: boolean; kind?: ThemeKind
const handleThemeSelect = (theme: Theme) => {
setThemeId(theme.id);
// [Gitea #455] Also remember it as this kind's variant, so switching to
// System later keeps the user's pick.
if (theme.kind === ThemeKind.Light) setLightThemeId(theme.id);
else setDarkThemeId(theme.id);
setMenuCords(undefined);
};
@@ -401,7 +392,7 @@ function SystemThemePreferences() {
return (
<Box wrap="Wrap" gap="400">
<SettingTile
title="Light theme:"
title="Light Theme:"
after={
<Chip
variant={themeKind === ThemeKind.Light ? 'Primary' : 'Secondary'}
@@ -442,7 +433,7 @@ function SystemThemePreferences() {
}
/>
<SettingTile
title="Dark theme:"
title="Dark Theme:"
after={
<Chip
variant={themeKind === ThemeKind.Dark ? 'Primary' : 'Secondary'}
@@ -544,26 +535,6 @@ function PageZoomInput() {
function Appearance() {
const [systemTheme, setSystemTheme] = useSetting(settingsAtom, 'useSystemTheme');
const allThemes = useThemes();
const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId');
const [lightThemeId] = useSetting(settingsAtom, 'lightThemeId');
const [darkThemeId] = useSetting(settingsAtom, 'darkThemeId');
const currentKind = allThemes.find((t) => t.id === themeId)?.kind ?? ThemeKind.Light;
let themeMode: 'system' | 'light' | 'dark' = 'system';
if (!systemTheme) themeMode = currentKind === ThemeKind.Dark ? 'dark' : 'light';
const chooseThemeMode = (mode: 'system' | 'light' | 'dark') => {
if (mode === 'system') {
setSystemTheme(true);
return;
}
setSystemTheme(false);
// Use the variant already chosen for that kind (or the default one).
const kind = mode === 'dark' ? ThemeKind.Dark : ThemeKind.Light;
if (allThemes.find((t) => t.id === themeId)?.kind === kind) return;
const preferred = mode === 'dark' ? darkThemeId : lightThemeId;
const fallback = mode === 'dark' ? DarkTheme.id : LightTheme.id;
setThemeId(allThemes.find((t) => t.id === preferred && t.kind === kind)?.id ?? fallback);
};
const [monochromeMode, setMonochromeMode] = useSetting(settingsAtom, 'monochromeMode');
const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
const [perMessageProfiles, setPerMessageProfiles] = useSetting(
@@ -605,64 +576,38 @@ function Appearance() {
direction="Column"
gap="400"
>
{/* [Gitea #455] One control instead of a System switch plus a Theme
dropdown that was greyed out whenever the switch was on. */}
<SettingTile
title="Theme"
description={
themeMode === 'system'
? "Follows your device's light or dark setting."
: 'Always use this theme, whatever your device is set to.'
}
after={
<Box gap="100" role="radiogroup" aria-label="Theme" shrink="No" wrap="Wrap">
{(
[
['system', 'System'],
['light', 'Light'],
['dark', 'Dark'],
] as const
).map(([mode, label]) => (
<Chip
key={mode}
role="radio"
aria-checked={themeMode === mode}
variant={themeMode === mode ? 'Primary' : 'Secondary'}
outlined={themeMode === mode}
radii="Pill"
onClick={() => chooseThemeMode(mode)}
>
<Text size="B300">{label}</Text>
</Chip>
))}
</Box>
}
title="System Theme"
description="Choose between light and dark theme based on system preference."
after={<Switch variant="Primary" value={systemTheme} onChange={setSystemTheme} />}
/>
{themeMode === 'system' && <SystemThemePreferences />}
{themeMode !== 'system' && (
<SettingTile
title={themeMode === 'light' ? 'Light theme:' : 'Dark theme:'}
after={<SelectTheme kind={themeMode === 'light' ? ThemeKind.Light : ThemeKind.Dark} />}
/>
)}
{systemTheme && <SystemThemePreferences />}
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Monochrome mode"
title="Theme"
description="Theme to use when system theme is not enabled."
after={<SelectTheme disabled={systemTheme} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Monochrome Mode"
after={<Switch variant="Primary" value={monochromeMode} onChange={setMonochromeMode} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Emoji style: Twemoji"
title="Twitter Emoji"
after={<Switch variant="Primary" value={twitterEmoji} onChange={setTwitterEmoji} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title="Page zoom" after={<PageZoomInput />} />
<SettingTile title="Page Zoom" after={<PageZoomInput />} />
</SequenceCard>
<SequenceCard
@@ -672,7 +617,7 @@ function Appearance() {
gap="200"
>
<SettingTile
title="Chat background"
title="Chat Background"
description="Pattern applied behind the message timeline."
/>
<Box style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}>
@@ -682,7 +627,7 @@ function Appearance() {
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Pause background animations"
title="Pause Background Animations"
description="Stop background animation to reduce motion or improve performance."
after={<Switch variant="Primary" value={pauseAnimations} onChange={setPauseAnimations} />}
/>
@@ -695,7 +640,7 @@ function Appearance() {
gap="200"
>
<SettingTile
title="Seasonal theme"
title="Seasonal Theme"
description="Decorative overlays for holidays and events. “Auto” follows the calendar — each theme below shows the dates it turns on. Click to select."
/>
<Box style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}>
@@ -715,7 +660,7 @@ function Appearance() {
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Show name and avatar on every message"
title="Show Profile on Every Message"
description="Display avatar and name on each message instead of grouping consecutive messages."
after={
<Switch variant="Primary" value={perMessageProfiles} onChange={setPerMessageProfiles} />
@@ -729,7 +674,7 @@ function Appearance() {
gap="200"
>
<SettingTile
title="Night light"
title="Night Light"
description="Reduce blue light with a warm orange tint overlay."
after={
<Switch variant="Primary" value={nightLightEnabled} onChange={setNightLightEnabled} />
@@ -809,7 +754,7 @@ function Appearance() {
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Glass sidebar"
title="Glassmorphism Sidebar"
description="Semi-transparent sidebar with frosted glass effect. Works best with custom chat backgrounds."
after={
<Switch
@@ -823,8 +768,8 @@ function Appearance() {
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Lotus Terminal mode"
description="A retro terminal look: orange and cyan on a dot grid, CRT scanlines and a boot animation (press Esc to skip)."
title="Lotus Terminal Mode"
description="LotusGuild Terminal Design System: Anduril Orange + Ice Cyan + Matrix Green, dot-grid background, CRT scanlines, and boot sequence animation (press Escape to skip)."
after={
<Box direction="Row" gap="200" alignItems="Center">
{lotusTerminal && (
@@ -858,7 +803,7 @@ function Appearance() {
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Interface font"
title="UI Font"
description="Font used throughout the interface."
after={
<SettingsSelect
@@ -878,7 +823,7 @@ function Appearance() {
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="@mention highlight colour"
title="@Mention Highlight Color"
description="Color used to highlight messages that mention you. Leave empty to use the theme default."
after={
<HexColorPickerPopOut
@@ -922,7 +867,7 @@ function Appearance() {
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Custom accent colour"
title="Custom Accent Color"
description={
lotusTerminal
? 'Only applies to non-TDS themes. Disable Lotus Terminal Mode to use a custom accent.'
@@ -1296,7 +1241,7 @@ function SelectDateFormat() {
return (
<>
<SettingTile
title="Date format"
title="Date Format"
description={customDateFormat ? dayjs().format(dateFormatString) : ''}
after={<PresetDateFormat value={selectedDateFormat} onChange={handlePresetChange} />}
/>
@@ -1312,10 +1257,10 @@ function DateAndTime() {
return (
<Box direction="Column" gap="100">
<Text size="L400">Date and time</Text>
<Text size="L400">Date & Time</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="24-hour time"
title="24-Hour Time Format"
after={<Switch variant="Primary" value={hour24Clock} onChange={setHour24Clock} />}
/>
</SequenceCard>
@@ -1527,7 +1472,7 @@ function Editor() {
<Text size="L400">Editor</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Enter for a new line"
title="ENTER for Newline"
description={`Use ${
isMacOS() ? KeySymbol.Command : 'Ctrl'
} + ENTER to send message and ENTER for newline.`}
@@ -1536,20 +1481,20 @@ function Editor() {
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Markdown formatting"
title="Markdown Formatting"
after={<Switch variant="Primary" value={isMarkdown} onChange={setIsMarkdown} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Offer to format pasted code"
title="Offer to Format Pasted Code"
description="When something you paste looks like code, offer to turn it into a code block. Nothing changes unless you accept."
after={<Switch variant="Primary" value={offerCodePaste} onChange={setOfferCodePaste} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Formatting toolbar"
title="Formatting Toolbar"
description="Show bold, italic, code and other formatting buttons above the message input."
after={<Switch variant="Primary" value={editorToolbar} onChange={setEditorToolbar} />}
/>
@@ -1561,7 +1506,7 @@ function Editor() {
gap="300"
>
<SettingTile
title="Composer toolbar"
title="Composer Toolbar"
description="Drag to reorder buttons, and tap a button to show or hide it in the message composer."
/>
<Box direction="Column" style={{ paddingBottom: config.space.S200 }}>
@@ -1596,28 +1541,28 @@ function Privacy() {
<Text size="L400">Privacy</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Strip tracking parameters from links"
title="Strip Tracking Parameters from Links"
description="Remove utm_, fbclid, YouTube si= and other ad/analytics identifiers from links you paste or send, and from links shown in chat. Runs entirely on this device."
after={<Switch variant="Primary" value={stripTracking} onChange={setStripTracking} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Remove photo metadata before sending"
title="Remove Photo Metadata Before Sending"
description="Strip location, camera model and timestamp (EXIF/XMP) from JPEG, PNG and WebP images you send or set as an avatar. Pixels are untouched; the rotation is kept. Videos and GIFs are not covered."
after={<Switch variant="Primary" value={stripImageMeta} onChange={setStripImageMeta} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Hide typing and read receipts"
title="Hide Typing & Read Receipts"
description="Turn off both typing status and read receipts to keep your activity private."
after={<Switch variant="Primary" value={hideActivity} onChange={setHideActivity} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Private read receipts"
title="Private Read Receipts"
description="Send read receipts only to you and the server — other users won't see when you've read messages."
after={
<Switch
@@ -1630,7 +1575,7 @@ function Privacy() {
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Hide online status"
title="Hide Online Status"
description="Appear offline to everyone while still using the app. Disables online, away, and idle indicators."
after={<Switch variant="Primary" value={hidePresence} onChange={setHidePresence} />}
/>
@@ -1712,7 +1657,7 @@ function SettingsSyncSection() {
<Text size="L400">Sync</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Sync settings across devices"
title="Sync Settings Across Devices"
description="Keep your preferences (theme, composer toolbar, notifications, quiet hours, call keys…) the same on every device, stored as account data on your own homeserver. Device-specific settings such as zoom, media auto-load, noise suppression and volumes stay local."
after={<Switch variant="Primary" value={settingsSync} onChange={setSettingsSync} />}
/>
@@ -1720,7 +1665,7 @@ function SettingsSyncSection() {
{settingsSync && (
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Use this device's settings everywhere"
title="Use This Device's Settings Everywhere"
description="Push this device's current preferences so every other device adopts them, even ones that changed something more recently."
// [Gitea #273] On phones the two buttons squeezed the text into a
// narrow column; put them under the description there.
@@ -1855,14 +1800,14 @@ function Calls() {
<Text size="L400">Calls</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Join with camera on"
title="Join with Camera On"
description="Enable camera automatically when joining a voice or video call. Camera is off by default."
after={<Switch variant="Primary" value={cameraOnJoin} onChange={setCameraOnJoin} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Noise suppression"
title="Noise Suppression"
description={
<Box direction="Column" gap="200">
{/* [Gitea #271] T200 like every other setting description. */}
@@ -1919,7 +1864,7 @@ function Calls() {
<Box direction="Column" gap="200">
<Text size="L400">Model</Text>
<SettingTile
title="ML model"
title="ML Model"
description="Choose the machine learning model used for noise removal. Heavier models clean more aggressively at a higher CPU cost."
after={
<SettingsSelect<DenoiseModelId>
@@ -2035,7 +1980,7 @@ function Calls() {
>
<Text size="L400">Enhancements</Text>
<SettingTile
title="Also use the browser's noise suppression"
title="Series Suppression"
description="Run the browser's native stationary noise filter before the ML model. Recommended for eliminating fan hum."
after={
<Switch
@@ -2047,7 +1992,7 @@ function Calls() {
/>
<SettingTile
title="Noise gate"
title="Noise Gate"
description="Hard-cut audio when you aren't speaking to ensure absolute silence between sentences."
after={
<Switch variant="Primary" value={callDenoiseGate} onChange={setCallDenoiseGate} />
@@ -2083,7 +2028,7 @@ function Calls() {
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
}}
>
<Text size="L400">Test and calibrate</Text>
<Text size="L400">Test &amp; calibrate</Text>
<Text size="T200" priority="300">
Audition the selected model and tune the gate without joining a call. Changes to the
settings above apply the next time you start a monitor or play a clip.
@@ -2105,7 +2050,7 @@ function Calls() {
gap="400"
>
<SettingTile
title="Rejoin calls after a restart"
title="After a Restart"
description="If Lotus crashes, reloads or restarts for an update while you are in a voice room, put you back in."
after={
<SettingsSelect<'ask' | 'auto' | 'off'>
@@ -2120,7 +2065,7 @@ function Calls() {
}
/>
<SettingTile
title="Summary after calls"
title="Summary After Calls"
description="When a call ends, show how long it was and how the connection held up, with a Details button for who was there, network numbers and your own talk/mute time."
after={
<Switch variant="Primary" value={callSummaryToast} onChange={setCallSummaryToast} />
@@ -2128,19 +2073,19 @@ function Calls() {
/>
{hapticsSupported() && (
<SettingTile
title="Haptic feedback"
title="Haptic Feedback"
description="A short vibration when push-to-talk engages or releases and when you send a reaction. Off automatically when your system prefers reduced motion."
after={<Switch variant="Primary" value={haptics} onChange={setHaptics} />}
/>
)}
<SettingTile
title="Push to talk"
title="Push to Talk"
description="Mute your microphone by default. Hold the PTT key to speak."
after={<Switch variant="Primary" value={pttMode} onChange={setPttMode} />}
/>
{pttMode && (
<SettingTile
title="Push-to-talk key"
title="PTT Key"
description={pttBind.error ?? 'Press a key to bind it as your push-to-talk key.'}
after={
<Button
@@ -2158,7 +2103,7 @@ function Calls() {
/>
)}
<SettingTile
title="Push to deafen"
title="Push to Deafen"
description={
deafenBind.error ??
(deafenHotkey
@@ -2199,7 +2144,7 @@ function Calls() {
/>
{isTauriEnv() && (
<SettingTile
title="Hotkeys work outside the window"
title="Hotkeys Work Outside the Window"
description="Keep Push to Talk and Push to Deafen working while a game or another app has focus. The keys are only watched during a call and are never blocked from other apps. Windows only; some anti-cheat systems may prevent it."
after={
<Switch variant="Primary" value={globalCallHotkeys} onChange={setGlobalCallHotkeys} />
@@ -2221,13 +2166,13 @@ function Calls() {
}
/>
<SettingTile
title="AFK auto-mute"
title="AFK Auto-Mute"
description="Automatically mute your microphone when you have been silent in a call."
after={<Switch variant="Primary" value={afkAutoMute} onChange={setAfkAutoMute} />}
/>
{afkAutoMute && (
<SettingTile
title="Idle timeout"
title="Idle Timeout"
description="How long to wait before auto-muting."
after={
<SettingsSelect
@@ -2259,7 +2204,7 @@ function Calls() {
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title="Ringtone volume" description="Volume of the incoming call ringtone." />
<SettingTile title="Ringtone Volume" description="Volume of the incoming call ringtone." />
<Box
direction="Row"
alignItems="Center"
@@ -2283,7 +2228,7 @@ function Calls() {
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Join and leave sounds"
title="Join & Leave Sounds"
description="Play a sound when someone joins or leaves a call you are in. Selecting an option plays a preview."
after={
<SettingsSelect
@@ -2302,7 +2247,7 @@ function Calls() {
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Microphone bitrate"
title="Microphone Bitrate"
description="Cap the audio bitrate your mic sends in calls. Lower saves bandwidth; higher is clearer. Auto lets Element Call decide."
after={
<SettingsSelect<CallAudioBitrate>
@@ -2316,7 +2261,7 @@ function Calls() {
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Screen share bitrate"
title="Screenshare Bitrate"
description="Cap the bitrate used when you share your screen. Lower is smoother on poor connections; higher is sharper."
after={
<SettingsSelect<ScreenshareBitrate>
@@ -2330,7 +2275,7 @@ function Calls() {
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Screen share frame rate"
title="Screenshare Framerate"
description="Cap the frames-per-second of your screenshare. 60 fps suits motion/gaming; 15 fps suits slides and saves bandwidth."
after={
<SettingsSelect<ScreenshareFramerate>
@@ -2356,7 +2301,7 @@ function Calls() {
/>
{soundboardEnabled && (
<>
<SettingTile title="Soundboard volume" />
<SettingTile title="Soundboard Volume" />
<Box
alignItems="Center"
gap="200"
@@ -2701,14 +2646,14 @@ function Messages() {
<Box direction="Column" gap="100">
<Text size="L400">Messages</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title="Message layout" after={<SelectMessageLayout />} />
<SettingTile title="Message Layout" after={<SelectMessageLayout />} />
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title="Message spacing" after={<SelectMessageSpacing />} />
<SettingTile title="Message Spacing" after={<SelectMessageSpacing />} />
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Legacy username colour"
title="Legacy Username Color"
after={
<Switch
variant="Primary"
@@ -2720,7 +2665,7 @@ function Messages() {
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Hide joins and leaves"
title="Hide Membership Change"
after={
<Switch
variant="Primary"
@@ -2732,7 +2677,7 @@ function Messages() {
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Hide name and avatar changes"
title="Hide Profile Change"
after={
<Switch
variant="Primary"
@@ -2744,7 +2689,7 @@ function Messages() {
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Don't load media automatically"
title="Disable Media Auto Load"
after={
<Switch
variant="Primary"
@@ -2756,14 +2701,14 @@ function Messages() {
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Link previews"
title="URL Preview"
description="Your homeserver fetches and caches link previews. It will see the URLs of all links you preview."
after={<Switch variant="Primary" value={urlPreview} onChange={setUrlPreview} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Link previews in encrypted rooms"
title="URL Preview in Encrypted Rooms"
description={
<Box direction="Column" gap="100">
<Text size="T200" priority="300">
@@ -2787,29 +2732,34 @@ function Messages() {
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Inline media players"
title="Inline Media Players"
description="Play YouTube, Vimeo, TikTok, Twitch, Spotify and more right in the chat. Nothing loads from the third-party site until you press play."
after={
<Switch variant="Primary" value={inlineMediaEmbeds} onChange={setInlineMediaEmbeds} />
}
/>
</SequenceCard>
{/* [Gitea #457] Only where this build has a GIF key: a disabled switch
with a paragraph about missing configuration helps nobody. */}
{gifAvailable && (
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="GIF picker"
description="Every search term (and your IP address) is sent directly to Giphy, not through your homeserver. Off by default."
after={
<Switch variant="Primary" value={gifPickerEnabled} onChange={setGifPickerEnabled} />
}
/>
</SequenceCard>
)}
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Show hidden events"
title="GIF Picker"
description={
gifAvailable
? 'Every search term (and your IP address) is sent directly to Giphy, not through your homeserver. Off by default.'
: "GIF search isn't set up for this version of the app (no Giphy key in its configuration), so there is no GIF button even when this is on."
}
after={
<Switch
variant="Primary"
value={gifPickerEnabled && gifAvailable}
onChange={setGifPickerEnabled}
disabled={!gifAvailable}
/>
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Show Hidden Events"
after={
<Switch variant="Primary" value={showHiddenEvents} onChange={setShowHiddenEvents} />
}
@@ -2817,7 +2767,7 @@ function Messages() {
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Enforce message retention"
title="Enforce Message Retention"
description="Permanently delete your own messages once a room's retention window (Room Settings → Message Retention) has passed. Off by default; only affects your own messages."
after={
<Switch
@@ -2834,7 +2784,7 @@ function Messages() {
direction="Column"
>
<SettingTile
title="Translate messages into"
title="Translate Messages Into"
description={
translationSupported
? 'Translate messages written in other languages into this one. Runs entirely on your device — message text never leaves your computer or touches a cloud service.'
@@ -2852,7 +2802,7 @@ function Messages() {
/>
{translationSupported && (
<SettingTile
title="Auto-translate incoming messages"
title="Auto-translate Incoming Messages"
description="Automatically translate messages that aren’t already in your language. When off, translate individual messages from their menu. The first time a language is used, its model downloads once (a few MB)."
after={<Switch variant="Primary" value={autoTranslate} onChange={setAutoTranslate} />}
/>
@@ -3013,9 +2963,9 @@ function AppUpdates() {
return (
<Box direction="Column" gap="100">
<Text size="L400">App updates</Text>
<Text size="L400">App Updates</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title="Check for updates" description={description} after={after} />
<SettingTile title="Check for Updates" description={description} after={after} />
{packageUpdate && packageHelp && <PackageUpdate help={packageHelp} />}
{status.state === 'error' && !packageUpdate && (
<Text size="T200" priority="300" style={{ wordBreak: 'break-word' }}>
@@ -3058,6 +3008,7 @@ export function General({ requestClose }: GeneralProps) {
<Privacy />
<StorageUsage />
<SettingsSyncSection />
<YourDataSection />
<Calls />
<DesktopSettings />
<AppUpdates />
@@ -0,0 +1,285 @@
import React, { useCallback, useRef, useState } from 'react';
import { useAtom } from 'jotai';
import { Method } from 'matrix-js-sdk';
import { Box, Button, Text, color } from 'folds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { Settings, settingsAtom } from '../../../state/settings';
import {
buildExport,
EXPORT_ACCOUNT_DATA,
ExportFile,
ImportPlanItem,
mergeData,
parseExport,
planImport,
} from '../../../utils/dataExport';
import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
const DECORATION_FIELD = 'io.lotus.avatar_decoration';
type Current = {
accountData: Record<string, unknown>;
decoration: string | null;
localSettings: Record<string, unknown> | null;
};
/**
* [Gitea #114] "Your data": export the account's Lotus data (settings,
* bookmarks, notes, sections, ...) to a file and import it here or on another
* account. Import merges: new things are added; for things that differ the
* user picks "keep mine" (default) or "use the file's". Message history and
* encryption keys have their own exports.
*/
export function YourDataSection() {
const mx = useMatrixClient();
const userId = mx.getSafeUserId();
const [settings, setSettings] = useAtom(settingsAtom);
const fileInput = useRef<HTMLInputElement>(null);
const [pending, setPending] = useState<{ file: ExportFile; plan: ImportPlanItem[] }>();
const [preferFile, setPreferFile] = useState(false);
const [message, setMessage] = useState<{ tone: 'ok' | 'error'; text: string }>();
const [busy, setBusy] = useState(false);
const readCurrent = useCallback(async (): Promise<Current> => {
const accountData: Record<string, unknown> = {};
EXPORT_ACCOUNT_DATA.forEach(({ type }) => {
const c = mx.getAccountData(type as never)?.getContent();
if (c && Object.keys(c).length > 0) accountData[type] = c;
});
let decoration: string | null = null;
try {
const profile = await mx.http.authedRequest<Record<string, unknown>>(
Method.Get,
`/profile/${encodeURIComponent(userId)}`,
);
const v = profile[DECORATION_FIELD];
decoration = typeof v === 'string' && v ? v : null;
} catch {
/* no decoration */
}
return {
accountData,
decoration,
localSettings: settings as unknown as Record<string, unknown>,
};
}, [mx, userId, settings]);
const exportData = async () => {
setBusy(true);
setMessage(undefined);
try {
const cur = await readCurrent();
const file = buildExport({ userId, ...cur });
const blob = new Blob([JSON.stringify(file, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `lotus-chat-data-${userId.replace(/[^a-z0-9]+/gi, '-')}-${file.exportedAt.slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
setMessage({ tone: 'ok', text: 'Exported. Keep the file somewhere safe.' });
} catch {
setMessage({ tone: 'error', text: "Couldn't export your data. Please try again." });
} finally {
setBusy(false);
}
};
const chooseFile = async (f: File | undefined) => {
if (!f) return;
setMessage(undefined);
const parsed = parseExport(await f.text());
if (!parsed.ok) {
setMessage({ tone: 'error', text: parsed.error });
return;
}
const plan = planImport(parsed.file, await readCurrent());
setPreferFile(false);
setPending({ file: parsed.file, plan });
};
const applyImport = async () => {
if (!pending) return;
setBusy(true);
try {
const cur = await readCurrent();
const { file } = pending;
await Object.entries(file.accountData).reduce<Promise<void>>(
async (prev, [type, incoming]) => {
await prev;
const r = mergeData(cur.accountData[type], incoming, preferFile);
if (r.added > 0 || (preferFile && r.conflicts > 0)) {
await mx.setAccountData(type as never, r.value as never);
}
},
Promise.resolve(),
);
const deco = file.profile.decoration;
if (deco && deco !== cur.decoration && (!cur.decoration || preferFile)) {
await mx.http.authedRequest(
Method.Put,
`/profile/${encodeURIComponent(userId)}/${DECORATION_FIELD}`,
undefined,
{ [DECORATION_FIELD]: deco },
);
invalidateDecorationCache(userId);
}
if (file.local.settings) {
const r = mergeData(cur.localSettings ?? {}, file.local.settings, preferFile);
setSettings({ ...settings, ...(r.value as Partial<Settings>) });
}
setPending(undefined);
setMessage({ tone: 'ok', text: 'Imported.' });
} catch {
setMessage({ tone: 'error', text: "Couldn't finish the import. Nothing else was changed." });
} finally {
setBusy(false);
}
};
const totals = pending?.plan.reduce(
(t, p) => ({ added: t.added + p.added, conflicts: t.conflicts + p.conflicts }),
{ added: 0, conflicts: 0 },
);
return (
<Box direction="Column" gap="100">
<Text size="L400">Your data</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Export your data"
description="Settings, bookmarks, private notes, room sections and names, reminders, status presets, soundboard and emoji, as one file. Not your messages or encryption keys (they have their own exports)."
after={
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
outlined
disabled={busy}
onClick={exportData}
>
<Text size="B300">Export</Text>
</Button>
}
/>
<SettingTile
title="Import data"
description="Load a Lotus Chat data file into this account. New items are added; you choose what happens to things that differ."
after={
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
outlined
disabled={busy}
onClick={() => fileInput.current?.click()}
>
<Text size="B300">Import…</Text>
</Button>
}
/>
<input
ref={fileInput}
type="file"
accept="application/json,.json"
hidden
aria-label="Lotus Chat data file"
onChange={(e) => {
chooseFile(e.target.files?.[0]).catch(() => undefined);
e.target.value = '';
}}
/>
{pending && totals && (
<Box direction="Column" gap="200" role="region" aria-label="Import summary">
{pending.file.userId && pending.file.userId !== userId && (
<Text size="T200">
This file is from <b>{pending.file.userId}</b>.
</Text>
)}
{pending.plan.map((p) => (
<Text key={p.key} size="T200">
{p.label}:{' '}
{p.added === 0 && p.conflicts === 0
? 'already up to date'
: [p.added > 0 && `${p.added} new`, p.conflicts > 0 && `${p.conflicts} differ`]
.filter(Boolean)
.join(', ')}
</Text>
))}
{totals.conflicts > 0 && (
<Box direction="Column" gap="100" role="radiogroup" aria-label="Things that differ">
<Text size="T200">
<b>{totals.conflicts}</b>{' '}
{totals.conflicts === 1 ? 'thing differs' : 'things differ'} from what&apos;s
here:
</Text>
<label>
<input
type="radio"
name="your-data-conflicts"
checked={!preferFile}
onChange={() => setPreferFile(false)}
/>{' '}
<Text as="span" size="T200">
Keep mine
</Text>
</label>
<label>
<input
type="radio"
name="your-data-conflicts"
checked={preferFile}
onChange={() => setPreferFile(true)}
/>{' '}
<Text as="span" size="T200">
Use the file&apos;s
</Text>
</label>
</Box>
)}
<Box gap="200">
<Button
size="300"
variant="Primary"
radii="300"
disabled={busy || (totals.added === 0 && !(preferFile && totals.conflicts > 0))}
onClick={applyImport}
>
<Text size="B300">Import</Text>
</Button>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
disabled={busy}
onClick={() => setPending(undefined)}
>
<Text size="B300">Cancel</Text>
</Button>
</Box>
</Box>
)}
{message && (
<Text
size="T200"
role="status"
style={{ color: message.tone === 'error' ? color.Critical.Main : undefined }}
>
{message.text}
</Text>
)}
</SequenceCard>
</Box>
);
}
@@ -82,7 +82,7 @@ export function AllMessagesNotifications() {
return (
<Box direction="Column" gap="100">
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="L400">All messages</Text>
<Text size="L400">All Messages</Text>
<Box gap="100">
<Text size="T200">Badge: </Text>
<Badge radii="300" variant="Secondary" fill="Solid">
@@ -97,7 +97,7 @@ export function AllMessagesNotifications() {
gap="400"
>
<SettingTile
title="1-to-1 chats"
title="1-to-1 Chats"
after={<AllMessagesModeSwitcher pushRules={pushRules} ruleId={RuleId.DM} oneToOne />}
/>
</SequenceCard>
@@ -108,7 +108,7 @@ export function AllMessagesNotifications() {
gap="400"
>
<SettingTile
title="1-to-1 chats (encrypted)"
title="1-to-1 Chats (Encrypted)"
after={
<AllMessagesModeSwitcher
pushRules={pushRules}
@@ -137,7 +137,7 @@ export function AllMessagesNotifications() {
gap="400"
>
<SettingTile
title="Rooms (encrypted)"
title="Rooms (Encrypted)"
after={
<AllMessagesModeSwitcher
pushRules={pushRules}
@@ -171,7 +171,7 @@ export function KeywordMessagesNotifications() {
return (
<Box direction="Column" gap="100">
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="L400">Keyword messages</Text>
<Text size="L400">Keyword Messages</Text>
<Box gap="100">
<Text size="T200">Badge: </Text>
<Badge radii="300" variant="Success" fill="Solid">
@@ -186,7 +186,7 @@ export function KeywordMessagesNotifications() {
gap="400"
>
<SettingTile
title="Add a keyword"
title="Select Keyword"
description="Set a notification preference for message containing given keyword."
>
<KeywordInput />
@@ -9,6 +9,7 @@ import { KeywordMessagesNotifications } from './KeywordMessages';
import { PushRuleEditor } from './PushRuleEditor';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { settingsAtom, Settings } from '../../../state/settings';
import { notificationSnoozeUntilAtom } from '../../../state/notificationSnooze';
@@ -80,7 +81,7 @@ function NotificationPresets() {
return (
<Box direction="Column" gap="100">
<Text size="L400">Quick presets</Text>
<Text size="L400">Quick Presets</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<Box gap="300" style={{ padding: config.space.S300, flexWrap: 'wrap' }}>
{PRESETS.map((preset) => (
@@ -149,6 +150,17 @@ export function Notifications({ requestClose }: NotificationsProps) {
<SpecialMessagesNotifications />
<KeywordMessagesNotifications />
<PushRuleEditor />
<Box direction="Column" gap="100">
<Text size="L400">Block Messages</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile description='This option has been moved to "Account > Block Users" section.' />
</SequenceCard>
</Box>
</Box>
</PageContent>
</Scroll>
@@ -333,10 +333,10 @@ export function PushRuleEditor() {
return (
<Box direction="Column" gap="100">
<Text size="L400">Advanced push rules</Text>
<RuleSection title="Override rules" kind={PushRuleKind.Override} rules={overrideRules} />
<Text size="L400">Advanced Push Rules</Text>
<RuleSection title="Override Rules" kind={PushRuleKind.Override} rules={overrideRules} />
<RuleSection
title="Room rules"
title="Room Rules"
kind={PushRuleKind.RoomSpecific}
rules={roomRules}
addForm={
@@ -348,7 +348,7 @@ export function PushRuleEditor() {
}
/>
<RuleSection
title="Sender rules"
title="Sender Rules"
kind={PushRuleKind.SenderSpecific}
rules={senderRules}
addForm={
@@ -359,7 +359,7 @@ export function PushRuleEditor() {
/>
}
/>
<RuleSection title="Underride rules" kind={PushRuleKind.Underride} rules={underrideRules} />
<RuleSection title="Underride Rules" kind={PushRuleKind.Underride} rules={underrideRules} />
</Box>
);
}
@@ -124,7 +124,7 @@ export function SpecialMessagesNotifications() {
return (
<Box direction="Column" gap="100">
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="L400">Special messages</Text>
<Text size="L400">Special Messages</Text>
<Box gap="100">
<Text size="T200">Badge: </Text>
<Badge radii="300" variant="Success" fill="Solid">
@@ -63,7 +63,7 @@ function EmailNotification() {
return (
<SettingTile
title="Email notifications"
title="Email Notification"
description={
<>
{result && !result.email && (
@@ -161,7 +161,7 @@ function PauseNotifications() {
return (
<SettingTile
title="Pause notifications"
title="Pause Notifications"
description={<span style={active ? { color: color.Warning.Main } : undefined}>{status}</span>}
after={
active ? (
@@ -221,7 +221,7 @@ export function SystemNotification() {
gap="400"
>
<SettingTile
title="Desktop notifications"
title="Desktop Notifications"
description={
notifPermission === 'denied' ? (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
@@ -230,7 +230,7 @@ export function SystemNotification() {
: 'Notifications are not supported by the system.'}
</Text>
) : (
<span>Show desktop notifications when messages arrive.</span>
<span>Show desktop notifications when message arrive.</span>
)
}
after={
@@ -263,8 +263,8 @@ export function SystemNotification() {
gap="400"
>
<SettingTile
title="Notification sound"
description="Play a sound when a new message arrives."
title="Notification Sound"
description="Play sound when new message arrive."
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
/>
{isNotificationSounds && (
@@ -353,7 +353,7 @@ export function SystemNotification() {
gap="400"
>
<SettingTile
title="Quiet hours"
title="Quiet Hours"
description="Notifications are silenced between these times. Handles overnight (e.g. 11:00 PM – 8:00 AM)."
after={<Switch value={quietHoursEnabled} onChange={setQuietHoursEnabled} />}
/>
+2 -2
View File
@@ -1015,13 +1015,13 @@ function TauriUpdateFeature() {
dismissToast(UPDATE_PROGRESS_TOAST);
if (!failure) return;
// Short on purpose; the full explanation and a Download installer button
// are in Settings → General → App updates.
// are in Settings → General → App Updates.
const busy = failure.phase !== 'install' && isNetworkUpdateError(failure.message);
setToast({
id: UPDATE_FAILED_TOAST,
iconSrc: Icons.Warning,
displayName: 'Update didn’t install',
body: `${busy ? 'The update server seems busy. ' : ''}Click to try again, or get the installer in Settings → General → App updates.`,
body: `${busy ? 'The update server seems busy. ' : ''}Click to try again, or get the installer in Settings → General → App Updates.`,
roomName: 'System',
roomId: '',
sticky: true,
-7
View File
@@ -1,7 +0,0 @@
import { atom } from 'jotai';
/**
* [Gitea #113] Session ids the stale-sessions nudge's Review asked Settings →
* Devices to tick for sign-out. Consumed (and cleared) by OtherDevices.
*/
export const preselectSignOutAtom = atom<string[]>([]);
+76
View File
@@ -0,0 +1,76 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildExport, mergeData, parseExport, planImport } from './dataExport';
const file = buildExport({
userId: '@a:hs',
accountData: {
'io.lotus.bookmarks': { bookmarks: [{ id: 'b1', note: 'x' }] },
'io.lotus.user_notes': { notes: { '@b:hs': 'met at LAN party' } },
'm.direct': { secret: true },
'io.lotus.settings': { theme: 'dark', fontSize: 16 },
},
decoration: 'halo',
localSettings: { callNoiseSuppression: 'ml' },
now: new Date('2026-10-25T00:00:00Z'),
});
test('[#114] export keeps only our own types', () => {
assert.equal(file.format, 'lotus-chat-data');
assert.equal(file.version, 1);
assert.deepEqual(Object.keys(file.accountData).sort(), [
'io.lotus.bookmarks',
'io.lotus.settings',
'io.lotus.user_notes',
]);
assert.equal(file.profile.decoration, 'halo');
});
test('[#114] parse: round trip, wrong files, newer versions, foreign types', () => {
const r = parseExport(JSON.stringify(file));
assert.ok(r.ok);
assert.equal(parseExport('nope').ok, false);
assert.equal(parseExport('{"format":"other"}').ok, false);
const newer = parseExport(JSON.stringify({ ...file, version: 2 }));
assert.ok(!newer.ok && /newer Lotus Chat/.test(newer.error));
const sneaky = parseExport(
JSON.stringify({ ...file, accountData: { 'm.push_rules': {}, 'io.lotus.bookmarks': {} } }),
);
assert.ok(sneaky.ok);
assert.deepEqual(Object.keys(sneaky.file.accountData), ['io.lotus.bookmarks']);
});
test('[#114] merge: lists by id, maps by key, conflicts kept unless preferred', () => {
const mine = { bookmarks: [{ id: 'b1', note: 'mine' }, { id: 'b2' }] };
const theirs = { bookmarks: [{ id: 'b1', note: 'theirs' }, { id: 'b3' }] };
const keep = mergeData(mine, theirs);
assert.equal(keep.added, 1);
assert.equal(keep.conflicts, 1);
assert.deepEqual(keep.value, {
bookmarks: [{ id: 'b1', note: 'mine' }, { id: 'b2' }, { id: 'b3' }],
});
const take = mergeData(mine, theirs, true);
assert.deepEqual((take.value as typeof mine).bookmarks[0], { id: 'b1', note: 'theirs' });
const notes = mergeData({ a: 1, b: 2 }, { b: 3, c: 4 });
assert.deepEqual(notes, { value: { a: 1, b: 2, c: 4 }, added: 1, conflicts: 1 });
assert.deepEqual(mergeData(undefined, { x: 1 }), { value: { x: 1 }, added: 1, conflicts: 0 });
assert.deepEqual(mergeData(['a'], ['a', 'b']).value, ['a', 'b']);
});
test('[#114] plan summarises per category', () => {
const plan = planImport(file, {
accountData: { 'io.lotus.settings': { theme: 'light' } },
decoration: null,
localSettings: { callNoiseSuppression: 'ml' },
});
const by = Object.fromEntries(plan.map((p) => [p.key, p]));
assert.deepEqual(
[by['io.lotus.settings'].added, by['io.lotus.settings'].conflicts],
[1, 1],
'fontSize new, theme differs',
);
assert.equal(by['io.lotus.bookmarks'].added, 1);
assert.equal(by['profile.decoration'].added, 1);
assert.deepEqual([by['local.settings'].added, by['local.settings'].conflicts], [0, 0]);
});
+219
View File
@@ -0,0 +1,219 @@
/**
* [Gitea #114] Export / import "your data": the Lotus-side personal data of
* an account (not message history, not encryption keys: those have their own
* exports, and keys are secret). One JSON file:
*
* { format: 'lotus-chat-data', version: 1, exportedAt, userId,
* accountData: { [type]: content }, profile: { decoration }, local: { settings } }
*
* Import MERGES: anything missing here is added; a value that differs is a
* conflict, kept as it is unless the user chooses the file's. Pure helpers.
*/
export const EXPORT_FORMAT = 'lotus-chat-data';
export const EXPORT_VERSION = 1;
/** The account-data types that are the user's own Lotus data, with labels. */
export const EXPORT_ACCOUNT_DATA: { type: string; label: string }[] = [
{ type: 'io.lotus.settings', label: 'Synced settings' },
{ type: 'io.lotus.bookmarks', label: 'Bookmarks' },
{ type: 'io.lotus.user_notes', label: 'Private notes about people' },
{ type: 'io.lotus.room_names', label: 'Your names for rooms' },
{ type: 'in.cinny.spaces', label: 'Room sections and sidebar' },
{ type: 'io.lotus.reminders', label: 'Reminders' },
{ type: 'io.lotus.status_presets', label: 'Status presets' },
{ type: 'io.lotus.thread_notifications', label: 'Thread notification settings' },
{ type: 'io.lotus.mute_timers', label: 'Timed mutes' },
{ type: 'io.lotus.soundboard', label: 'Soundboard (clips stay on the server)' },
{ type: 'io.lotus.soundboard_rooms', label: 'Soundboards enabled everywhere' },
{ type: 'im.ponies.user_emotes', label: 'Your emoji and stickers' },
{ type: 'im.ponies.emote_rooms', label: 'Emoji packs enabled everywhere' },
];
export type ExportFile = {
format: typeof EXPORT_FORMAT;
version: number;
exportedAt: string;
userId: string;
accountData: Record<string, unknown>;
profile: { decoration: string | null };
local: { settings: Record<string, unknown> | null };
};
export const buildExport = (input: {
userId: string;
accountData: Record<string, unknown>;
decoration: string | null;
localSettings: Record<string, unknown> | null;
now?: Date;
}): ExportFile => {
const accountData: Record<string, unknown> = {};
EXPORT_ACCOUNT_DATA.forEach(({ type }) => {
const c = input.accountData[type];
if (c !== undefined && c !== null) accountData[type] = c;
});
return {
format: EXPORT_FORMAT,
version: EXPORT_VERSION,
exportedAt: (input.now ?? new Date()).toISOString(),
userId: input.userId,
accountData,
profile: { decoration: input.decoration },
local: { settings: input.localSettings },
};
};
const isObject = (v: unknown): v is Record<string, unknown> =>
!!v && typeof v === 'object' && !Array.isArray(v);
export type ParseResult = { ok: true; file: ExportFile } | { ok: false; error: string };
/** Validate an imported file; older versions would be migrated here. */
export const parseExport = (text: string): ParseResult => {
let v: unknown;
try {
v = JSON.parse(text);
} catch {
return { ok: false, error: "This isn't a Lotus Chat data file (it isn't valid JSON)." };
}
if (!isObject(v) || v.format !== EXPORT_FORMAT)
return { ok: false, error: "This isn't a Lotus Chat data file." };
if (typeof v.version !== 'number' || v.version < 1)
return { ok: false, error: 'This data file is damaged (no version).' };
if (v.version > EXPORT_VERSION)
return {
ok: false,
error: 'This file was made by a newer Lotus Chat. Update the app, then import it again.',
};
const accountData = isObject(v.accountData) ? v.accountData : {};
const known = new Set(EXPORT_ACCOUNT_DATA.map((t) => t.type));
const filtered: Record<string, unknown> = {};
Object.entries(accountData).forEach(([type, content]) => {
// Only our own types: a file can't be used to write arbitrary account data.
if (known.has(type) && content !== null && content !== undefined) filtered[type] = content;
});
const profile = isObject(v.profile) ? v.profile : {};
const local = isObject(v.local) ? v.local : {};
return {
ok: true,
file: {
format: EXPORT_FORMAT,
version: v.version,
exportedAt: typeof v.exportedAt === 'string' ? v.exportedAt : '',
userId: typeof v.userId === 'string' ? v.userId : '',
accountData: filtered,
profile: {
decoration: typeof profile.decoration === 'string' ? profile.decoration : null,
},
local: { settings: isObject(local.settings) ? local.settings : null },
},
};
};
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
const itemKey = (v: unknown): string => {
if (isObject(v)) {
const id = v.id ?? v.eventId ?? v.event_id ?? v.key ?? v.roomId ?? v.room_id ?? v.mxc;
if (typeof id === 'string' || typeof id === 'number') return `id:${String(id)}`;
}
return `json:${JSON.stringify(v)}`;
};
export type MergeResult = { value: unknown; added: number; conflicts: number };
/**
* Pure: merge `incoming` into `current`. Lists: add items that are missing
* (matched by id, else by content). Objects: add missing keys, recurse into
* shared ones. A differing leaf is a conflict: `preferIncoming` decides.
*/
export const mergeData = (
current: unknown,
incoming: unknown,
preferIncoming = false,
): MergeResult => {
if (current === undefined || current === null) {
return { value: incoming, added: incoming === undefined ? 0 : 1, conflicts: 0 };
}
if (Array.isArray(current) && Array.isArray(incoming)) {
const have = new Map(current.map((v, i) => [itemKey(v), i]));
const out = [...current];
let added = 0;
let conflicts = 0;
incoming.forEach((item) => {
const k = itemKey(item);
const at = have.get(k);
if (at === undefined) {
out.push(item);
have.set(k, out.length - 1);
added += 1;
} else if (!same(out[at], item)) {
conflicts += 1;
if (preferIncoming) out[at] = item;
}
});
return { value: out, added, conflicts };
}
if (isObject(current) && isObject(incoming)) {
const out: Record<string, unknown> = { ...current };
let added = 0;
let conflicts = 0;
Object.entries(incoming).forEach(([k, v]) => {
if (!(k in current)) {
out[k] = v;
added += 1;
return;
}
const r = mergeData(current[k], v, preferIncoming);
out[k] = r.value;
added += r.added;
conflicts += r.conflicts;
});
return { value: out, added, conflicts };
}
if (same(current, incoming)) return { value: current, added: 0, conflicts: 0 };
return { value: preferIncoming ? incoming : current, added: 0, conflicts: 1 };
};
export type ImportPlanItem = {
key: string;
label: string;
added: number;
conflicts: number;
};
/** What importing `file` into the current data would change, per category. */
export const planImport = (
file: ExportFile,
current: {
accountData: Record<string, unknown>;
decoration: string | null;
localSettings: Record<string, unknown> | null;
},
): ImportPlanItem[] => {
const items: ImportPlanItem[] = [];
EXPORT_ACCOUNT_DATA.forEach(({ type, label }) => {
if (!(type in file.accountData)) return;
const r = mergeData(current.accountData[type], file.accountData[type]);
items.push({ key: type, label, added: r.added, conflicts: r.conflicts });
});
if (file.profile.decoration !== null) {
const differs = current.decoration !== file.profile.decoration;
items.push({
key: 'profile.decoration',
label: 'Avatar decoration',
added: !current.decoration && differs ? 1 : 0,
conflicts: current.decoration && differs ? 1 : 0,
});
}
if (file.local.settings) {
const r = mergeData(current.localSettings ?? {}, file.local.settings);
items.push({
key: 'local.settings',
label: "This device's settings",
added: r.added,
conflicts: r.conflicts,
});
}
return items;
};
-45
View File
@@ -8,9 +8,6 @@ import {
parseNudgeRecord,
SecurityState,
shouldShowNudge,
pickNudge,
staleSessions,
staleSessionsText,
} from './securityNudge';
const healthy: SecurityState = {
@@ -82,45 +79,3 @@ test('stored record: round trip, and garbage starts a fresh grace period', () =>
assert.deepEqual(parseNudgeRecord(bad, t0), { firstSeen: t0, dismissals: {} }, bad);
}
});
test('[#113] stale sessions: 90+ days unused, never the current one', () => {
const now = Date.UTC(2026, 9, 25);
const day = 86_400_000;
const devices = [
{ device_id: 'ME', last_seen_ts: now - 400 * day },
{ device_id: 'OLD', last_seen_ts: now - 91 * day },
{ device_id: 'EDGE', last_seen_ts: now - 90 * day },
{ device_id: 'RECENT', last_seen_ts: now - 89 * day },
{ device_id: 'UNKNOWN' },
{ device_id: 'NULL', last_seen_ts: null },
];
assert.deepEqual(
staleSessions(devices, 'ME', now).map((d) => d.device_id),
['OLD', 'EDGE'],
);
});
test('[#113] wording follows the count', () => {
assert.match(
staleSessionsText(1),
/^You have a session you haven't used in 3 months\. Signing it out/,
);
assert.match(
staleSessionsText(3),
/^You have 3 sessions you haven't used in 3 months\. Signing them out/,
);
});
test('[#113] pickNudge: security first, stale sessions when that one is snoozed or absent', () => {
const now = 10 * 86_400_000;
const fresh = { firstSeen: 0, dismissals: {} };
assert.equal(pickNudge('verify-device', 2, fresh, now), 'verify-device');
assert.equal(pickNudge(undefined, 2, fresh, now), 'stale-sessions');
assert.equal(pickNudge(undefined, 0, fresh, now), undefined);
const snoozed = dismissNudge('verify-device', fresh, now);
assert.equal(pickNudge('verify-device', 2, snoozed, now), 'stale-sessions');
const bothSnoozed = dismissNudge('stale-sessions', snoozed, now);
assert.equal(pickNudge('verify-device', 2, bothSnoozed, now), undefined);
// inside the 24 h grace period nothing shows
assert.equal(pickNudge(undefined, 2, { firstSeen: now, dismissals: {} }, now), undefined);
});
+1 -54
View File
@@ -9,15 +9,12 @@
* - 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 → Devices 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 NudgeKind = 'verify-device' | 'connect-backup' | 'setup-backup';
export type SecurityState = {
/** m.cross_signing.master account data present. */
@@ -41,51 +38,6 @@ export const nudgeFor = (s: SecurityState): NudgeKind | 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;
@@ -135,11 +87,6 @@ export const NUDGE_COPY: Record<NudgeKind, { text: string; action: string }> = {
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',