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:
@@ -0,0 +1,167 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useStore } from 'jotai';
|
||||
import { ClientEvent, MatrixEvent } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { getAccountData, setAccountData } from '../utils/accountData';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { Settings, getSettings, settingsAtom } from '../state/settings';
|
||||
import {
|
||||
SETTINGS_SYNC_EVENT,
|
||||
buildSyncedContent,
|
||||
isSyncedSettingsContent,
|
||||
mergeRemoteSettings,
|
||||
pickSyncable,
|
||||
shouldApplyRemote,
|
||||
syncableEqual,
|
||||
} from '../utils/settingsSync';
|
||||
|
||||
// Keyed per account: a different user signing in on this device must not
|
||||
// inherit the previous user's "last synced" stamp.
|
||||
const metaKey = (userId: string): string => `settings-sync-meta:${userId}`;
|
||||
const PUSH_DEBOUNCE_MS = 1500;
|
||||
|
||||
type SyncMeta = { lastSyncedAt: number };
|
||||
|
||||
const readMeta = (userId: string): SyncMeta | null => {
|
||||
try {
|
||||
const raw = localStorage.getItem(metaKey(userId));
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<SyncMeta>;
|
||||
return typeof parsed.lastSyncedAt === 'number' ? { lastSyncedAt: parsed.lastSyncedAt } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const writeMeta = (userId: string, meta: SyncMeta): void => {
|
||||
try {
|
||||
localStorage.setItem(metaKey(userId), JSON.stringify(meta));
|
||||
} catch {
|
||||
/* quota / blocked storage — sync still works for this session */
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #104] Keeps the syncable subset of Settings mirrored to the
|
||||
* `io.lotus.settings` account-data event and applies snapshots other devices
|
||||
* push. Mount once (ClientNonUIFeatures) while a client is running.
|
||||
*
|
||||
* Flow:
|
||||
* - start: if the remote snapshot is newer than what this device last synced,
|
||||
* apply it; otherwise push local if it differs from remote.
|
||||
* - local change (any settingsAtom write): debounce, then push if the syncable
|
||||
* subset differs from the last pushed/applied snapshot — so applying a
|
||||
* remote snapshot never echoes it straight back.
|
||||
* - remote AccountData event: apply if newer than our last sync stamp (our own
|
||||
* push comes back with an equal stamp and is ignored).
|
||||
* Stamps are wall-clock ms but forced monotonic per device, so a device with a
|
||||
* slow clock can still win once it makes a later change.
|
||||
*/
|
||||
export function useSettingsSync(): void {
|
||||
const mx = useMatrixClient();
|
||||
const store = useStore();
|
||||
const [enabled] = useSetting(settingsAtom, 'settingsSync');
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
const userId = mx.getUserId();
|
||||
if (!userId) return undefined;
|
||||
|
||||
let lastSyncedAt: number | null = readMeta(userId)?.lastSyncedAt ?? null;
|
||||
let lastSnapshot: Partial<Settings> | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let disposed = false;
|
||||
|
||||
const stamp = (): number => Math.max(Date.now(), (lastSyncedAt ?? 0) + 1);
|
||||
|
||||
const applyRemote = (content: unknown): boolean => {
|
||||
if (!isSyncedSettingsContent(content)) return false;
|
||||
if (!shouldApplyRemote(content, lastSyncedAt)) return false;
|
||||
const merged = mergeRemoteSettings(store.get(settingsAtom), content);
|
||||
lastSyncedAt = content.updatedAt;
|
||||
writeMeta(userId, { lastSyncedAt });
|
||||
store.set(settingsAtom, merged);
|
||||
// Re-read through getSettings() so enum coercion applies to whatever the
|
||||
// other device sent, and remember that coerced view as "already synced".
|
||||
const coerced = getSettings();
|
||||
store.set(settingsAtom, coerced);
|
||||
lastSnapshot = pickSyncable(coerced);
|
||||
return true;
|
||||
};
|
||||
|
||||
const push = (): void => {
|
||||
if (disposed) return;
|
||||
const current = store.get(settingsAtom);
|
||||
const syncable = pickSyncable(current);
|
||||
if (lastSnapshot && syncableEqual(syncable, lastSnapshot)) return;
|
||||
const content = buildSyncedContent(current, stamp());
|
||||
const previousSnapshot = lastSnapshot;
|
||||
const previousStamp = lastSyncedAt;
|
||||
lastSnapshot = content.settings;
|
||||
lastSyncedAt = content.updatedAt;
|
||||
writeMeta(userId, { lastSyncedAt });
|
||||
setAccountData(mx, SETTINGS_SYNC_EVENT, content).catch(() => {
|
||||
// Roll back so the next local change (or reload) retries the push.
|
||||
if (disposed) return;
|
||||
lastSnapshot = previousSnapshot;
|
||||
lastSyncedAt = previousStamp;
|
||||
if (previousStamp !== null) writeMeta(userId, { lastSyncedAt: previousStamp });
|
||||
});
|
||||
};
|
||||
|
||||
const schedulePush = (): void => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(push, PUSH_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
// Initial reconcile.
|
||||
const existing = getAccountData<unknown>(mx, SETTINGS_SYNC_EVENT);
|
||||
if (!applyRemote(existing)) {
|
||||
// Remote is absent, invalid, or not newer than what we last synced: treat
|
||||
// it as the baseline and push only if this device differs from it.
|
||||
lastSnapshot = isSyncedSettingsContent(existing) ? existing.settings : null;
|
||||
push();
|
||||
}
|
||||
|
||||
const unsubscribe = store.sub(settingsAtom, schedulePush);
|
||||
const handleAccountData = (evt: MatrixEvent): void => {
|
||||
if (evt.getType() !== SETTINGS_SYNC_EVENT) return;
|
||||
applyRemote(evt.getContent());
|
||||
};
|
||||
mx.on(ClientEvent.AccountData, handleAccountData);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
unsubscribe();
|
||||
mx.off(ClientEvent.AccountData, handleAccountData);
|
||||
};
|
||||
}, [mx, store, enabled]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual actions for Settings → General → Sync. `pushNow` stamps a fresh
|
||||
* snapshot of THIS device so it wins on every other device; `clearRemote`
|
||||
* blanks the account-data event (other devices ignore the invalid content and
|
||||
* keep their local settings until someone pushes again).
|
||||
*/
|
||||
export function useSettingsSyncActions(): {
|
||||
pushNow: () => Promise<void>;
|
||||
clearRemote: () => Promise<void>;
|
||||
} {
|
||||
const mx = useMatrixClient();
|
||||
const store = useStore();
|
||||
|
||||
const pushNow = useCallback(async () => {
|
||||
const content = buildSyncedContent(store.get(settingsAtom), Date.now());
|
||||
await setAccountData(mx, SETTINGS_SYNC_EVENT, content);
|
||||
const userId = mx.getUserId();
|
||||
if (userId) writeMeta(userId, { lastSyncedAt: content.updatedAt });
|
||||
}, [mx, store]);
|
||||
|
||||
const clearRemote = useCallback(async () => {
|
||||
await setAccountData(mx, SETTINGS_SYNC_EVENT, {});
|
||||
}, [mx]);
|
||||
|
||||
return { pushNow, clearRemote };
|
||||
}
|
||||
Reference in New Issue
Block a user