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
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:
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user