diff --git a/.changeset/settings-sync.md b/.changeset/settings-sync.md new file mode 100644 index 000000000..a436fc4bd --- /dev/null +++ b/.changeset/settings-sync.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add settings sync across devices via Matrix account data, with JSON export/import. diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 0392e47da..1db5272db 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -7,6 +7,7 @@ import { useState, } from 'react'; import dayjs from 'dayjs'; +import { useAtomValue, useSetAtom } from 'jotai'; import { Box, Button, @@ -26,7 +27,6 @@ import { toRem, } from 'folds'; import FocusTrap from 'focus-trap-react'; -import { useAtomValue, useSetAtom } from 'jotai'; import { Page, PageContent, PageHeader } from '$components/page'; import { SequenceCard } from '$components/sequence-card'; import { useSetting } from '$state/hooks/settings'; @@ -51,6 +51,8 @@ import { sessionsAtom, activeSessionIdAtom } from '$state/sessions'; import { useClientConfig } from '$hooks/useClientConfig'; import { resolveSlidingEnabled } from '$client/initMatrix'; import { isKeyHotkey } from 'is-hotkey'; +import { settingsSyncLastSyncedAtom, settingsSyncStatusAtom } from '$hooks/useSettingsSync'; +import { exportSettingsAsJson, importSettingsFromJson } from '$utils/settingsSync'; type DateHintProps = { hasChanges: boolean; @@ -1079,6 +1081,82 @@ type GeneralProps = { requestClose: () => void; }; +function SettingsSyncSection() { + const [syncEnabled, setSyncEnabled] = useSetting(settingsAtom, 'settingsSyncEnabled'); + const lastSynced = useAtomValue(settingsSyncLastSyncedAtom); + const syncStatus = useAtomValue(settingsSyncStatusAtom); + const fullSettings = useAtomValue(settingsAtom); + const setSettings = useSetAtom(settingsAtom); + + const [importError, setImportError] = useState(null); + + const handleImport = async () => { + setImportError(null); + const merged = await importSettingsFromJson(fullSettings); + if (merged === null) { + setImportError('Could not import — file was invalid or you cancelled.'); + return; + } + setSettings(merged); + }; + + const syncStatusLabel: Record = { + idle: lastSynced + ? `Last synced at ${dayjs(lastSynced).format('HH:mm:ss')}` + : 'Not yet synced this session', + syncing: 'Syncing…', + error: 'Sync failed — will retry on next change', + }; + + return ( + + Settings Sync & Backup + + } + /> + {syncEnabled && ( + + )} + + + + + + {importError && ( + + {importError} + + )} + + ); +} + function DiagnosticsAndPrivacy() { const [sentryEnabled, setSentryEnabled] = useState( localStorage.getItem('sable_sentry_enabled') === 'true' @@ -1207,6 +1285,7 @@ export function General({ requestClose }: Readonly) { + diff --git a/src/app/hooks/useSettingsSync.test.tsx b/src/app/hooks/useSettingsSync.test.tsx new file mode 100644 index 000000000..214a61bc9 --- /dev/null +++ b/src/app/hooks/useSettingsSync.test.tsx @@ -0,0 +1,298 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { createStore, Provider } from 'jotai'; +import { createElement, type ReactNode } from 'react'; +import { settingsAtom, getSettings } from '$state/settings'; +import { AccountDataEvent } from '$types/matrix/accountData'; +import { SETTINGS_SYNC_VERSION } from '$utils/settingsSync'; +import { + settingsSyncLastSyncedAtom, + settingsSyncStatusAtom, + useSettingsSyncEffect, +} from './useSettingsSync'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +// Keep a reference to the latest account data callback registered by the hook +// so tests can simulate incoming Matrix events. +const { callbackHolder, mockMx } = vi.hoisted(() => { + const holder: { + current: ((event: { getType: () => string; getContent: () => unknown }) => void) | null; + } = { current: null }; + const mx = { + getAccountData: vi.fn().mockReturnValue(null), + setAccountData: vi.fn().mockResolvedValue(undefined), + }; + return { callbackHolder: holder, mockMx: mx }; +}); + +vi.mock('$hooks/useMatrixClient', () => ({ + useMatrixClient: () => mockMx, +})); + +vi.mock('$hooks/useAccountDataCallback', () => ({ + useAccountDataCallback: ( + _mx: unknown, + cb: (event: { getType: () => string; getContent: () => unknown }) => void + ) => { + callbackHolder.current = cb; + }, +})); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** Build a fresh jotai store pre-loaded with the given settings. */ +function makeStore(overrides?: Partial>) { + const store = createStore(); + const base = getSettings(); + store.set(settingsAtom, { ...base, ...overrides }); + return store; +} + +/** Wrapper that provides an isolated jotai store per test. */ +function makeWrapper(store: ReturnType) { + return function ({ children }: { children: ReactNode }) { + return createElement(Provider, { store }, children); + }; +} + +function makeSableSettingsEvent(content: unknown) { + return { + getType: () => AccountDataEvent.SableSettings, + getContent: () => content, + }; +} + +// ── Atom initial values ─────────────────────────────────────────────────────── + +describe('atom initial values', () => { + it('settingsSyncLastSyncedAtom starts as null', () => { + const store = createStore(); + expect(store.get(settingsSyncLastSyncedAtom)).toBeNull(); + }); + + it('settingsSyncStatusAtom starts as idle', () => { + const store = createStore(); + expect(store.get(settingsSyncStatusAtom)).toBe('idle'); + }); +}); + +// ── Hook: sync disabled ─────────────────────────────────────────────────────── + +describe('useSettingsSyncEffect — sync disabled', () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not read account data when settingsSyncEnabled is false', () => { + const store = makeStore({ settingsSyncEnabled: false }); + renderHook(() => useSettingsSyncEffect(), { wrapper: makeWrapper(store) }); + expect(mockMx.getAccountData).not.toHaveBeenCalled(); + }); + + it('does not schedule an upload when settingsSyncEnabled is false', () => { + const store = makeStore({ settingsSyncEnabled: false }); + renderHook(() => useSettingsSyncEffect(), { wrapper: makeWrapper(store) }); + vi.runAllTimers(); + expect(mockMx.setAccountData).not.toHaveBeenCalled(); + }); +}); + +// ── Hook: sync enabled — mount behaviour ───────────────────────────────────── + +describe('useSettingsSyncEffect — sync enabled on mount', () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('reads account data on mount and applies it to the atom', () => { + const remoteContent = { + v: SETTINGS_SYNC_VERSION, + settings: { isMarkdown: false }, + }; + mockMx.getAccountData.mockReturnValueOnce({ + getContent: () => remoteContent, + }); + + const store = makeStore({ settingsSyncEnabled: true, isMarkdown: true }); + renderHook(() => useSettingsSyncEffect(), { wrapper: makeWrapper(store) }); + + expect(store.get(settingsAtom).isMarkdown).toBe(false); + }); + + it('sets lastSynced after loading from account data on mount', () => { + const remoteContent = { v: SETTINGS_SYNC_VERSION, settings: { isMarkdown: false } }; + mockMx.getAccountData.mockReturnValueOnce({ getContent: () => remoteContent }); + + const store = makeStore({ settingsSyncEnabled: true }); + const before = Date.now(); + renderHook(() => useSettingsSyncEffect(), { wrapper: makeWrapper(store) }); + const after = Date.now(); + + const lastSynced = store.get(settingsSyncLastSyncedAtom); + expect(lastSynced).not.toBeNull(); + expect(lastSynced!).toBeGreaterThanOrEqual(before); + expect(lastSynced!).toBeLessThanOrEqual(after); + }); + + it('does nothing on mount when account data is absent', () => { + mockMx.getAccountData.mockReturnValue(null); + const store = makeStore({ settingsSyncEnabled: true }); + renderHook(() => useSettingsSyncEffect(), { wrapper: makeWrapper(store) }); + expect(store.get(settingsSyncLastSyncedAtom)).toBeNull(); + }); +}); + +// ── Hook: debounced upload ──────────────────────────────────────────────────── + +describe('useSettingsSyncEffect — debounced upload', () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('uploads settings after the debounce delay', () => { + const store = makeStore({ settingsSyncEnabled: true }); + renderHook(() => useSettingsSyncEffect(), { wrapper: makeWrapper(store) }); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(mockMx.setAccountData).toHaveBeenCalledOnce(); + const [type, content] = mockMx.setAccountData.mock.calls[0] as [ + string, + Record, + ]; + expect(type).toBe(AccountDataEvent.SableSettings); + expect(content.v).toBe(SETTINGS_SYNC_VERSION); + expect(typeof content._echo).toBe('string'); + }); + + it('sets sync status to syncing while the upload is in flight', () => { + const store = makeStore({ settingsSyncEnabled: true }); + renderHook(() => useSettingsSyncEffect(), { wrapper: makeWrapper(store) }); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(store.get(settingsSyncStatusAtom)).toBe('syncing'); + }); + + it('sets sync status to error when setAccountData rejects', async () => { + mockMx.setAccountData.mockRejectedValueOnce(new Error('network')); + const store = makeStore({ settingsSyncEnabled: true }); + renderHook(() => useSettingsSyncEffect(), { wrapper: makeWrapper(store) }); + + await act(async () => { + vi.advanceTimersByTime(2000); + // Flush the rejection microtask. + await Promise.resolve(); + }); + + expect(store.get(settingsSyncStatusAtom)).toBe('error'); + }); +}); + +// ── Hook: echo-token loop prevention ───────────────────────────────────────── + +describe('useSettingsSyncEffect — echo-token loop prevention', () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('skips re-applying an event that echoes our own upload token', async () => { + const store = makeStore({ settingsSyncEnabled: true, isMarkdown: true }); + renderHook(() => useSettingsSyncEffect(), { wrapper: makeWrapper(store) }); + + // Trigger the upload. + act(() => { + vi.advanceTimersByTime(2000); + }); + + // Capture the echo token that was uploaded. + const uploadedContent = mockMx.setAccountData.mock.calls[0][1] as Record; + const echoToken = uploadedContent._echo as string; + + // Simulate the homeserver echoing our own event back. + const echoEvent = makeSableSettingsEvent({ + v: SETTINGS_SYNC_VERSION, + _echo: echoToken, + settings: { isMarkdown: false }, // different — must be ignored + }); + + act(() => { + callbackHolder.current?.(echoEvent); + }); + + // isMarkdown should stay true (echo was ignored). + expect(store.get(settingsAtom).isMarkdown).toBe(true); + }); + + it('marks sync status as idle and updates lastSynced when own echo arrives', async () => { + const store = makeStore({ settingsSyncEnabled: true }); + renderHook(() => useSettingsSyncEffect(), { wrapper: makeWrapper(store) }); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + const uploadedContent = mockMx.setAccountData.mock.calls[0][1] as Record; + const echoToken = uploadedContent._echo as string; + + const before = Date.now(); + act(() => { + callbackHolder.current?.( + makeSableSettingsEvent({ v: SETTINGS_SYNC_VERSION, _echo: echoToken, settings: {} }) + ); + }); + const after = Date.now(); + + expect(store.get(settingsSyncStatusAtom)).toBe('idle'); + const lastSynced = store.get(settingsSyncLastSyncedAtom); + expect(lastSynced).not.toBeNull(); + expect(lastSynced!).toBeGreaterThanOrEqual(before); + expect(lastSynced!).toBeLessThanOrEqual(after); + }); + + it('applies an event from another device (different or absent echo token)', () => { + const store = makeStore({ settingsSyncEnabled: true, isMarkdown: true }); + renderHook(() => useSettingsSyncEffect(), { wrapper: makeWrapper(store) }); + + const remoteEvent = makeSableSettingsEvent({ + v: SETTINGS_SYNC_VERSION, + settings: { isMarkdown: false }, + // No _echo — definitely from another device. + }); + + act(() => { + callbackHolder.current?.(remoteEvent); + }); + + expect(store.get(settingsAtom).isMarkdown).toBe(false); + }); +}); diff --git a/src/app/hooks/useSettingsSync.ts b/src/app/hooks/useSettingsSync.ts new file mode 100644 index 000000000..925f6cb7c --- /dev/null +++ b/src/app/hooks/useSettingsSync.ts @@ -0,0 +1,105 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { atom, useAtom, useSetAtom } from 'jotai'; +import { MatrixEvent } from '$types/matrix-sdk'; +import { AccountDataEvent } from '$types/matrix/accountData'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useAccountDataCallback } from '$hooks/useAccountDataCallback'; +import { settingsAtom } from '$state/settings'; +import { deserializeFromSync, serializeForSync } from '$utils/settingsSync'; + +export type SyncStatus = 'idle' | 'syncing' | 'error'; + +/** Milliseconds to wait after a local settings change before uploading. */ +const DEBOUNCE_MS = 2000; + +/** Unix timestamp (ms) of the last confirmed sync, or null if never synced this session. */ +export const settingsSyncLastSyncedAtom = atom(null); + +/** Current upload state for UI feedback. */ +export const settingsSyncStatusAtom = atom('idle'); + +/** + * Side-effect hook that: + * - loads settings from account data when sync is first enabled + * - listens for live updates arriving from other devices + * - debounce-uploads local changes back to account data + * + * Only active when `settings.settingsSyncEnabled === true`. + * Call this once from a component that stays mounted for the session lifetime. + */ +export function useSettingsSyncEffect(): void { + const mx = useMatrixClient(); + const [settings, setSettings] = useAtom(settingsAtom); + const setLastSynced = useSetAtom(settingsSyncLastSyncedAtom); + const setSyncStatus = useSetAtom(settingsSyncStatusAtom); + + // Keep a ref so callbacks can always read the latest value without stale closures. + const settingsRef = useRef(settings); + settingsRef.current = settings; + + const syncEnabled = settings.settingsSyncEnabled; + + // ── On mount / when sync is first enabled: load from account data ────────── + useEffect(() => { + if (!syncEnabled) return; + const event = mx.getAccountData(AccountDataEvent.SableSettings as never); + if (!event) return; + const merged = deserializeFromSync(event.getContent(), settingsRef.current); + if (merged) { + setSettings(merged); + setLastSynced(Date.now()); + } + }, [mx, syncEnabled, setSettings, setLastSynced]); + + // ── Echo-detection: track the token of our last upload ──────────────────── + // When our upload echoes back via ClientEvent.AccountData we skip applying it + // (to avoid overwriting settings that changed between upload and echo). + const pendingEchoTokenRef = useRef(null); + + // ── Live updates from other devices ─────────────────────────────────────── + const onAccountData = useCallback( + (event: MatrixEvent) => { + if (event.getType() !== AccountDataEvent.SableSettings) return; + if (!settingsRef.current.settingsSyncEnabled) return; + + const content = event.getContent(); + + // If this is the echo of our own upload, just confirm success and skip. + if (typeof content._echo === 'string' && content._echo === pendingEchoTokenRef.current) { + pendingEchoTokenRef.current = null; + setLastSynced(Date.now()); + setSyncStatus('idle'); + return; + } + + // Otherwise it came from another device — apply it. + const merged = deserializeFromSync(content, settingsRef.current); + if (merged) { + setSettings(merged); + setLastSynced(Date.now()); + } + }, + [setSettings, setLastSynced, setSyncStatus] + ); + useAccountDataCallback(mx, onAccountData); + + // ── Debounced upload whenever settings change ────────────────────────────── + const timerRef = useRef>(); + useEffect(() => { + if (!syncEnabled) return undefined; + + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + setSyncStatus('syncing'); + const token = Math.random().toString(36).slice(2, 10); + pendingEchoTokenRef.current = token; + const content = { ...serializeForSync(settingsRef.current), _echo: token }; + mx.setAccountData(AccountDataEvent.SableSettings as never, content as never).catch(() => { + pendingEchoTokenRef.current = null; + setSyncStatus('error'); + }); + }, DEBOUNCE_MS); + + return () => clearTimeout(timerRef.current); + }, [mx, settings, syncEnabled, setSyncStatus]); +} diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index d8228ccdd..8688a9dc4 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -54,6 +54,7 @@ import { TelemetryConsentBanner } from '$components/telemetry-consent'; import { useCallSignaling } from '$hooks/useCallSignaling'; import { getBlobCacheStats } from '$hooks/useBlobCache'; import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; +import { useSettingsSyncEffect } from '$hooks/useSettingsSync'; import { getInboxInvitesPath } from '../pathUtils'; import { BackgroundNotifications } from './BackgroundNotifications'; @@ -835,10 +836,16 @@ function PresenceFeature() { return null; } +function SettingsSyncFeature() { + useSettingsSyncEffect(); + return null; +} + export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { useCallSignaling(); return ( <> + diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index e9331c62f..054f4e3ee 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -64,6 +64,7 @@ export interface Settings { developerTools: boolean; enableMSC4268CMD: boolean; + settingsSyncEnabled: boolean; // Cosmetics! jumboEmojiSize: JumboEmojiSize; @@ -147,6 +148,7 @@ const defaultSettings: Settings = { dateFormatString: 'D MMM YYYY', developerTools: false, + settingsSyncEnabled: false, // Cosmetics! jumboEmojiSize: 'normal', diff --git a/src/app/utils/colorMXID.ts b/src/app/utils/colorMXID.ts index c94a19d48..3c512d99f 100644 --- a/src/app/utils/colorMXID.ts +++ b/src/app/utils/colorMXID.ts @@ -9,7 +9,7 @@ function hashCode(str?: string): number { const chr = str.codePointAt(i) ?? 0; // eslint-disable-next-line no-bitwise hash = (hash << 5) - hash + chr; - // eslint-disable-next-line no-bitwise + hash = Math.trunc(hash); } return Math.abs(hash); diff --git a/src/app/utils/settingsSync.test.ts b/src/app/utils/settingsSync.test.ts new file mode 100644 index 000000000..7f44b93a4 --- /dev/null +++ b/src/app/utils/settingsSync.test.ts @@ -0,0 +1,307 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { getSettings } from '$state/settings'; +import { + NON_SYNCABLE_KEYS, + SETTINGS_SYNC_VERSION, + serializeForSync, + deserializeFromSync, + exportSettingsAsJson, + importSettingsFromJson, +} from './settingsSync'; + +// ── fixtures ────────────────────────────────────────────────────────────────── + +let base: ReturnType; + +beforeEach(() => { + localStorage.clear(); + base = getSettings(); +}); + +// ── NON_SYNCABLE_KEYS ───────────────────────────────────────────────────────── + +describe('NON_SYNCABLE_KEYS', () => { + it('contains all device-local and security-sensitive keys', () => { + const expected = [ + 'usePushNotifications', + 'useInAppNotifications', + 'useSystemNotifications', + 'pageZoom', + 'isPeopleDrawer', + 'isWidgetDrawer', + 'memberSortFilterIndex', + 'developerTools', + 'settingsSyncEnabled', + ] as const; + + expected.forEach((key) => { + expect(NON_SYNCABLE_KEYS.has(key)).toBe(true); + }); + }); + + it('does not include ordinary syncable keys', () => { + const syncable = ['isMarkdown', 'twitterEmoji', 'messageLayout', 'urlPreview'] as const; + syncable.forEach((key) => { + expect(NON_SYNCABLE_KEYS.has(key)).toBe(false); + }); + }); +}); + +// ── serializeForSync ────────────────────────────────────────────────────────── + +describe('serializeForSync', () => { + it('sets the correct schema version', () => { + const result = serializeForSync(base); + expect(result.v).toBe(SETTINGS_SYNC_VERSION); + }); + + it('includes syncable settings fields', () => { + const settings = { ...base, isMarkdown: false, twitterEmoji: false }; + const { settings: s } = serializeForSync(settings); + expect(s.isMarkdown).toBe(false); + expect(s.twitterEmoji).toBe(false); + }); + + it('strips all non-syncable keys from the payload', () => { + const { settings: s } = serializeForSync(base); + Array.from(NON_SYNCABLE_KEYS).forEach((key) => { + expect(Object.hasOwn(s, key)).toBe(false); + }); + }); + + it('does not mutate the original settings object', () => { + const original = { ...base, pageZoom: 150 }; + serializeForSync(original); + expect(original.pageZoom).toBe(150); + }); +}); + +// ── deserializeFromSync ─────────────────────────────────────────────────────── + +describe('deserializeFromSync', () => { + it('returns null for null input', () => { + expect(deserializeFromSync(null, base)).toBeNull(); + }); + + it('returns null for non-object primitives', () => { + expect(deserializeFromSync('string', base)).toBeNull(); + expect(deserializeFromSync(42, base)).toBeNull(); + expect(deserializeFromSync(true, base)).toBeNull(); + }); + + it('returns null for an array', () => { + expect(deserializeFromSync([], base)).toBeNull(); + }); + + it('returns null when the version field is missing', () => { + expect(deserializeFromSync({ settings: {} }, base)).toBeNull(); + }); + + it('returns null when the version is wrong', () => { + expect(deserializeFromSync({ v: 99, settings: {} }, base)).toBeNull(); + expect(deserializeFromSync({ v: 0, settings: {} }, base)).toBeNull(); + }); + + it('returns null when the settings field is missing', () => { + expect(deserializeFromSync({ v: SETTINGS_SYNC_VERSION }, base)).toBeNull(); + }); + + it('returns null when the settings field is an array', () => { + expect(deserializeFromSync({ v: SETTINGS_SYNC_VERSION, settings: [] }, base)).toBeNull(); + }); + + it('returns null when the settings field is a primitive', () => { + expect(deserializeFromSync({ v: SETTINGS_SYNC_VERSION, settings: 'bad' }, base)).toBeNull(); + }); + + it('merges remote settings over local', () => { + const remote = { v: SETTINGS_SYNC_VERSION, settings: { isMarkdown: false, urlPreview: false } }; + const result = deserializeFromSync(remote, { ...base, isMarkdown: true, urlPreview: true }); + expect(result).not.toBeNull(); + expect(result!.isMarkdown).toBe(false); + expect(result!.urlPreview).toBe(false); + }); + + it('preserves non-syncable keys from local, even if remote provides different values', () => { + const remote = { + v: SETTINGS_SYNC_VERSION, + settings: { + pageZoom: 200, + isPeopleDrawer: false, + settingsSyncEnabled: true, + developerTools: true, + }, + }; + const local = { ...base, pageZoom: 100, isPeopleDrawer: true, settingsSyncEnabled: false }; + const result = deserializeFromSync(remote, local); + expect(result).not.toBeNull(); + expect(result!.pageZoom).toBe(100); + expect(result!.isPeopleDrawer).toBe(true); + expect(result!.settingsSyncEnabled).toBe(false); + expect(result!.developerTools).toBe(false); + }); + + it('round-trips through serialize then deserialize correctly', () => { + const tweaked = { ...base, isMarkdown: false, hour24Clock: true }; + const payload = serializeForSync(tweaked); + const result = deserializeFromSync(payload, base); + expect(result).not.toBeNull(); + expect(result!.isMarkdown).toBe(false); + expect(result!.hour24Clock).toBe(true); + // non-syncable comes from base, not tweaked (pageZoom etc. same anyway) + expect(result!.settingsSyncEnabled).toBe(base.settingsSyncEnabled); + }); + + it('ignores extra unknown keys in the remote payload', () => { + const remote = { + v: SETTINGS_SYNC_VERSION, + settings: { isMarkdown: false, __unknown: 'surprise' }, + }; + const result = deserializeFromSync(remote, base); + expect(result).not.toBeNull(); + expect(result!.isMarkdown).toBe(false); + }); +}); + +// ── exportSettingsAsJson ────────────────────────────────────────────────────── + +describe('exportSettingsAsJson', () => { + let fakeUrl: string; + let anchorClick: ReturnType; + + beforeEach(() => { + fakeUrl = 'blob:fake-url'; + anchorClick = vi.fn(); + vi.stubGlobal('URL', { + createObjectURL: vi.fn().mockReturnValue(fakeUrl), + revokeObjectURL: vi.fn(), + }); + + // Intercept anchor element creation to capture click calls. + const realCreate = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation((tag: string, ...args) => { + const el = realCreate(tag, ...args); + if (tag === 'a') { + vi.spyOn(el, 'click').mockImplementation(anchorClick as () => void); + } + return el; + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('calls URL.createObjectURL with a JSON Blob', () => { + exportSettingsAsJson(base); + expect(URL.createObjectURL).toHaveBeenCalledOnce(); + const blob: Blob = (URL.createObjectURL as ReturnType).mock.calls[0][0]; + expect(blob).toBeInstanceOf(Blob); + expect(blob.type).toBe('application/json'); + }); + + it('Blob content is valid JSON with the correct schema version and all settings', async () => { + exportSettingsAsJson(base); + const blob: Blob = (URL.createObjectURL as ReturnType).mock.calls[0][0]; + const text = await blob.text(); + const parsed = JSON.parse(text); + expect(parsed.v).toBe(SETTINGS_SYNC_VERSION); + expect(typeof parsed.settings).toBe('object'); + // non-syncable keys ARE present in the export (full snapshot, not filtered) + expect(parsed.settings.pageZoom).toBeDefined(); + }); + + it('creates an anchor with a .json download attribute and clicks it', () => { + exportSettingsAsJson(base); + expect(anchorClick).toHaveBeenCalledOnce(); + }); + + it('revokes the object URL after triggering the download', () => { + exportSettingsAsJson(base); + expect(URL.revokeObjectURL).toHaveBeenCalledWith(fakeUrl); + }); +}); + +// ── importSettingsFromJson ──────────────────────────────────────────────────── + +describe('importSettingsFromJson', () => { + let mockInput: { + type: string; + accept: string; + files: FileList | null; + onchange: ((ev: Event) => void) | null; + click: ReturnType; + }; + + beforeEach(() => { + mockInput = { + type: '', + accept: '', + files: null, + onchange: null, + click: vi.fn(), + }; + + const realCreate = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation((tag: string, ...args) => { + if (tag === 'input') return mockInput as unknown as HTMLInputElement; + return realCreate(tag, ...args); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('resolves null when no file is selected (empty files list)', async () => { + // Start the promise, then immediately trigger onchange with no file. + const promise = importSettingsFromJson(base); + mockInput.onchange?.(new Event('change')); + await expect(promise).resolves.toBeNull(); + }); + + it('resolves merged settings when a valid JSON file is provided', async () => { + const payload = { v: SETTINGS_SYNC_VERSION, settings: { isMarkdown: false } }; + const fileContent = JSON.stringify(payload); + const file = new File([fileContent], 'settings.json', { type: 'application/json' }); + + // Build a minimal FileList-like object. + const fakeFileList = { 0: file, length: 1, item: () => file } as unknown as FileList; + mockInput.files = fakeFileList; + + const promise = importSettingsFromJson({ ...base, isMarkdown: true }); + + // Trigger the change event; the file reader will asynchronously call onload. + mockInput.onchange?.(new Event('change')); + + const result = await promise; + expect(result).not.toBeNull(); + expect(result!.isMarkdown).toBe(false); + }); + + it('resolves null when the file contains invalid JSON', async () => { + const file = new File(['not json {{'], 'bad.json', { type: 'application/json' }); + const fakeFileList = { 0: file, length: 1, item: () => file } as unknown as FileList; + mockInput.files = fakeFileList; + + const promise = importSettingsFromJson(base); + mockInput.onchange?.(new Event('change')); + + await expect(promise).resolves.toBeNull(); + }); + + it('resolves null when the JSON has an incompatible schema version', async () => { + const payload = { v: 99, settings: { isMarkdown: false } }; + const file = new File([JSON.stringify(payload)], 'settings.json', { + type: 'application/json', + }); + const fakeFileList = { 0: file, length: 1, item: () => file } as unknown as FileList; + mockInput.files = fakeFileList; + + const promise = importSettingsFromJson(base); + mockInput.onchange?.(new Event('change')); + + await expect(promise).resolves.toBeNull(); + }); +}); diff --git a/src/app/utils/settingsSync.ts b/src/app/utils/settingsSync.ts new file mode 100644 index 000000000..a154ff92d --- /dev/null +++ b/src/app/utils/settingsSync.ts @@ -0,0 +1,101 @@ +import { Settings } from '$state/settings'; + +/** + * Keys excluded from cross-device sync. + * These are platform-specific, security-sensitive, or purely local UI state. + */ +export const NON_SYNCABLE_KEYS = new Set([ + // Platform / permission-level — differ per device/browser + 'usePushNotifications', + 'useInAppNotifications', + 'useSystemNotifications', + // Personal device-level preferences + 'pageZoom', + 'isPeopleDrawer', + 'isWidgetDrawer', + 'memberSortFilterIndex', + // Developer / diagnostic + 'developerTools', + // Sync toggle itself must never be uploaded (it's device-local) + 'settingsSyncEnabled', +]); + +export const SETTINGS_SYNC_VERSION = 1; + +export type SettingsSyncContent = { + v: number; + settings: Partial; +}; + +/** Strip non-syncable keys and wrap in a versioned envelope. */ +export const serializeForSync = (settings: Settings): SettingsSyncContent => { + const syncable = { ...settings } as Partial; + NON_SYNCABLE_KEYS.forEach((key) => delete syncable[key]); + return { v: SETTINGS_SYNC_VERSION, settings: syncable }; +}; + +/** + * Validate incoming account data and merge it into current settings. + * Returns null when the data is invalid or from an incompatible schema version. + * Non-syncable keys are always taken from `currentSettings`, never from remote. + */ +export const deserializeFromSync = (data: unknown, currentSettings: Settings): Settings | null => { + if (!data || typeof data !== 'object') return null; + const content = data as Record; + if (content.v !== SETTINGS_SYNC_VERSION) return null; + const remote = content.settings; + if (!remote || typeof remote !== 'object' || Array.isArray(remote)) return null; + + const merged = { ...currentSettings, ...(remote as Partial) }; + // Always restore non-syncable keys from local state. + NON_SYNCABLE_KEYS.forEach((key) => { + (merged as unknown as Record)[key] = ( + currentSettings as unknown as Record + )[key]; + }); + + return merged; +}; + +/** Trigger a browser download of the current settings as a JSON file. */ +export const exportSettingsAsJson = (settings: Settings): void => { + const payload = JSON.stringify({ v: SETTINGS_SYNC_VERSION, settings }, null, 2); + const blob = new Blob([payload], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `sable-settings-${Date.now()}.json`; + a.click(); + URL.revokeObjectURL(url); +}; + +/** + * Open a file picker, parse the selected JSON, and return the merged settings. + * Resolves to null if the user cancels or the file is invalid. + */ +export const importSettingsFromJson = (currentSettings: Settings): Promise => + new Promise((resolve) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'application/json,.json'; + input.onchange = () => { + const file = input.files?.[0]; + if (!file) { + resolve(null); + return; + } + const reader = new FileReader(); + reader.onload = (e) => { + try { + const data = JSON.parse(e.target?.result as string); + resolve(deserializeFromSync(data, currentSettings)); + } catch { + resolve(null); + } + }; + reader.onerror = () => resolve(null); + reader.readAsText(file); + }; + // oncancel is not widely supported; clicking away without selecting resolves naturally via onchange with empty files + input.click(); + }); diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts index 6d0a804e2..56edd8779 100644 --- a/src/types/matrix/accountData.ts +++ b/src/types/matrix/accountData.ts @@ -21,6 +21,7 @@ export enum AccountDataEvent { // Sable account data SableNicknames = 'moe.sable.app.nicknames', SablePinStatus = 'moe.sable.app.pins_read_marker', + SableSettings = 'moe.sable.app.settings', } export type MDirectContent = Record;