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
168 lines
6.2 KiB
TypeScript
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 };
|
|
}
|