Merge pull request 'feat(settings): export / import your data (#114)' (#454) from feat/data-export into lotus
CI / Build & Quality Checks (push) Successful in 3m50s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 17s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s

This commit was merged in pull request #454.
This commit is contained in:
2026-10-25 02:16:03 -04:00
5 changed files with 680 additions and 0 deletions
@@ -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,
@@ -3007,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>
);
}