import { Settings } from '../state/settings'; /** * [Gitea #104] Settings sync — pure helpers. * * Lotus settings live in `localStorage` per device. A syncable subset is * mirrored to the account-data event `io.lotus.settings` on the user's own * homeserver so a second device picks it up. Everything device-bound stays * local (see DEVICE_LOCAL_KEYS). Conflicts are last-write-wins on the * `updatedAt` stamp inside the event; a device that applied a remote snapshot * remembers it so the resulting local change is not echoed straight back. */ export const SETTINGS_SYNC_EVENT = 'io.lotus.settings'; export const SETTINGS_SYNC_VERSION = 1; export type SyncedSettingsContent = { version: number; updatedAt: number; settings: Partial; }; // Keys that describe THIS device (display, hardware, bandwidth, CPU budget, // transient UI state, per-device permissions) rather than the user's // preferences. Never written to, or read from, the synced event. The sync // toggle itself is local too, so turning it off on one device is not undone by // another. export const DEVICE_LOCAL_KEYS: ReadonlySet = new Set([ 'settingsSync', 'globalCallHotkeys', 'pageZoom', 'mediaAutoLoad', 'pauseAnimations', 'glassmorphismSidebar', 'isPeopleDrawer', 'memberSortFilterIndex', 'presenceStatus', 'showNotifications', 'developerTools', 'pttMode', 'callNoiseSuppression', 'callDenoiseModel', 'callDenoiseNativeNS', 'callDenoiseGate', 'callDenoiseGateThreshold', 'callAudioBitrate', 'screenshareBitrate', 'screenshareFramerate', 'ringtoneVolume', 'soundboardVolume', 'cameraOnJoin', ]); /** The subset of `settings` that is synced (deep-copied so callers can't alias). */ export const pickSyncable = (settings: Settings): Partial => { const out: Partial = {}; (Object.keys(settings) as (keyof Settings)[]).forEach((key) => { if (DEVICE_LOCAL_KEYS.has(key)) return; const value = settings[key]; if (value === undefined) return; (out as Record)[key] = typeof value === 'object' && value !== null ? JSON.parse(JSON.stringify(value)) : value; }); return out; }; /** Structural equality of two syncable subsets (key order independent). */ export const syncableEqual = (a: Partial, b: Partial): boolean => { const ka = Object.keys(a).sort(); const kb = Object.keys(b).sort(); if (ka.length !== kb.length) return false; for (let i = 0; i < ka.length; i += 1) { if (ka[i] !== kb[i]) return false; const va = (a as Record)[ka[i]]; const vb = (b as Record)[kb[i]]; if (JSON.stringify(va) !== JSON.stringify(vb)) return false; } return true; }; /** Type guard for what comes back from account data (any client, any version). */ export const isSyncedSettingsContent = (content: unknown): content is SyncedSettingsContent => { if (typeof content !== 'object' || content === null) return false; const c = content as Record; return ( typeof c.version === 'number' && typeof c.updatedAt === 'number' && Number.isFinite(c.updatedAt) && typeof c.settings === 'object' && c.settings !== null && !Array.isArray(c.settings) ); }; /** * Overlay a remote snapshot onto the local settings. Only syncable keys the * local schema knows about are taken (so a newer client's extra keys, or a * device-local key smuggled in by a buggy writer, are ignored). Values are * copied verbatim; the caller runs the result through the normal * `getSettings()` coercion by persisting it, which is what validates enums. */ export const mergeRemoteSettings = (local: Settings, remote: SyncedSettingsContent): Settings => { const merged: Settings = { ...local }; (Object.keys(remote.settings) as (keyof Settings)[]).forEach((key) => { if (DEVICE_LOCAL_KEYS.has(key)) return; if (!(key in local)) return; const value = remote.settings[key]; if (value === undefined) return; // Shape guard: a value of a different JS type than this build stores for // the key (e.g. a renamed enum that became an object) is skipped; enum // *values* are validated by getSettings() coercion on the next load. const current = local[key]; if (current !== undefined && current !== null && typeof value !== typeof current) return; if (Array.isArray(current) !== Array.isArray(value)) return; (merged as unknown as Record)[key] = value; }); return merged; }; export const buildSyncedContent = ( settings: Settings, updatedAt: number = Date.now(), ): SyncedSettingsContent => ({ version: SETTINGS_SYNC_VERSION, updatedAt, settings: pickSyncable(settings), }); /** * Should a remote snapshot be applied over what this device last synced? * `lastSyncedAt` is the stamp of the snapshot this device last pushed or * applied; anything older or equal is our own echo (or a stale write that lost * the race) and is ignored. */ export const shouldApplyRemote = ( remote: SyncedSettingsContent, lastSyncedAt: number | null, ): boolean => lastSyncedAt === null || remote.updatedAt > lastSyncedAt;