// Keeps a few display preferences (theme, default translation, verse layout, commentary // source, and where you left off in the Reader) in step with the signed-in account. // Every preference has its own timestamp, so the newest change to each one wins and two // devices changing different settings never overwrite each other. import { useCallback, useEffect, useRef } from 'react'; import { getRemotePrefs, saveRemotePrefs } from '../syncService.js'; export const PREF_KEYS = ['theme', 'translation', 'layout', 'commentarySource', 'planTranslation', 'startPage', 'position', 'colorLabels']; const META_KEY = 'prefs-meta'; // { key: timestamp of the last change made on this device } const PUSH_DELAY_MS = 1000; const FOCUS_PULL_MIN_MS = 30_000; const lsGet = (k) => { try { return localStorage.getItem(k); } catch { return null; } }; const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch { /* storage unavailable */ } }; const lsDel = (k) => { try { localStorage.removeItem(k); } catch { /* storage unavailable */ } }; const readMeta = () => { try { return JSON.parse(lsGet(META_KEY) || '{}'); } catch { return {}; } }; const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); // values: { theme, translation, layout, commentarySource, planTranslation, startPage, position: { book, chapter } } // apply(key, value): puts a server value on screen and in localStorage // canApplyPosition(): false while the Reader is open, so a background sync never moves the page under you export function usePrefsSync({ values, apply, canApplyPosition = () => true }) { const valuesRef = useRef(values); valuesRef.current = values; const prevRef = useRef(values); const enabledRef = useRef(false); const dirtyRef = useRef(new Set()); const timerRef = useRef(null); const lastPullRef = useRef(0); const applyRef = useRef(apply); applyRef.current = apply; const canApplyPositionRef = useRef(canApplyPosition); canApplyPositionRef.current = canApplyPosition; const push = useCallback(async () => { window.clearTimeout(timerRef.current); if (!enabledRef.current || dirtyRef.current.size === 0) return; const keys = [...dirtyRef.current]; const meta = readMeta(); const prefs = Object.fromEntries(keys.map((k) => [k, { v: valuesRef.current[k], at: meta[k] ?? Date.now() }])); const result = await saveRemotePrefs(prefs); if (!result.ok) return; // stays dirty; retried on the next change or when the tab regains focus keys.forEach((k) => { if (readMeta()[k] === prefs[k].at) dirtyRef.current.delete(k); }); if (dirtyRef.current.size > 0) timerRef.current = window.setTimeout(push, PUSH_DELAY_MS); }, []); // Local changes: stamp them and save shortly. useEffect(() => { const prev = prevRef.current; const changed = PREF_KEYS.filter((k) => !same(prev[k], values[k])); prevRef.current = values; if (changed.length === 0) return; const meta = readMeta(); const now = Date.now(); changed.forEach((k) => { meta[k] = now; dirtyRef.current.add(k); }); lsSet(META_KEY, JSON.stringify(meta)); if (enabledRef.current) { window.clearTimeout(timerRef.current); timerRef.current = window.setTimeout(push, PUSH_DELAY_MS); } }, [values.theme, values.translation, values.layout, values.commentarySource, values.planTranslation, values.startPage, values.position.book, values.position.chapter, values.colorLabels, push]); // eslint-disable-line react-hooks/exhaustive-deps const pullServerPrefs = useCallback(async () => { enabledRef.current = true; lastPullRef.current = Date.now(); const result = await getRemotePrefs(); if (!result.ok) return; const server = result.data?.prefs ?? {}; const meta = readMeta(); const now = Date.now(); for (const key of PREF_KEYS) { if (valuesRef.current[key] === undefined) continue; // nothing on this device to compare or upload const remote = server[key]; const localAt = meta[key] ?? 0; if (remote && remote.at > localAt) { if (key === 'position' && !canApplyPositionRef.current()) continue; // don't move the page mid-read meta[key] = remote.at; if (!same(valuesRef.current[key], remote.v)) { prevRef.current = { ...prevRef.current, [key]: remote.v }; // so this isn't mistaken for a local edit applyRef.current(key, remote.v); } dirtyRef.current.delete(key); } else if (!remote || localAt > remote.at) { if (!meta[key]) meta[key] = now; // first time on this account: what's on screen becomes the account's dirtyRef.current.add(key); } } lsSet(META_KEY, JSON.stringify(meta)); await push(); }, [push]); // Signed out: stop syncing. Values stay as they are on this device; the timestamps are // forgotten so the next account to sign in here starts from its own settings. const stopPrefsSync = useCallback(() => { enabledRef.current = false; window.clearTimeout(timerRef.current); dirtyRef.current.clear(); lsDel(META_KEY); }, []); useEffect(() => { const onVisibility = () => { if (!enabledRef.current) return; if (document.visibilityState === 'hidden') { push(); return; } if (Date.now() - lastPullRef.current > FOCUS_PULL_MIN_MS) pullServerPrefs(); }; document.addEventListener('visibilitychange', onVisibility); return () => document.removeEventListener('visibilitychange', onVisibility); }, [push, pullServerPrefs]); useEffect(() => () => window.clearTimeout(timerRef.current), []); return { pullServerPrefs, stopPrefsSync, flushPrefs: push }; }