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; 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 | null = null; let timer: ReturnType | 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(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; clearRemote: () => Promise; } { 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 }; }