Sync bookmarks, highlights and display preferences to the account
Build and Push Docker Image / build-and-push (push) Failing after 19s

Bookmarks and text highlights are saved per user and merged across devices
(union on first contact, newer server copy when this device is clean). Theme,
default translation, verse layout, commentary source and reading position sync
too, each with its own timestamp so the newest change to each setting wins.
The Reader never moves under you while it is open. Signing out clears this
device's marks and forgets sync timestamps.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-09-29 10:45:23 -04:00
1 parent 297f2c7e84
commit 760c1e568b
20 files changed
+976 -18

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
// 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', 'position'];
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, 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.position.book, values.position.chapter, 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) {
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 };
}