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

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:
2026-09-17 01:56:06 -04:00
co-authored by Claude Opus 5
parent 5b0d355417
commit 1ff28820f3
9 changed files with 532 additions and 4 deletions
+8
View File
@@ -1171,6 +1171,14 @@ Persists via the `homeRoomSort` setting.
A toggle in **Settings → Privacy** switches between sending `m.read` (public receipts) and `m.read.private` (private receipts visible only to the sender and the server).
### Tracking-Parameter Stripping (Gitea #103)
Links you paste, send, edit, or merely _see_ lose ad/analytics identifiers — `utm_*`, `fbclid`, `gclid`, YouTube `si=`, Amazon `ref=`/`tag=`, X `s=`/`t=`, TikTok `_r`/`_t`, and ~40 more, plus host-scoped rules so e.g. `si` is only removed on YouTube/Spotify. Runs entirely on the device (`src/app/utils/urlTracking.ts`, unit-tested). Wired at paste (re-inserted through Slate so multi-line pastes still split into paragraphs), at send/schedule/edit on both `body` and `formatted_body`, and at render (linkify + explicit `<a href>` in formatted HTML), so links from other clients are cleaned locally too. `matrix.to` and non-http(s) schemes are never touched; Amazon's `th`/`psc` variant selectors are kept. Toggle in **Settings → Privacy → Strip Tracking Parameters from Links** (default on).
### Settings Sync Across Devices (Gitea #104)
The syncable subset of Lotus settings (theme, composer toolbar order, notification/quiet-hour preferences, call keys, privacy toggles, …) is mirrored to the `io.lotus.settings` account-data event on the user's own homeserver and applied on every other device. Device-bound keys stay local (`DEVICE_LOCAL_KEYS` in `src/app/utils/settingsSync.ts`: page zoom, media auto-load, animation pause, glassmorphism, noise-suppression tier/model, bitrates, volumes, notification permission, developer tools, PTT mode, camera-on-join, drawer state). Conflicts are last-write-wins on an `updatedAt` stamp forced monotonic per device; a per-account `lastSyncedAt` marker in localStorage stops a device from echoing a snapshot it just applied. **Settings → General → Sync** has the toggle (itself device-local), **Push now** (make this device win everywhere) and **Clear synced copy**. Hook: `src/app/hooks/useSettingsSync.ts`, mounted from `ClientNonUIFeatures`.
### Media Gallery
`MediaGallery.tsx` — a right-side drawer for browsing room media.
+3 -1
View File
@@ -236,7 +236,7 @@ After Phases AC the client spec is ~complete. What's left, flagged by **what
**✅ Buildable NOW (client-only, no server/infra change):**
- [ ] **Custom room tags / sections** — user-defined room categories in the sidebar via standard `u.*` room tags (beyond the built-in Favourite / Low-Priority). Mirrors the favourite/low-priority category pattern (`RoomNavItem` context-menu + `Home.tsx` categories). _Medium._ The only substantive client-only feature left.
- [ ] **Custom room tags / sections** (Gitea **#108**, milestone _Features 2026-Q4_) — user-defined room categories in the sidebar via standard `u.*` room tags (beyond the built-in Favourite / Low-Priority). Mirrors the favourite/low-priority category pattern (`RoomNavItem` context-menu + `Home.tsx` categories). _Medium._ The only substantive client-only feature left.
**🔧 Needs INFRASTRUCTURE (NOT a Synapse-flag flip — you'd have to stand it up):**
@@ -258,6 +258,8 @@ After Phases AC the client spec is ~complete. What's left, flagged by **what
## 📋 Open Feature Backlog
**Features 2026-Q4 milestone (Gitea):** #103 tracking-param stripping ✅ · #104 settings sync ✅ · #105 desktop keychain + idle lock (research) · #106 search operators (research) · #107 paste-as-code (research) · #108 custom room sections.
### [ ] Basic in-app audio editor / video→audio extractor (LARGE PROJECT)
A minimal audio editor for soundboard clips and voice content. Scope: (1) **trim/clip** an audio file to a chosen start/end (waveform scrubber, in/out handles); (2) **upload a video file → strip and discard the video track, keep only the audio** (extract audio, then the source video is dropped — never uploaded/stored); (3) minimal edits only (trim, maybe gain/normalize, fade in/out) — not a full DAW. Likely Web Audio API (`AudioContext.decodeAudioData` → trim `AudioBuffer` → re-encode) + `MediaRecorder`/an encoder for output; video demux via a `<video>`+`MediaElementSource` capture or ffmpeg.wasm (weigh bundle cost). Feeds the soundboard uploader (`utils/soundboardClips.ts`, `SoundboardPackEditor`) and attachments. Design under TDS + native-cinny law. Big build — plan a dedicated session; evaluate ffmpeg.wasm size/CSP (wasm) before committing.
@@ -59,6 +59,7 @@ import {
ML_DENOISE_REQUIREMENTS,
} from '../../../utils/lotusDenoiseUtils';
import { useSetting } from '../../../state/hooks/settings';
import { useSettingsSyncActions } from '../../../hooks/useSettingsSync';
import {
CallAudioBitrate,
ChatBackground,
@@ -1495,6 +1496,90 @@ function Privacy() {
);
}
// [Gitea #104] Sync toggle + the two manual actions. The toggle is itself
// device-local (never synced) so switching it off here sticks.
function SettingsSyncSection() {
const [settingsSync, setSettingsSync] = useSetting(settingsAtom, 'settingsSync');
const { pushNow, clearRemote } = useSettingsSyncActions();
const [busy, setBusy] = useState<'push' | 'clear' | null>(null);
const [note, setNote] = useState<string | null>(null);
const run = async (action: 'push' | 'clear') => {
setBusy(action);
setNote(null);
try {
if (action === 'push') {
await pushNow();
setNote('Pushed — other devices will pick these settings up on their next sync.');
} else {
await clearRemote();
setNote('Cleared — devices keep their current settings until one pushes again.');
}
} catch {
setNote('Failed — check your connection and try again.');
} finally {
setBusy(null);
}
};
return (
<Box direction="Column" gap="100">
<Text size="L400">Sync</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Sync Settings Across Devices"
description="Keep your preferences (theme, composer toolbar, notifications, quiet hours, call keys…) the same on every device, stored as account data on your own homeserver. Device-specific settings such as zoom, media auto-load, noise suppression and volumes stay local."
after={<Switch variant="Primary" value={settingsSync} onChange={setSettingsSync} />}
/>
</SequenceCard>
{settingsSync && (
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Use This Device's Settings Everywhere"
description="Push this device's current preferences so every other device adopts them, even ones that changed something more recently."
after={
<Box gap="200">
<Button
size="300"
variant="Secondary"
fill="Soft"
outlined
radii="300"
disabled={busy !== null}
before={busy === 'push' ? <Spinner size="100" variant="Secondary" /> : undefined}
onClick={() => run('push')}
>
<Text size="B300">Push now</Text>
</Button>
<Button
size="300"
variant="Critical"
fill="None"
radii="300"
disabled={busy !== null}
before={busy === 'clear' ? <Spinner size="100" variant="Critical" /> : undefined}
onClick={() => run('clear')}
>
<Text size="B300">Clear synced copy</Text>
</Button>
</Box>
}
/>
{note && (
<Text
size="T200"
priority="300"
style={{ padding: `0 ${config.space.S300} ${config.space.S300}` }}
>
{note}
</Text>
)}
</SequenceCard>
)}
</Box>
);
}
// [Gitea #23] Denylist navigation-critical/modifier codes and reject a code that
// collides with the other call key (`otherKey`), so a rebind can never trap
// keyboard focus in-call or silently double-bind PTT and deafen to the same key.
@@ -2596,6 +2681,7 @@ export function General({ requestClose }: GeneralProps) {
<Editor />
<Messages />
<Privacy />
<SettingsSyncSection />
<Calls />
<AppUpdates />
</Box>
+167
View File
@@ -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 };
}
+16 -3
View File
@@ -21,6 +21,7 @@ import { NOTIFICATION_SOUND_MAP } from '../../utils/notificationSounds';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { setStripTrackingOnRender } from '../../plugins/react-custom-html-parser';
import { useSettingsSync } from '../../hooks/useSettingsSync';
import { allInvitesAtom } from '../../state/room-list/inviteList';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useHydrateMsgDrafts } from '../../hooks/useHydrateMsgDrafts';
@@ -89,13 +90,23 @@ function SystemEmojiFeature() {
return null;
}
function PageZoomFeature() {
const [pageZoom] = useSetting(settingsAtom, 'pageZoom');
// [Gitea #103] Mirror the privacy toggle into the html parser's module flag.
// [Gitea #103] Mirror the privacy toggle into the html parser's module flag.
function TrackingParamsFeature() {
const [stripTracking] = useSetting(settingsAtom, 'stripTrackingParams');
useEffect(() => {
setStripTrackingOnRender(stripTracking);
}, [stripTracking]);
return null;
}
// [Gitea #104] Mirror user preferences to account data and apply remote snapshots.
function SettingsSyncFeature() {
useSettingsSync();
return null;
}
function PageZoomFeature() {
const [pageZoom] = useSetting(settingsAtom, 'pageZoom');
if (pageZoom === 100) {
document.documentElement.style.removeProperty('font-size');
@@ -893,6 +904,8 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<SearchCacheInvalidationFeature />
<SystemEmojiFeature />
<PageZoomFeature />
<TrackingParamsFeature />
<SettingsSyncFeature />
<FaviconUpdater />
<PresenceUpdater />
<MuteTimerRestore />
+6
View File
@@ -267,6 +267,10 @@ export interface Settings {
// send, and from links rendered in the timeline. Local only.
stripTrackingParams: boolean;
// [Gitea #104] Mirror user preferences to `io.lotus.settings` account data
// so other devices pick them up. Device-local itself (utils/settingsSync).
settingsSync: boolean;
pauseAnimations: boolean;
composerToolbarButtons: ComposerToolbarSettings;
@@ -378,6 +382,8 @@ const defaultSettings: Settings = {
stripTrackingParams: true,
settingsSync: true,
pauseAnimations: false,
composerToolbarButtons: DEFAULT_COMPOSER_TOOLBAR,
+106
View File
@@ -0,0 +1,106 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
DEVICE_LOCAL_KEYS,
SETTINGS_SYNC_VERSION,
buildSyncedContent,
isSyncedSettingsContent,
mergeRemoteSettings,
pickSyncable,
shouldApplyRemote,
syncableEqual,
} from './settingsSync';
import { Settings } from '../state/settings';
// A representative slice of Settings; the helpers are generic over keys so a
// partial object cast is enough to exercise every branch.
const base = {
settingsSync: true,
pageZoom: 110,
isMarkdown: true,
quietHoursEnabled: false,
quietHoursStart: '22:00',
composerToolbarButtons: { showGif: true, order: ['showEmoji', 'showGif'] },
pttKey: 'KeyV',
callDenoiseModel: 'rnnoise',
} as unknown as Settings;
describe('pickSyncable', () => {
it('drops device-local keys and deep-copies objects', () => {
const picked = pickSyncable(base);
assert.equal('pageZoom' in picked, false);
assert.equal('settingsSync' in picked, false);
assert.equal('callDenoiseModel' in picked, false);
assert.equal(picked.isMarkdown, true);
assert.equal(picked.pttKey, 'KeyV');
assert.notEqual(picked.composerToolbarButtons, base.composerToolbarButtons);
assert.deepEqual(picked.composerToolbarButtons, base.composerToolbarButtons);
});
it('never syncs the sync toggle itself', () => {
assert.equal(DEVICE_LOCAL_KEYS.has('settingsSync'), true);
});
});
describe('syncableEqual', () => {
it('is structural and key-order independent', () => {
const a = pickSyncable(base);
const b = Object.fromEntries(Object.entries(a).reverse()) as Partial<Settings>;
assert.equal(syncableEqual(a, b), true);
assert.equal(syncableEqual(a, { ...a, isMarkdown: false }), false);
assert.equal(syncableEqual(a, { ...a, extra: 1 } as Partial<Settings>), false);
});
});
describe('isSyncedSettingsContent', () => {
it('accepts a well-formed event and rejects junk', () => {
assert.equal(isSyncedSettingsContent(buildSyncedContent(base, 5)), true);
assert.equal(isSyncedSettingsContent({}), false);
assert.equal(isSyncedSettingsContent(null), false);
assert.equal(isSyncedSettingsContent({ version: 1, updatedAt: 'x', settings: {} }), false);
assert.equal(isSyncedSettingsContent({ version: 1, updatedAt: 1, settings: [] }), false);
});
});
describe('mergeRemoteSettings', () => {
it('applies syncable keys, ignores device-local and unknown keys and wrong shapes', () => {
const remote = {
version: SETTINGS_SYNC_VERSION,
updatedAt: 10,
settings: {
isMarkdown: false,
pageZoom: 200, // device-local: ignored
quietHoursStart: 42, // wrong type: ignored
composerToolbarButtons: 'nope', // wrong type: ignored
futureKey: true, // unknown: ignored
} as unknown as Partial<Settings>,
};
const merged = mergeRemoteSettings(base, remote);
assert.equal(merged.isMarkdown, false);
assert.equal(merged.pageZoom, 110);
assert.equal(merged.quietHoursStart, '22:00');
assert.deepEqual(merged.composerToolbarButtons, base.composerToolbarButtons);
assert.equal('futureKey' in merged, false);
// Never mutates the input.
assert.equal(base.isMarkdown, true);
});
});
describe('shouldApplyRemote', () => {
it('applies when never synced or strictly newer; ignores echoes', () => {
const remote = buildSyncedContent(base, 100);
assert.equal(shouldApplyRemote(remote, null), true);
assert.equal(shouldApplyRemote(remote, 99), true);
assert.equal(shouldApplyRemote(remote, 100), false);
assert.equal(shouldApplyRemote(remote, 101), false);
});
});
describe('buildSyncedContent', () => {
it('stamps version and updatedAt around the syncable subset', () => {
const c = buildSyncedContent(base, 7);
assert.equal(c.version, SETTINGS_SYNC_VERSION);
assert.equal(c.updatedAt, 7);
assert.deepEqual(c.settings, pickSyncable(base));
});
});
+137
View File
@@ -0,0 +1,137 @@
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<Settings>;
};
// 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<keyof Settings> = new Set<keyof Settings>([
'settingsSync',
'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<Settings> => {
const out: Partial<Settings> = {};
(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<string, unknown>)[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<Settings>, b: Partial<Settings>): 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<string, unknown>)[ka[i]];
const vb = (b as Record<string, unknown>)[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<string, unknown>;
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<string, unknown>)[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;
+3
View File
@@ -23,6 +23,9 @@ export enum AccountDataEvent {
// global default behavior for threads.
LotusThreadNotifications = 'io.lotus.thread_notifications',
// [Gitea #104] Synced subset of the Lotus Settings object (utils/settingsSync).
LotusSettings = 'io.lotus.settings',
SecretStorageDefaultKey = 'm.secret_storage.default_key',
CrossSigningMaster = 'm.cross_signing.master',