// Keeps the Reader's bookmarks and text highlights in step with the signed-in user's // account, so they follow you between devices. Local edits save to localStorage as they // always did; while signed in they are also pushed to the server a moment after the last // change, and pulled when you sign in and whenever you come back to the tab. import { useCallback, useEffect, useRef } from 'react'; import { getRemoteMarks, saveRemoteMarks } from '../syncService.js'; import { emptyMarks, isEmptyMarks, marksEqual, mergeMarks, normalizeMarks } from '../utils/readerMarks.js'; const KEYS = { bookmarks: 'reader-bookmarks', highlights: 'readerTextHighlights', syncedAt: 'reader-marks-synced-at', dirty: 'reader-marks-dirty' }; const PUSH_DELAY_MS = 1500; const FOCUS_PULL_MIN_MS = 20_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 */ } }; export function useReaderMarksSync({ bookmarks, highlights, setBookmarks, setHighlights }) { const enabledRef = useRef(false); const latestRef = useRef({ bookmarks, highlights }); latestRef.current = { bookmarks, highlights }; const timerRef = useRef(null); const skipRef = useRef(false); // set while applying server data, so it isn't mistaken for a local edit const firstRunRef = useRef(true); const lastPullRef = useRef(0); const pushingRef = useRef(null); const setDirty = (dirty) => (dirty ? lsSet(KEYS.dirty, '1') : lsDel(KEYS.dirty)); const push = useCallback(async () => { window.clearTimeout(timerRef.current); if (!enabledRef.current) return; if (pushingRef.current) { await pushingRef.current; } const snapshot = latestRef.current; pushingRef.current = saveRemoteMarks({ bookmarks: snapshot.bookmarks, highlights: snapshot.highlights }); const result = await pushingRef.current; pushingRef.current = null; if (!result.ok) return; // stays dirty; retried on the next change or when the tab regains focus lsSet(KEYS.syncedAt, String(result.data.updatedAt)); // Only clean if nothing changed while the request was in flight if (latestRef.current.bookmarks === snapshot.bookmarks && latestRef.current.highlights === snapshot.highlights) setDirty(false); else timerRef.current = window.setTimeout(push, PUSH_DELAY_MS); }, []); // Local edits: remember there's something to save, and save it shortly. useEffect(() => { if (firstRunRef.current) { firstRunRef.current = false; return; } if (skipRef.current) { skipRef.current = false; return; } setDirty(true); if (enabledRef.current) { window.clearTimeout(timerRef.current); timerRef.current = window.setTimeout(push, PUSH_DELAY_MS); } }, [bookmarks, highlights, push]); // Puts `next` on screen and in localStorage without treating it as a local edit. const apply = useCallback((next) => { if (marksEqual(next, latestRef.current)) return; skipRef.current = true; lsSet(KEYS.bookmarks, JSON.stringify(next.bookmarks)); lsSet(KEYS.highlights, JSON.stringify(next.highlights)); setBookmarks(next.bookmarks); setHighlights(next.highlights); }, [setBookmarks, setHighlights]); // Sign-in (and tab focus): reconcile this device with the server's copy. const pullServerMarks = useCallback(async () => { enabledRef.current = true; lastPullRef.current = Date.now(); const result = await getRemoteMarks(); if (!result.ok) return; const server = normalizeMarks(result.data); const serverAt = Number(result.data?.updatedAt) || 0; const local = normalizeMarks(latestRef.current); const syncedAt = Number(lsGet(KEYS.syncedAt)) || 0; const dirty = lsGet(KEYS.dirty) === '1'; let next = local; let needsPush = false; if (serverAt === 0) { needsPush = !isEmptyMarks(local); // first time on this account: the server takes what's here } else if (syncedAt === 0) { next = mergeMarks(local, server); // first time this device meets the server's copy: keep both needsPush = !marksEqual(next, server); } else if (serverAt > syncedAt) { next = dirty ? mergeMarks(local, server) : server; // another device saved since we last synced needsPush = dirty && !marksEqual(next, server); } else { needsPush = dirty; // server has nothing newer than what we already have } apply(next); if (serverAt > 0) lsSet(KEYS.syncedAt, String(serverAt)); if (needsPush) { setDirty(true); latestRef.current = next; await push(); } else if (serverAt >= syncedAt) setDirty(false); }, [apply, push]); // Save anything pending before signing out. const flushMarks = useCallback(async () => { if (enabledRef.current && lsGet(KEYS.dirty) === '1') await push(); }, [push]); // Signed out: stop syncing and clear this device's copy, so the next person to sign in // here starts clean (their marks come from their own account). const stopMarksSync = useCallback(() => { enabledRef.current = false; window.clearTimeout(timerRef.current); [KEYS.bookmarks, KEYS.highlights, KEYS.syncedAt, KEYS.dirty].forEach(lsDel); apply(emptyMarks()); }, [apply]); // Coming back to the tab (another device may have saved), or leaving it (save now). useEffect(() => { const onVisibility = () => { if (!enabledRef.current) return; if (document.visibilityState === 'hidden') { if (lsGet(KEYS.dirty) === '1') push(); return; } if (Date.now() - lastPullRef.current > FOCUS_PULL_MIN_MS) pullServerMarks(); }; document.addEventListener('visibilitychange', onVisibility); return () => document.removeEventListener('visibilitychange', onVisibility); }, [push, pullServerMarks]); useEffect(() => () => window.clearTimeout(timerRef.current), []); return { pullServerMarks, flushMarks, stopMarksSync }; }