feat(settings): sync preferences across devices via io.lotus.settings account data (#104)
CI / Build & Quality Checks (push) Successful in 1m27s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 1m36s
CI / Build & Quality Checks (push) Successful in 1m27s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 1m36s
Every Lotus setting was localStorage-only, so a user on web + desktop + phone configured theme, composer toolbar, quiet hours, call keys… three times. - utils/settingsSync.ts (pure, 7 tests): DEVICE_LOCAL_KEYS denylist (zoom, media auto-load, animation pause, glassmorphism, denoise tier/model, bitrates, volumes, notification permission, developer tools, PTT mode, camera-on-join, drawer state, and the sync toggle itself), pickSyncable, mergeRemoteSettings (unknown keys, device-local keys and wrong-shaped values are skipped), buildSyncedContent, shouldApplyRemote (LWW on updatedAt; equal stamp = our own echo). - hooks/useSettingsSync.ts: on start applies a newer remote snapshot or pushes local if it differs; debounced push on any settingsAtom write, skipped when the syncable subset equals the last pushed/applied snapshot so a remote apply never echoes back; AccountData listener for live updates; stamps forced monotonic per device; per-account lastSyncedAt marker so another user on the same device can't inherit it; failed pushes roll the marker back so the next change retries. Remote values are re-read through getSettings() so enum coercion applies. - Settings → General → Sync: toggle (device-local), "Push now", "Clear synced copy". AccountDataEvent.LotusSettings registered. - ClientNonUIFeatures: the #103 tracking-param subscriber moves out of PageZoomFeature into its own TrackingParamsFeature next to SettingsSyncFeature. - Docs: LOTUS_FEATURES entries for #103/#104; LOTUS_TODO links the new Features 2026-Q4 milestone and #108. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -59,6 +59,7 @@ import {
|
||||
ML_DENOISE_REQUIREMENTS,
|
||||
} from '../../../utils/lotusDenoiseUtils';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { useSettingsSyncActions } from '../../../hooks/useSettingsSync';
|
||||
import {
|
||||
CallAudioBitrate,
|
||||
ChatBackground,
|
||||
@@ -1495,6 +1496,90 @@ function Privacy() {
|
||||
);
|
||||
}
|
||||
|
||||
// [Gitea #104] Sync toggle + the two manual actions. The toggle is itself
|
||||
// device-local (never synced) so switching it off here sticks.
|
||||
function SettingsSyncSection() {
|
||||
const [settingsSync, setSettingsSync] = useSetting(settingsAtom, 'settingsSync');
|
||||
const { pushNow, clearRemote } = useSettingsSyncActions();
|
||||
const [busy, setBusy] = useState<'push' | 'clear' | null>(null);
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
|
||||
const run = async (action: 'push' | 'clear') => {
|
||||
setBusy(action);
|
||||
setNote(null);
|
||||
try {
|
||||
if (action === 'push') {
|
||||
await pushNow();
|
||||
setNote('Pushed — other devices will pick these settings up on their next sync.');
|
||||
} else {
|
||||
await clearRemote();
|
||||
setNote('Cleared — devices keep their current settings until one pushes again.');
|
||||
}
|
||||
} catch {
|
||||
setNote('Failed — check your connection and try again.');
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Sync</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
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} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
{settingsSync && (
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
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."
|
||||
after={
|
||||
<Box gap="200">
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
outlined
|
||||
radii="300"
|
||||
disabled={busy !== null}
|
||||
before={busy === 'push' ? <Spinner size="100" variant="Secondary" /> : undefined}
|
||||
onClick={() => run('push')}
|
||||
>
|
||||
<Text size="B300">Push now</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Critical"
|
||||
fill="None"
|
||||
radii="300"
|
||||
disabled={busy !== null}
|
||||
before={busy === 'clear' ? <Spinner size="100" variant="Critical" /> : undefined}
|
||||
onClick={() => run('clear')}
|
||||
>
|
||||
<Text size="B300">Clear synced copy</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
{note && (
|
||||
<Text
|
||||
size="T200"
|
||||
priority="300"
|
||||
style={{ padding: `0 ${config.space.S300} ${config.space.S300}` }}
|
||||
>
|
||||
{note}
|
||||
</Text>
|
||||
)}
|
||||
</SequenceCard>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// [Gitea #23] Denylist navigation-critical/modifier codes and reject a code that
|
||||
// collides with the other call key (`otherKey`), so a rebind can never trap
|
||||
// keyboard focus in-call or silently double-bind PTT and deafen to the same key.
|
||||
@@ -2596,6 +2681,7 @@ export function General({ requestClose }: GeneralProps) {
|
||||
<Editor />
|
||||
<Messages />
|
||||
<Privacy />
|
||||
<SettingsSyncSection />
|
||||
<Calls />
|
||||
<AppUpdates />
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user