// The signed-in user's saved projects: opening, renaming, deleting, and keeping // this device's copies in step with the server. import { useEffect, useRef, useState } from 'react'; import { useToast } from '../components/Toasts.jsx'; import { clearSyncBase, getSyncBase, setSyncBase } from '../utils/syncBase.js'; import { loadProjectIndex } from '../utils/bibleUtils.jsx'; import { knownOnServer, pendingDeletes as pendingDeleteIds, unsyncedStudies } from '../utils/syncQueue.js'; import { saveProjectToStorage, deleteProjectFromStorage, loadProjectById, } from '../utils/projectStorage.js'; import { saveRemoteProject, deleteRemoteProject, listRemoteProjects, loadRemoteProject, } from '../syncService.js'; export function useProjectLibrary({ authUser, project, setProject, setProjectIndex, setCurrentPage, }) { const { toast } = useToast(); // Deleted projects waiting out their "Undo" window before the server copy is removed too const pendingDeletes = useRef(new Map()); // id -> timer const [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer const [autoRestoredCount, setAutoRestoredCount] = useState(null); // brief "synced from another device" toast // Studies another account invited you to edit: id -> the owner's email (shown on the card) const [sharedStudies, setSharedStudies] = useState({}); const sharedFrom = (list) => Object.fromEntries(list.filter((e) => e.shared).map((e) => [e.id, e.ownerEmail ?? ''])); // Studies saved here that the server hasn't accepted yet (saved offline, or the tab closed mid-sync). // A study another device changed meanwhile is left alone; opening it offers the keep-mine/keep-theirs choice. const pushPending = async () => { for (const id of unsyncedStudies.list()) { const local = loadProjectById(id); if (!local) { unsyncedStudies.remove(id); continue; } const result = await saveRemoteProject(local, { baseEdited: getSyncBase(id) }); if (result.ok) { const serverEdited = result.data?.lastEdited ?? local.lastEdited; setSyncBase(id, serverEdited); unsyncedStudies.remove(id); knownOnServer.add(id); try { saveProjectToStorage({ ...local, lastEdited: serverEdited }); } catch { /* already saved */ } } else if (result.status === 409) { toast(`“${local.title}” changed on another device. Open it to choose which version to keep.`, { tone: 'error' }); } } setProjectIndex(loadProjectIndex()); }; const retryPendingDeletes = async () => { for (const id of pendingDeleteIds.list()) { if (pendingDeletes.current.has(id)) continue; // still inside its Undo window const result = await deleteRemoteProject(id); if (result.ok) { pendingDeleteIds.remove(id); knownOnServer.remove(id); } } }; // Once we know who's signed in, reconcile the local project index against the server. // Runs on every authUser change (including logout -> different login) so a previous // account's stale suggestions never linger after switching users. Projects that exist // on the server but not on this device (e.g. a brand new browser) are pulled down // automatically instead of requiring a manual "Restore" click per project. useEffect(() => { if (!authUser) { setStaleLocalProjects([]); return; } let cancelled = false; const localIndex = loadProjectIndex(); listRemoteProjects().then(async (result) => { if (!result.ok || cancelled) return; // Deletes that never reached the server (offline, tab closed) go first, so those studies aren't pulled back. // (the server's list above may still include them, so note which they are before they're cleared) const gone = new Set(pendingDeleteIds.list()); await retryPendingDeletes(); const serverList = result.data.filter((e) => !gone.has(e.id)); setSharedStudies(sharedFrom(serverList)); const localMap = new Map(localIndex.map((e) => [e.id, e])); // What this device has seen of each study on the server: a save made on top of anything older is // checked against it, so a newer copy from another device is never overwritten silently. for (const e of serverList) { const local = localMap.get(e.id); setSyncBase(e.id, local ? Math.min(e.lastEdited ?? 0, local.lastEdited ?? 0) : e.lastEdited); } // A study this device knew the server had, that the server no longer has, was deleted somewhere else. const onServer = new Set(serverList.map((e) => e.id)); const unsynced = new Set(unsyncedStudies.list()); const deletedElsewhere = localIndex.filter((e) => knownOnServer.has(e.id) && !onServer.has(e.id) && !unsynced.has(e.id) && !gone.has(e.id)); deletedElsewhere.forEach((e) => { const copy = loadProjectById(e.id); deleteProjectFromStorage(e.id); clearSyncBase(e.id); knownOnServer.remove(e.id); localMap.delete(e.id); toast(`“${e.title}” was deleted on another device`, { action: { label: 'Keep it', onClick: () => { if (copy) { saveProjectToStorage(copy); unsyncedStudies.add(copy.id); setProjectIndex(loadProjectIndex()); pushPending(); } }, }, }); }); if (deletedElsewhere.length > 0) setProjectIndex(loadProjectIndex()); knownOnServer.replace(serverList.map((e) => e.id)); const missing = serverList.filter((e) => !localMap.has(e.id)); if (missing.length > 0) { const fetched = await Promise.all(missing.map((e) => loadRemoteProject(e.id))); fetched.filter((r) => r.ok).forEach((r) => { saveProjectToStorage(r.data); setSyncBase(r.data.id, r.data.lastEdited); }); if (cancelled) return; const restoredCount = fetched.filter((r) => r.ok).length; if (restoredCount > 0) { setProjectIndex(loadProjectIndex()); setAutoRestoredCount(restoredCount); window.setTimeout(() => setAutoRestoredCount(null), 4000); } } const stale = serverList.filter((e) => { const local = localMap.get(e.id); return local && (e.lastEdited ?? 0) > (local.lastEdited ?? 0) && !unsynced.has(e.id); }); if (!cancelled) setStaleLocalProjects(stale); pushPending(); }); return () => { cancelled = true; }; }, [authUser]); // Another device (or a co-teacher) may have saved a study since you opened this page: look again when you come // back to the tab, and every couple of minutes while it's open. A newer copy is offered, never forced on you. const refreshStale = async () => { const result = await listRemoteProjects(); if (!result.ok) return; const local = new Map(loadProjectIndex().map((e) => [e.id, e])); const unsynced = new Set(unsyncedStudies.list()); setSharedStudies(sharedFrom(result.data)); setStaleLocalProjects(result.data.filter((e) => { const mine = local.get(e.id); return mine && (e.lastEdited ?? 0) > (mine.lastEdited ?? 0) && !unsynced.has(e.id); })); }; useEffect(() => { if (!authUser) return undefined; let last = Date.now(); const maybe = () => { if (document.visibilityState === 'visible' && Date.now() - last > 30_000) { last = Date.now(); refreshStale(); } }; document.addEventListener('visibilitychange', maybe); const timer = window.setInterval(maybe, 120_000); return () => { document.removeEventListener('visibilitychange', maybe); window.clearInterval(timer); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [authUser]); // Back online: send what the server missed. useEffect(() => { const online = () => { if (authUser) { retryPendingDeletes(); pushPending(); } }; window.addEventListener('online', online); return () => window.removeEventListener('online', online); }, [authUser]); const resumeProject = (id) => { const loaded = loadProjectById(id); if (!loaded) return; setProject(loaded); const loadedChunks = loaded.chapters.flatMap((ch) => ch.chunks); const hasSelectedChunk = loaded.selectedChunkId && loadedChunks.some((c) => c.id === loaded.selectedChunkId); setCurrentPage(hasSelectedChunk ? 'study' : 'setup'); }; // Opens a study on one particular chunk (from the Reader's "In your studies"). const openStudyChunk = (id, chunkId) => { const loaded = loadProjectById(id); if (!loaded) return; setProject({ ...loaded, selectedChunkId: chunkId }); setCurrentPage('study'); }; const renameProjectInStorage = (id, title) => { const loaded = loadProjectById(id); if (!loaded) return; const updated = { ...loaded, title, lastEdited: Date.now() }; saveProjectToStorage(updated); setProjectIndex(loadProjectIndex()); unsyncedStudies.add(id); saveRemoteProject(updated, { baseEdited: getSyncBase(id) }).then((result) => { if (result.ok) { const serverEdited = result.data?.lastEdited ?? updated.lastEdited; setSyncBase(id, serverEdited); unsyncedStudies.remove(id); try { saveProjectToStorage({ ...updated, lastEdited: serverEdited }); setProjectIndex(loadProjectIndex()); } catch { /* already saved */ } } else if (result.status === 409) toast('That study changed on another device. Open it to choose which version to keep.', { tone: 'error' }); }); if (project?.id === id) { setProject((current) => (current ? { ...current, title } : current)); } }; // Puts a study in a series ("Romans", "Advent 2026"), or takes it out (an empty name). const setProjectSeries = (id, series) => { const loaded = loadProjectById(id); if (!loaded) return; const name = String(series ?? '').trim().slice(0, 80); if ((loaded.series ?? '') === name) return; const updated = { ...loaded, series: name, lastEdited: Date.now() }; saveProjectToStorage(updated); unsyncedStudies.add(id); setProjectIndex(loadProjectIndex()); if (project?.id === id) setProject((current) => (current ? { ...current, series: name } : current)); saveRemoteProject(updated, { baseEdited: getSyncBase(id) }).then((result) => { if (result.ok) { const serverEdited = result.data?.lastEdited ?? updated.lastEdited; setSyncBase(id, serverEdited); unsyncedStudies.remove(id); try { saveProjectToStorage({ ...updated, lastEdited: serverEdited }); setProjectIndex(loadProjectIndex()); } catch { /* already saved */ } } else if (result.status === 409) toast('That study changed on another device. Open it to choose which version to keep.', { tone: 'error' }); }); }; // Changes one chunk of a stored study (from Home, without opening it) and syncs it. `change` takes the chunk, returns the new one. const updateStoredChunk = (id, chunkId, change) => { const loaded = loadProjectById(id); if (!loaded) return; const updated = { ...loaded, lastEdited: Date.now(), chapters: loaded.chapters.map((ch) => ({ ...ch, chunks: ch.chunks.map((c) => (c.id === chunkId ? change(c) : c)), })), }; saveProjectToStorage(updated); unsyncedStudies.add(id); setProjectIndex(loadProjectIndex()); if (project?.id === id) setProject(updated); saveRemoteProject(updated, { baseEdited: getSyncBase(id) }).then((result) => { if (result.ok) { const serverEdited = result.data?.lastEdited ?? updated.lastEdited; setSyncBase(id, serverEdited); unsyncedStudies.remove(id); try { saveProjectToStorage({ ...updated, lastEdited: serverEdited }); setProjectIndex(loadProjectIndex()); } catch { /* already saved */ } } else if (result.status === 409) toast('That study changed on another device. Open it to choose which version to keep.', { tone: 'error' }); }); }; // A weekly recall (what you remembered, and how it went) on one chunk. const saveChunkRecall = (id, chunkId, recall) => updateStoredChunk(id, chunkId, (c) => ({ ...c, recalls: [...(c.recalls ?? []), recall] })); // Notes written from Home (the daily five minutes): counts as editing the chunk. const saveChunkPatch = (id, chunkId, patch) => updateStoredChunk(id, chunkId, (c) => ({ ...c, ...patch, editedAt: Date.now() })); // Deleting is immediate here, with an Undo for a few seconds; only after that is the server copy // removed (and straight away if the tab is closed first, so a deleted project never comes back). const finishDelete = (id) => { if (!pendingDeletes.current.has(id)) return; window.clearTimeout(pendingDeletes.current.get(id)); pendingDeletes.current.delete(id); deleteRemoteProject(id).then((result) => { // stays on the pending list until the server confirms if (result.ok) { pendingDeleteIds.remove(id); knownOnServer.remove(id); } }); }; useEffect(() => { const flush = () => [...pendingDeletes.current.keys()].forEach(finishDelete); window.addEventListener('pagehide', flush); return () => window.removeEventListener('pagehide', flush); }, []); const deleteProject = (id) => { const loaded = loadProjectById(id); if (!loaded) return; deleteProjectFromStorage(id); clearSyncBase(id); unsyncedStudies.remove(id); pendingDeleteIds.add(id); setProjectIndex(loadProjectIndex()); pendingDeletes.current.set(id, window.setTimeout(() => finishDelete(id), 8000)); if (sharedStudies[id] !== undefined) { // A study someone else owns: this only removes you from it (they keep it). A new invite is needed to rejoin. toast(`You left “${loaded.title}”. Ask for a new invite if you want back in.`); return; } toast(`Deleted “${loaded.title}”`, { action: { label: 'Undo', onClick: () => { window.clearTimeout(pendingDeletes.current.get(id)); pendingDeletes.current.delete(id); pendingDeleteIds.remove(id); unsyncedStudies.add(id); // re-sent in case the server copy was already removed saveProjectToStorage(loaded); setProjectIndex(loadProjectIndex()); }, }, }); }; const pullLatestFromServer = async (id) => { const result = await loadRemoteProject(id); if (!result.ok) { toast('Could not pull the latest version from the server.', { tone: 'error' }); return; } saveProjectToStorage(result.data); setSyncBase(id, result.data.lastEdited); setProjectIndex(loadProjectIndex()); setStaleLocalProjects((prev) => prev.filter((e) => e.id !== id)); }; return { sharedStudies, refreshLibrary: refreshStale, staleLocalProjects, autoRestoredCount, resumeProject, openStudyChunk, renameProjectInStorage, setProjectSeries, saveChunkRecall, saveChunkPatch, deleteProject, pullLatestFromServer, }; }