Files
cinny/src/app/hooks/useSettingsSync.ts
T
jaredandClaude Opus 5 1ff28820f3
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
feat(settings): sync preferences across devices via io.lotus.settings account data (#104)
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
2026-09-17 01:56:06 -04:00

168 lines
6.2 KiB
TypeScript

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 };
}