diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index a3e17292d..e960fb112 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -30,6 +30,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp - Sparring is the website's typed mode: `sparring.ts` sends the opponent's turns and the judge as `generateSparringTurn` and `judgeSparringSession` in `src/lib/ai/client.ts` do (the Sparring model settings, a transcript-only debate context), and the round's moves (turn order, the deadline clock, judging, saved rows) are `packages/core/src/sparring/session.ts`, shared with the website's sparring store. The server saves the judged round to `sparring_sessions` when it scores it; the app then files it in its debate with `attach_to_debate` and the student's side, as the store does. The side is asked when the round starts. The unfinished round is a session slot (`sparringDraft`), so leaving the screen keeps it, and a request cut off by leaving is offered again. The flow button opens the Flowchart on the Current Debate's map, above the round, which keeps running. Practice lists the Current Debate's judged rounds and the Debate tab lists them by side; both open `sparring/[id]`, which can delete them. Voice rounds need the development build, so the voice switch shows disabled. - Flowchart mirrors the website's `FlowchartPage.tsx` and the session store's `saveFlowchart` and `deleteSavedFlowchart` without React Flow: the saved `flowcharts` row keeps the website's React Flow node and edge JSON, and `packages/core/src/flowchart.ts` reads and edits it (starter map, edge kinds from the label or stroke the website writes, role columns, the tree, and edits that keep every field they do not touch). `flowcharts.ts` reads and writes the rows; Save over an opened map updates it, Save as inserts a row as the website's Save does. `flowchart-drafts.ts` holds each debate's open map and unsaved edits in session memory, shared by the screen and its sheets. Maps follow `activeDebateId`: one-off maps are saved with no debate, as on the website. The website's PNG export is a PDF of the Map tree here (`flowchartExportHtml`). - One-off mode is session-only and never written, as on the website; only maps saved in it are kept, with no debate, as the website keeps them. The switcher turns it on with or without a Current Debate, and `OneOffNotice` shows it on the Debate tab, Prepare, Ask, Library and the Generate, Attack, Brainstorm, Factoids, Crossfire and Sparring forms (through `WorkingIn`) and the Flowchart. The Debate tab, Prepare and Library still list the Current Debate's work in one-off mode, as the website's home does. Students prepare both sides of a motion, so a debate has no side: the Debate tab's In favor / Against toggle only chooses which side's work it shows, using the website's rule (`'both'` counts for either side, neutral and side-less work for neither). It starts on In favor and is never stored. +- Offline reading: `offline-cache.ts` keeps a copy of every read in `debates.ts`, `sources.ts`, `results.ts`, `flowcharts.ts`, `chats.ts` and `sparring.ts` (the debates list, counts and recent work, Library sources with their text, saved results, maps, chats and judged Sparring rounds) in AsyncStorage under the user id. A read goes to the network first and saves its answer; when no answer comes back (supabase-js status 0: no connection, DNS, timeout), or `network-status.ts` (expo-network) says the device is offline, the copy answers and `OfflineNotice` shows at the top of `Screen`, `DocumentScreen` and the Flowchart until each copy shown is read from the server again or the screen showing it closes, moves on or leaves view (a screen passes `useCopyHolder().mark()` with its reads). A server error (an expired session, a policy, a 5xx) is thrown, never answered from a copy; a read made of several queries runs them through `readAll` (or `pickFailure`), so one query's refusal wins over another's missing answer. A slower answer never replaces a newer copy, each group keeps its most recently fetched copies only (`LIMITS`), and the auth provider calls `setCacheOwner` before it adopts a session, so another account's copies are removed before its first read. Nothing is written offline: AI and saving fail with their network errors, and there is no write queue. A read added to those modules goes through `cachedRead`, takes an optional `CopyMark`, and throws `readFailed(error, status)`. Every write that succeeds calls `afterWrite` with the copies it changed (`debateListScopes` names a debate's counts and lists; a delete uses `deletedListScopes`, which drops every debate's when no row came back), which drops them and stops older reads from saving over them, or `updateCopy` when it knows the new value. - `src/theme/`: colours from the mocks and the website's dark palette, Inter fonts. - `scripts/screenshots.ts`: web-export screenshots. `scripts/render-tab-icons.ts` redraws the tab bar PNGs. @@ -41,6 +42,7 @@ Root `npm run lint` and `npm run typecheck` cover the app. From the repo root: npm run -w @debatelab/mobile typecheck npm run -w @debatelab/mobile export:web PORT= npx tsx apps/mobile/scripts/screenshots.ts +ONLY=offline PORT=18080 STAGING_DEMO_CODE=... npx tsx apps/mobile/scripts/screenshots.ts # offline reading, signed in ``` Screenshots go to `.verify/mobile-shots/`. For signed-in screens, see the header of `scripts/screenshots.ts`. To load the app on a phone, see [docs/development.md](../../docs/development.md#mobile-app). diff --git a/apps/mobile/package.json b/apps/mobile/package.json index f422a6f74..26762557a 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -29,6 +29,7 @@ "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.12", "expo-localization": "~57.0.2", + "expo-network": "~57.0.2", "expo-print": "~57.0.2", "expo-router": "~57.0.25", "expo-secure-store": "~57.0.4", diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index fe86734ea..39d146cce 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -8,6 +8,7 @@ * PORT= npx tsx apps/mobile/scripts/screenshots.ts * * ONLY=iphone-light-en limits a run to the combinations whose name starts with it. + * ONLY=offline runs only the offline reading check (see shootOfflineScreens). * * Signed-in screens need a staging session. With STAGING_DEMO_CODE set, the * script signs in once through the real sign-in screen and also checks that a @@ -63,7 +64,7 @@ import { chromium, type Browser, type BrowserContext, type BrowserContextOptions import { createReadStream, existsSync, statSync } from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; import { createServer, type Server } from 'node:http'; -import { extname, join, normalize } from 'node:path'; +import { dirname, extname, join, normalize } from 'node:path'; import { SUPABASE_PROJECTS } from '../../../src/lib/supabase/projects'; @@ -101,7 +102,8 @@ const TYPES: Record = { function serve(): Promise { const server = createServer((req, res) => { const path = normalize(decodeURIComponent(new URL(req.url ?? '/', ORIGIN).pathname)).replace(/^(\.\.[/\\])+/, ''); - const candidates = [join(DIST, path), join(DIST, `${path}.html`), join(DIST, path, 'index.html')]; + // A dynamic route such as /case/ is exported once, as case/[id].html. + const candidates = [join(DIST, path), join(DIST, `${path}.html`), join(DIST, path, 'index.html'), join(DIST, dirname(path), '[id].html')]; const file = candidates.find((candidate) => candidate.startsWith(DIST) && existsSync(candidate) && statSync(candidate).isFile()); if (!file) { res.writeHead(404).end('Not found'); @@ -116,11 +118,16 @@ function serve(): Promise { }); } -/** Fails the run on page errors and console errors, which leave plausible-looking but broken screenshots. */ -function watchErrors(page: Page, problems: string[], label: string) { +/** + * Fails the run on page errors and console errors, which leave plausible-looking but broken screenshots. + * While `offline()` is true, the browser's own "Failed to load resource" errors are expected. + */ +function watchErrors(page: Page, problems: string[], label: string, offline = () => false) { page.on('pageerror', (error) => problems.push(`${label}: ${error.message}`)); page.on('console', (message) => { - if (message.type() === 'error') problems.push(`${label}: ${message.text()}`); + if (message.type() !== 'error') return; + if (offline() && message.text().startsWith('Failed to load resource')) return; + problems.push(`${label}: ${message.text()}`); }); } @@ -1128,6 +1135,203 @@ async function checkOfflineColdStart(browser: Browser, state: StorageState, prob if (!tabsShown || signInShown || !stored || !refreshed) throw new Error('Offline cold start signed the user out'); } +const OFFLINE_RUNS = [ + { prefix: 'iphone-light-en', scheme: 'light', language: 'en' }, + { prefix: 'iphone-dark-hu', scheme: 'dark', language: 'hu' }, +] as const; +const TABS = { + debate: /^(Debate|Vita)$/, + prepare: /^(Prepare|Felkészülés)$/, + library: /^(Library|Könyvtár)$/, + you: /^(You|Profil)$/, +}; +const CACHE_PREFIX = 'debatelab.cache.v1'; +const SAVED_CASE_ID = '00000000-0000-4000-8000-0000000ca5ed'; +// Stands in for another account whose saved copies are still on the device. +const OTHER_USER_ID = '00000000-0000-4000-8000-0000000a11ce'; + +/** Answers the read of one made-up saved case (FAKE_CASE). Nothing is written. */ +async function fakeSavedCase(page: Page) { + await page.route(`${REST}/cases*`, (route) => { + const request = route.request(); + if (request.method() !== 'GET' || new URL(request.url()).searchParams.get('id') !== `eq.${SAVED_CASE_ID}`) return route.continue(); + const row = { id: SAVED_CASE_ID, title: 'Car-free city centres', side: 'pro', debate_id: null, created_at: FAKE_CASE.metadata.generatedAt, input: {}, output: FAKE_CASE }; + const single = (request.headers().accept ?? '').includes('vnd.pgrst.object'); + return route.fulfill({ status: 200, json: single ? row : [row] }); + }); +} + +// Screens under the current one stay mounted on web, with their own notice. +const shownNotice = (page: Page) => page.locator('[data-testid="offline-notice"]:visible').first(); +const cacheKeys = (page: Page) => page.evaluate((prefix) => Object.keys(localStorage).filter((key) => key.startsWith(prefix)), CACHE_PREFIX); + +async function openFlowchart(page: Page) { + await page.getByTestId('prepare-tool-flowchart').waitFor({ timeout: 30_000 }); + await settle(page); + await page.getByTestId('prepare-tool-flowchart').click(); + await page.getByTestId('flow-card').first().waitFor({ timeout: 30_000 }); +} + +/** + * Offline reading (ONLY=offline). Online, the run reads the Debate tab, the + * Library and a source, a saved case and a flowchart, so the app keeps + * copies. Then, first with the device offline (expo-network sees + * navigator.onLine turn false) and navigating inside the app, and then + * online but with every staging request failing and the page reloaded, the + * same screens must show the same data under the offline notice. The case + * and the maps are made up (FAKE_CASE, FAKE_FLOWCHART_ROWS); the debate and + * the Library are the account's own. Nothing is written to staging. + * Playwright's offline mode also blocks the local server, so the reload + * fails staging requests instead. + */ +async function shootOfflineScreens(browser: Browser, state: StorageState, problems: string[], run: (typeof OFFLINE_RUNS)[number], isolation: boolean) { + const { prefix, scheme, language } = run; + const context = await browser.newContext({ ...DEVICES.iphone, colorScheme: scheme, locale: LOCALES[language], storageState: state }); + const page = await context.newPage(); + try { + return await readOffline(context, page, problems, run, isolation); + } catch (error) { + await shoot(page, `${prefix}-offline-failed`); + const spinners = await page.evaluate(() => Array.from(document.querySelectorAll('[role="progressbar"]'), (node) => node.getAttribute('aria-label') ?? '')); + console.error(`${prefix}: failed at ${page.url()} with ${spinners.length} progress indicators ${JSON.stringify(spinners)}`); + throw error; + } finally { + await context.close(); + } +} + +async function readOffline(context: BrowserContext, page: Page, problems: string[], run: (typeof OFFLINE_RUNS)[number], isolation: boolean) { + const { prefix, language } = run; + let offline = false; + watchErrors(page, problems, `${prefix} offline`, () => offline); + page.on('dialog', (dialog) => void dialog.dismiss()); + await pinSettings(page, language); + await fakeSavedCase(page); + await fakeFlowcharts(page); + + // Online: read each screen once. + await page.goto(`${ORIGIN}/`); + await page.getByTestId('current-debate-motion').waitFor({ timeout: 30_000 }); + await page.waitForFunction(() => !document.querySelector('[data-testid="debate-counts"]')?.textContent?.includes('–')); + await settle(page); + const motion = await page.getByTestId('current-debate-motion').textContent(); + const counts = await page.getByTestId('debate-counts').textContent(); + await page.getByRole('tab', { name: TABS.library }).click(); + await page.getByTestId('source-row').first().waitFor({ timeout: 30_000 }); + await settle(page); + const firstSource = await page.getByTestId('source-row').first().textContent(); + await page.getByTestId('source-row').first().click(); + await page.getByTestId('source-sheet').getByRole('heading').waitFor({ timeout: 30_000 }); + await settle(page); + await page.goto(`${ORIGIN}/case/${SAVED_CASE_ID}`); + await page.getByTestId('case-document').waitFor({ timeout: 30_000 }); + await settle(page); + await page.goto(`${ORIGIN}/prepare`); + await openFlowchart(page); + await settle(page); + if (await page.getByTestId('offline-notice').count()) throw new Error('The offline notice showed while online'); + + const userId = await page.evaluate((key) => (JSON.parse(localStorage.getItem(key) ?? '{}') as { user?: { id?: string } }).user?.id ?? '', SESSION_KEY); + const kept = await cacheKeys(page); + const foreign = kept.filter((key) => key !== `${CACHE_PREFIX}.owner` && !key.includes(userId)); + console.log(`${prefix}: ${kept.length} saved-copy keys after reading online, ${foreign.length} not under the signed-in account`); + if (!userId || kept.length < 5 || foreign.length > 0) throw new Error('The app did not keep its saved copies under the signed-in account'); + + const sameData = async (label: string) => { + await page.waitForFunction(() => !document.querySelector('[data-testid="debate-counts"]')?.textContent?.includes('–'), undefined, { timeout: 30_000 }); + await settle(page); + if ((await page.getByTestId('current-debate-motion').textContent()) !== motion) throw new Error(`${label}: the motion differs from the online read`); + if ((await page.getByTestId('debate-counts').textContent()) !== counts) throw new Error(`${label}: the counts differ from the online read`); + }; + + // The device goes offline; the app navigates without reloading. + offline = true; + await context.setOffline(true); + await shownNotice(page).waitFor({ timeout: 10_000 }); + await page.goBack(); + await page.getByRole('tab', { name: TABS.debate }).click(); + await page.getByTestId('current-debate-motion').waitFor({ timeout: 15_000 }); + await shownNotice(page).waitFor(); + await sameData('device offline'); + await shoot(page, `${prefix}-offline-debate`); + await page.getByRole('tab', { name: TABS.library }).click(); + await page.getByTestId('source-row').first().waitFor({ timeout: 15_000 }); + if ((await page.getByTestId('source-row').first().textContent()) !== firstSource) throw new Error('device offline: the Library differs'); + await shoot(page, `${prefix}-offline-library`); + await page.getByTestId('source-row').first().click(); + await page.getByTestId('source-sheet').getByRole('heading').waitFor({ timeout: 15_000 }); + await page.waitForTimeout(700); + await shoot(page, `${prefix}-offline-source`); + + // Back online, but staging cannot be reached, and the page reloads. + await context.setOffline(false); + const toBackend = (url: URL) => url.host === STAGING_HOST; + await page.route(toBackend, (route) => route.abort('internetdisconnected')); + await page.goto(`${ORIGIN}/`); + await page.getByTestId('current-debate-motion').waitFor({ timeout: 30_000 }); + await shownNotice(page).waitFor({ timeout: 15_000 }); + await sameData('staging unreachable'); + await shoot(page, `${prefix}-offline-reload-debate`); + await page.getByRole('tab', { name: TABS.library }).click(); + await page.getByTestId('source-row').first().waitFor({ timeout: 15_000 }); + await shoot(page, `${prefix}-offline-reload-library`); + await page.goto(`${ORIGIN}/case/${SAVED_CASE_ID}`); + await page.getByTestId('case-document').waitFor({ timeout: 30_000 }); + await shownNotice(page).waitFor(); + await page.waitForTimeout(400); + await shoot(page, `${prefix}-offline-case`); + await page.goto(`${ORIGIN}/prepare`); + await openFlowchart(page); + await page.getByTestId('flowchart-screen').locator('[data-testid="offline-notice"]:visible').waitFor(); + await shoot(page, `${prefix}-offline-flowchart`); + let shots = 7; + + if (isolation) { + // Another account's copies on this device: move every copy to OTHER_USER_ID and make it the owner. + await page.evaluate(({ prefix: cachePrefix, from, to }) => { + for (const key of Object.keys(localStorage)) { + if (!key.startsWith(cachePrefix) || !key.includes(from)) continue; + const value = localStorage.getItem(key) ?? ''; + localStorage.setItem(key.replaceAll(from, to), value.replaceAll(from, to)); + localStorage.removeItem(key); + } + localStorage.setItem(`${cachePrefix}.owner`, to); + }, { prefix: CACHE_PREFIX, from: userId, to: OTHER_USER_ID }); + const planted = (await cacheKeys(page)).filter((key) => key.includes(OTHER_USER_ID)).length; + await page.goto(`${ORIGIN}/`); + await page.getByTestId('auth-settled').waitFor({ state: 'attached', timeout: 60_000 }); + await settle(page); + await page.waitForFunction( + ({ prefix: cachePrefix, other }) => !Object.keys(localStorage).some((key) => key.startsWith(cachePrefix) && key.includes(other)), + { prefix: CACHE_PREFIX, other: OTHER_USER_ID }, + { timeout: 15_000 }, + ); + const leaked = (await page.locator('body').innerText()).includes(motion ?? '\u0000'); + const motionShown = await page.getByTestId('current-debate-motion').count(); + await shoot(page, `${prefix}-offline-other-account`); + shots += 1; + console.log(`${prefix}: planted ${planted} copies for another account; after a start as this account: left ${(await cacheKeys(page)).filter((key) => key.includes(OTHER_USER_ID)).length}, motion shown ${motionShown > 0}`); + if (planted === 0 || leaked || motionShown > 0) throw new Error("This account was shown another account's saved copies"); + + // Signing out removes every copy. The logout request is answered here, so the staging session stays valid for other runs. + await page.unroute(toBackend); + offline = false; + await page.route(`https://${STAGING_HOST}/auth/v1/logout*`, (route) => route.fulfill({ status: 204 })); + await page.goto(`${ORIGIN}/`); + await page.getByTestId('current-debate-motion').waitFor({ timeout: 30_000 }); + await settle(page); + const before = (await cacheKeys(page)).length; + await page.getByRole('tab', { name: TABS.you }).click(); + await page.getByRole('button', { name: /^(Sign out|Kijelentkezés)$/ }).click(); + await page.waitForURL('**/sign-in', { timeout: 30_000 }); + await page.waitForFunction((prefix) => !Object.keys(localStorage).some((key) => key.startsWith(prefix)), CACHE_PREFIX, { timeout: 15_000 }); + console.log(`${prefix}: ${before} saved-copy keys before sign-out, 0 after`); + if (before === 0) throw new Error('No saved copies to clear before sign-out'); + } + + return shots; +} + async function main() { if (!Number.isInteger(PORT) || PORT <= 0) throw new Error('Set PORT to a free port'); if (!existsSync(join(DIST, 'index.html'))) throw new Error('Run `npm run -w @debatelab/mobile export:web` first'); @@ -1139,7 +1343,9 @@ async function main() { let shots = 0; try { const session = DEMO_CODE ? await signIn(browser, problems) : null; - if (session) await checkOfflineColdStart(browser, session, problems); + if (session && ONLY === 'offline') { + for (const [index, run] of OFFLINE_RUNS.entries()) shots += await shootOfflineScreens(browser, session, problems, run, index === 0); + } else if (session) await checkOfflineColdStart(browser, session, problems); else console.log('STAGING_DEMO_CODE is not set: signed-in screens skipped'); for (const [device, options] of Object.entries(DEVICES)) { diff --git a/apps/mobile/src/app/(tabs)/(debate)/index.tsx b/apps/mobile/src/app/(tabs)/(debate)/index.tsx index 5e9f94c68..cebb274d8 100644 --- a/apps/mobile/src/app/(tabs)/(debate)/index.tsx +++ b/apps/mobile/src/app/(tabs)/(debate)/index.tsx @@ -23,6 +23,7 @@ import { type DebateItem, type DebateSide, } from '@/lib/debates'; +import { useCopyHolder } from '@/lib/use-copy-holder'; import { mainActionGradient, radius } from '@/theme/colors'; import { useTheme } from '@/theme/use-theme'; @@ -128,15 +129,17 @@ function DebateDetails({ debateId }: { debateId: string }) { const [counts, setCounts] = useState(null); const [recent, setRecent] = useState | null>(null); const [failed, setFailed] = useState(false); + const copies = useCopyHolder(); // Both sides load together, so switching the view is instant. const load = useCallback(async (isCurrent: () => boolean) => { if (!userId) return; + const mark = copies.mark(); try { const [nextCounts, pro, con] = await Promise.all([ - fetchDebateCounts(userId, debateId), - fetchRecentWork(userId, debateId, 'pro', RECENT_LIMIT), - fetchRecentWork(userId, debateId, 'con', RECENT_LIMIT), + fetchDebateCounts(userId, debateId, mark), + fetchRecentWork(userId, debateId, 'pro', RECENT_LIMIT, mark), + fetchRecentWork(userId, debateId, 'con', RECENT_LIMIT, mark), ]); if (!isCurrent()) return; setCounts(nextCounts); @@ -147,17 +150,18 @@ function DebateDetails({ debateId }: { debateId: string }) { console.warn('Could not load the debate:', cause instanceof Error ? cause.message : cause); setFailed(true); } - }, [userId, debateId]); + }, [userId, debateId, copies]); - // Reload on focus, so work saved on the website shows up. + // Reload on focus, so work saved on the website shows up. Out of view, its saved copies no longer count. useFocusEffect( useCallback(() => { let active = true; void load(() => active); return () => { active = false; + copies.release(); }; - }, [load]), + }, [load, copies]), ); const results = counts?.results[view]; diff --git a/apps/mobile/src/app/(tabs)/(library)/library.tsx b/apps/mobile/src/app/(tabs)/(library)/library.tsx index 43a74edba..19fef2639 100644 --- a/apps/mobile/src/app/(tabs)/(library)/library.tsx +++ b/apps/mobile/src/app/(tabs)/(library)/library.tsx @@ -18,6 +18,7 @@ import { useAuth } from '@/lib/auth'; import { confirmDestructive } from '@/lib/confirm'; import { useDebates } from '@/lib/debate-context'; import { fetchSources, removeSource, subscribeSourcesChanged, type Source, type SourceType } from '@/lib/sources'; +import { useCopyHolder } from '@/lib/use-copy-holder'; import { useTheme } from '@/theme/use-theme'; type Filter = 'all' | SourceType; @@ -75,13 +76,14 @@ function SourceList({ debateId }: { debateId: string }) { const [query, setQuery] = useState(''); const [filter, setFilter] = useState('all'); const loads = useRef(0); + const copies = useCopyHolder(); const userId = session?.user.id ?? null; const load = useCallback(async () => { if (!userId) return; const load = ++loads.current; try { - const next = await fetchSources(userId, debateId); + const next = await fetchSources(userId, debateId, copies.mark()); if (load !== loads.current) return; setSources(next); setFailed(false); @@ -90,13 +92,15 @@ function SourceList({ debateId }: { debateId: string }) { console.warn('Could not load sources:', cause instanceof Error ? cause.message : cause); setFailed(true); } - }, [userId, debateId]); + }, [userId, debateId, copies]); // Sources added on the website show up when the tab comes back into view. + // Out of view, its saved copies no longer count for the offline notice. useFocusEffect( useCallback(() => { void load(); - }, [load]), + return () => copies.release(); + }, [load, copies]), ); useEffect(() => subscribeSourcesChanged(() => void load()), [load]); diff --git a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx index 1a9a9d2d2..b57cbfdac 100644 --- a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx +++ b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx @@ -14,6 +14,7 @@ import { useAuth } from '@/lib/auth'; import { useDebates } from '@/lib/debate-context'; import { keepIfSame } from '@/lib/keep-if-same'; import { fetchSparringRounds, type SparringRoundSummary } from '@/lib/sparring'; +import { useCopyHolder } from '@/lib/use-copy-holder'; import { useTheme } from '@/theme/use-theme'; const ROUNDS_LIMIT = 5; @@ -44,13 +45,14 @@ export default function PracticeScreen() { // Every load takes a number; only the newest may write, so a slow load for another debate never replaces this one's. const loads = useRef(0); + const copies = useCopyHolder(); const load = useCallback(async () => { if (!userId) return; const forDebate = debateId; const request = ++loads.current; const isCurrent = () => loads.current === request; try { - const items = await fetchSparringRounds(userId, forDebate, ROUNDS_LIMIT); + const items = await fetchSparringRounds(userId, forDebate, ROUNDS_LIMIT, copies.mark()); if (!isCurrent()) return; setRounds((previous) => keepIfSame(previous, { debateId: forDebate, items, failed: false })); } catch (cause) { @@ -58,16 +60,17 @@ export default function PracticeScreen() { console.warn('Could not load sparring rounds:', cause instanceof Error ? cause.message : cause); setRounds({ debateId: forDebate, items: null, failed: true }); } - }, [userId, debateId]); + }, [userId, debateId, copies]); - // Reload on focus, so a round just judged or deleted shows up. + // Reload on focus, so a round just judged or deleted shows up. Out of view, its saved copies no longer count. useFocusEffect( useCallback(() => { void load(); return () => { loads.current += 1; + copies.release(); }; - }, [load]), + }, [load, copies]), ); const items = rounds?.debateId === debateId ? rounds.items : null; diff --git a/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx b/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx index 625c6f5fa..398100fe4 100644 --- a/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx +++ b/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx @@ -15,6 +15,7 @@ import { WorkRow, workHref } from '@/components/work-row'; import { useAuth } from '@/lib/auth'; import { useDebates } from '@/lib/debate-context'; import { fetchPrepareWork, type DebateItem } from '@/lib/debates'; +import { useCopyHolder } from '@/lib/use-copy-holder'; import { keepIfSame } from '@/lib/keep-if-same'; import { useTheme } from '@/theme/use-theme'; @@ -38,13 +39,14 @@ export default function PrepareScreen() { // Every load, from focus or Retry, takes a number; only the newest may write, so a late Retry for another debate never replaces this one's. const loads = useRef(0); + const copies = useCopyHolder(); const load = useCallback(async () => { if (!userId || !debateId) return; const forDebate = debateId; const request = ++loads.current; const isCurrent = () => loads.current === request; try { - const next = await fetchPrepareWork(userId, forDebate, SAVED_LIMIT); + const next = await fetchPrepareWork(userId, forDebate, SAVED_LIMIT, copies.mark()); if (!isCurrent()) return; // Every focus rereads the list; an unchanged one keeps its identity, so the rows do not re-render. setLoaded((previous) => keepIfSame(previous, { debateId: forDebate, items: next, failed: false })); @@ -53,16 +55,17 @@ export default function PrepareScreen() { console.warn('Could not load saved results:', cause instanceof Error ? cause.message : cause); setLoaded({ debateId: forDebate, items: null, failed: true }); } - }, [userId, debateId]); + }, [userId, debateId, copies]); - // Reload on focus, so a case generated a moment ago, or on the website, shows up. + // Reload on focus, so a case generated a moment ago, or on the website, shows up. Out of view, its saved copies no longer count. useFocusEffect( useCallback(() => { void load(); return () => { loads.current += 1; + copies.release(); }; - }, [load]), + }, [load, copies]), ); return ( diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 356152df5..65b0d75bc 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -11,6 +11,7 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { AuthProvider, useAuth } from '@/lib/auth'; import { DebateProvider } from '@/lib/debate-context'; +import { watchNetwork } from '@/lib/network-status'; import { SettingsProvider, useSettings } from '@/lib/settings'; import { interFonts } from '@/theme/fonts'; import { useTheme } from '@/theme/use-theme'; @@ -41,6 +42,9 @@ function Root() { if (ready) void SplashScreen.hideAsync(); }, [ready]); + // Offline, reads answer from saved copies and the screens say so. + useEffect(() => watchNetwork(), []); + if (!ready) return null; // Native form sheets with a grabber on iOS. The web preview shows them as full pages. diff --git a/apps/mobile/src/app/add-link.tsx b/apps/mobile/src/app/add-link.tsx index 0d141eb40..6ea07b105 100644 --- a/apps/mobile/src/app/add-link.tsx +++ b/apps/mobile/src/app/add-link.tsx @@ -12,6 +12,7 @@ import { Text } from '@/components/text'; import { useAuth } from '@/lib/auth'; import { useDebates } from '@/lib/debate-context'; import { addSource, fetchSources, importSourceFromUrl, isDuplicateOrigin } from '@/lib/sources'; +import { useCopyHolder } from '@/lib/use-copy-holder'; import { useTheme } from '@/theme/use-theme'; /** @@ -30,6 +31,7 @@ export default function AddLinkSheet() { const [title, setTitle] = useState(''); const [busy, setBusy] = useState<'import' | 'save' | null>(null); const [error, setError] = useState(''); + const copies = useCopyHolder(); const runImport = async () => { const trimmed = url.trim(); @@ -57,7 +59,7 @@ export default function AddLinkSheet() { setBusy('save'); setError(''); try { - if (isDuplicateOrigin(await fetchSources(userId, current.id), draft.url)) { + if (isDuplicateOrigin(await fetchSources(userId, current.id, copies.mark()), draft.url)) { setError(`${t('libraryPage.duplicateSource')}. ${t('libraryPage.duplicateSourceDesc')}`); setBusy(null); return; diff --git a/apps/mobile/src/app/ask.tsx b/apps/mobile/src/app/ask.tsx index 9b9678d0c..346c48baf 100644 --- a/apps/mobile/src/app/ask.tsx +++ b/apps/mobile/src/app/ask.tsx @@ -23,6 +23,7 @@ import { useDebates } from '@/lib/debate-context'; import { keepSourceAssessments } from '@/lib/results'; import { useSettings } from '@/lib/settings'; import { fetchSources, type Source } from '@/lib/sources'; +import { useCopyHolder } from '@/lib/use-copy-holder'; import { radius } from '@/theme/colors'; import { fontFamily } from '@/theme/fonts'; import { useTheme } from '@/theme/use-theme'; @@ -76,6 +77,8 @@ export default function AskScreen() { const config = useRef | null>(null); // The conversation the saved record belongs to; replacing it drops late writes. const conversation = useRef(0); + // The opened chat's saved copy counts until another conversation starts. + const chatCopies = useCopyHolder(); const recordRef = useRef(record); const saves = useRef>(Promise.resolve()); const activeDebateRef = useRef(activeDebateId); @@ -103,6 +106,7 @@ export default function AskScreen() { const startConversation = (messagesNext: ChatMessage[], recordNext: ChatRecord) => { abortActive(); conversation.current += 1; + chatCopies.release(); setMessages(messagesNext); setRecord(recordNext); setStreaming(null); @@ -126,12 +130,16 @@ export default function AskScreen() { // The debate this conversation reads from: a loaded chat keeps its own. const chatDebateId = record.chatId ? record.debateId : activeDebateId; + const libraryCopies = useCopyHolder(); useEffect(() => { // The previous debate's sources never stand in for this one's while they load. setLibrary([]); - if (!chatDebateId || !userId) return; + if (!chatDebateId || !userId) { + libraryCopies.release(); + return; + } let active = true; - fetchSources(userId, chatDebateId) + fetchSources(userId, chatDebateId, libraryCopies.mark()) .then((next) => { if (active) setLibrary(next); }) @@ -139,7 +147,7 @@ export default function AskScreen() { return () => { active = false; }; - }, [userId, chatDebateId]); + }, [userId, chatDebateId, libraryCopies]); // Each finished exchange is saved to the debate, as on the website. const lastRole = messages[messages.length - 1]?.role; @@ -306,7 +314,7 @@ export default function AskScreen() { const owner = conversation.current; setOpening(true); try { - const chat = await fetchChat(userId, chatId); + const chat = await fetchChat(userId, chatId, chatCopies.mark()); if (owner !== conversation.current) return; setMessages(chat.messages); setRecord({ chatId, debateId: chat.debateId, persisted: chat.messages.length }); diff --git a/apps/mobile/src/app/attack/index.tsx b/apps/mobile/src/app/attack/index.tsx index 696f6ee0b..89c9d97f1 100644 --- a/apps/mobile/src/app/attack/index.tsx +++ b/apps/mobile/src/app/attack/index.tsx @@ -22,6 +22,7 @@ import { caseToText } from '@/lib/result-text'; import { arrivalNotices, attackCase, autosaveSources, fetchResult, isCompleteCase, keepSourceAssessments, recordResult } from '@/lib/results'; import { sessionSlot } from '@/lib/session-memory'; import { useSettings } from '@/lib/settings'; +import { useCopyHolder } from '@/lib/use-copy-holder'; import { useTheme } from '@/theme/use-theme'; // The website's defaults (AttackForm). The last form stays for the session, as on the website. @@ -54,6 +55,7 @@ export default function AttackScreen() { const [phase, setPhase] = useState<'form' | 'attacking'>('form'); const [error, setError] = useState(null); const requests = useRef(0); + const copies = useCopyHolder(); const abort = useRef(null); const live = useRef(true); @@ -85,7 +87,7 @@ export default function AttackScreen() { } if (!userId || caseKey.startsWith('local-')) return; let active = true; - fetchResult(userId, 'case', caseKey) + fetchResult(userId, 'case', caseKey, copies.mark()) .then((row) => { if (active && row && isCompleteCase(row.output)) setForm({ caseText: caseToText(row.output) }); }) @@ -93,7 +95,7 @@ export default function AttackScreen() { return () => { active = false; }; - }, [caseKey, userId, setForm]); + }, [caseKey, userId, setForm, copies]); const submit = async () => { if (!userId) return; diff --git a/apps/mobile/src/app/debates.tsx b/apps/mobile/src/app/debates.tsx index 753e3baab..1dad451a9 100644 --- a/apps/mobile/src/app/debates.tsx +++ b/apps/mobile/src/app/debates.tsx @@ -12,6 +12,7 @@ import { useAuth } from '@/lib/auth'; import { useDebates } from '@/lib/debate-context'; import { fetchSourceCounts, type Debate } from '@/lib/debates'; import { formatRelativeDate, isToday } from '@/lib/dates'; +import { useCopyHolder } from '@/lib/use-copy-holder'; import { radius } from '@/theme/colors'; import { fontFamily } from '@/theme/fonts'; import { useTheme } from '@/theme/use-theme'; @@ -32,6 +33,7 @@ export default function DebatesSheet() { const [sourceCounts, setSourceCounts] = useState>({}); const [switching, setSwitching] = useState(null); const [error, setError] = useState(''); + const copies = useCopyHolder(); useEffect(() => { void refresh(); @@ -41,7 +43,7 @@ export default function DebatesSheet() { useEffect(() => { if (!userId || !ids) return; let active = true; - fetchSourceCounts(userId, ids.split(',')) + fetchSourceCounts(userId, ids.split(','), copies.mark()) .then((counts) => { if (active) setSourceCounts(counts); }) @@ -49,7 +51,7 @@ export default function DebatesSheet() { return () => { active = false; }; - }, [userId, ids]); + }, [userId, ids, copies]); const visible = useMemo(() => { const needle = normalize(query); diff --git a/apps/mobile/src/app/flowchart/index.tsx b/apps/mobile/src/app/flowchart/index.tsx index 7393d4863..8c6d7db61 100644 --- a/apps/mobile/src/app/flowchart/index.tsx +++ b/apps/mobile/src/app/flowchart/index.tsx @@ -10,6 +10,7 @@ import { TextButton } from '@/components/buttons'; import { DocumentHeader, type HeaderAction } from '@/components/document-header'; import { FlowColumnsView, FlowLegend, MapTreeView } from '@/components/flowchart-views'; import { Icon } from '@/components/icon'; +import { OfflineNotice } from '@/components/offline-notice'; import { OneOffNotice } from '@/components/one-off-notice'; import { SegmentedControl } from '@/components/segmented-control'; import { Text } from '@/components/text'; @@ -34,7 +35,8 @@ import { type FlowchartDraft, } from '@/lib/flowchart-drafts'; import { flowchartToText } from '@/lib/flowchart-text'; -import { deleteFlowchart, fetchFlowchart, fetchFlowchartList, insertFlowchart, updateFlowchart } from '@/lib/flowcharts'; +import { deleteFlowchart, fetchFlowchart, fetchFlowchartList, flowchartScreenCopies, insertFlowchart, updateFlowchart } from '@/lib/flowcharts'; +import { useCopyHolder } from '@/lib/use-copy-holder'; import { useTheme } from '@/theme/use-theme'; const NOTICE_MS = 3500; @@ -70,6 +72,7 @@ export default function FlowchartScreen() { const live = useRef(true); const owner = useRef(userId); + const copies = useCopyHolder(flowchartScreenCopies); owner.current = userId; const loads = useRef(0); const noticeTimer = useRef | null>(null); @@ -97,6 +100,7 @@ export default function FlowchartScreen() { if (!userId) return; const forUser = userId; const isCurrent = () => loads.current === request && live.current && owner.current === forUser; + const mark = copies.mark(); setFailure(null); const run = async () => { if (requestedId) { @@ -106,7 +110,7 @@ export default function FlowchartScreen() { setFound({ id: requestedId, scope: held.scope }); return; } - const row = await fetchFlowchart(forUser, requestedId); + const row = await fetchFlowchart(forUser, requestedId, mark); if (!isCurrent()) return; if (!row) { setFailure({ scope: null, kind: 'missing' }); @@ -119,9 +123,9 @@ export default function FlowchartScreen() { } const target = scopeKey(activeDebateId); if (openDraft(target)) return; - const list = await fetchFlowchartList(forUser, activeDebateId); + const list = await fetchFlowchartList(forUser, activeDebateId, mark); if (!isCurrent() || openDraft(target)) return; - const row = list.length > 0 ? await fetchFlowchart(forUser, list[0].id) : null; + const row = list.length > 0 ? await fetchFlowchart(forUser, list[0].id, mark) : null; if (!isCurrent() || openDraft(target)) return; showDraft(target, row ? savedDraft(row) : newDraft(activeDebateId, starterFlowchart(tRef.current))); }; @@ -130,7 +134,7 @@ export default function FlowchartScreen() { console.warn('Could not load flowcharts:', cause instanceof Error ? cause.message : cause); setFailure({ scope: requestedId ? null : scopeKey(activeDebateId), kind: 'failed' }); }); - }, [userId, activeDebateId, requestedId, attempt]); + }, [userId, activeDebateId, requestedId, attempt, copies]); const open = scope ? openDraft(scope) : null; const draft = open?.draft ?? null; @@ -296,6 +300,7 @@ export default function FlowchartScreen() { ) : null} {oneOff ? : null} + (null); const [failedOpen, setFailedOpen] = useState(false); const loads = useRef(0); + const copies = useCopyHolder(); // Only the latest tap may open a map; an earlier one still loading is dropped. const selections = useRef(0); const live = useRef(true); @@ -52,13 +54,13 @@ export default function SavedMapsSheet() { const forScope = scope; const isCurrent = () => loads.current === request && live.current && owner.current === forUser; try { - const items = await fetchFlowchartList(forUser, debateId); + const items = await fetchFlowchartList(forUser, debateId, copies.mark()); if (isCurrent()) setList((previous) => keepIfSame(previous, { for: `${forUser}:${forScope}`, items, failed: false })); } catch (cause) { console.warn('Could not load saved flowcharts:', cause instanceof Error ? cause.message : cause); if (isCurrent()) setList((previous) => (previous?.for === `${forUser}:${forScope}` && previous.items ? previous : { for: `${forUser}:${forScope}`, items: null, failed: true })); } - }, [debateId, scope, userId]); + }, [debateId, scope, userId, copies]); useEffect(() => { void load(); @@ -82,7 +84,8 @@ export default function SavedMapsSheet() { setOpening(index); setFailedOpen(false); try { - const row = await fetchFlowchart(forUser, item.id); + // The map shows on the Flowchart screen once this sheet closes, so it counts there. + const row = await fetchFlowchart(forUser, item.id, flowchartScreenCopies.mark()); if (!live.current || owner.current !== forUser || selections.current !== selection) return; if (!row) { setFailedOpen(true); diff --git a/apps/mobile/src/app/source/[id].tsx b/apps/mobile/src/app/source/[id].tsx index c15de2591..def0477f3 100644 --- a/apps/mobile/src/app/source/[id].tsx +++ b/apps/mobile/src/app/source/[id].tsx @@ -12,6 +12,7 @@ import { SOURCE_ICONS, sourceMeta } from '@/components/source-display'; import { Text } from '@/components/text'; import { useAuth } from '@/lib/auth'; import { fetchSource, type Source } from '@/lib/sources'; +import { useCopyHolder } from '@/lib/use-copy-holder'; import { useTheme } from '@/theme/use-theme'; function webUrl(origin: string | null): string | null { @@ -28,11 +29,12 @@ export default function SourceSheet() { const userId = session?.user.id ?? null; const [source, setSource] = useState(undefined); const [failed, setFailed] = useState(false); + const copies = useCopyHolder(); useEffect(() => { if (!userId) return; let active = true; - fetchSource(userId, id) + fetchSource(userId, id, copies.mark()) .then((next) => { if (active) setSource(next); }) @@ -43,7 +45,7 @@ export default function SourceSheet() { return () => { active = false; }; - }, [userId, id]); + }, [userId, id, copies]); const url = webUrl(source?.origin ?? null); diff --git a/apps/mobile/src/app/sparring/[id].tsx b/apps/mobile/src/app/sparring/[id].tsx index de72bae29..4b9575e87 100644 --- a/apps/mobile/src/app/sparring/[id].tsx +++ b/apps/mobile/src/app/sparring/[id].tsx @@ -11,6 +11,7 @@ import { SparringScorecardScreen } from '@/components/sparring-views'; import { useAuth } from '@/lib/auth'; import { backLabel } from '@/lib/back-label'; import { fetchSparringRound, sparringDraft } from '@/lib/sparring'; +import { useCopyHolder } from '@/lib/use-copy-holder'; type Judged = SparringSession & { scorecard: SparringScorecard }; type State = { status: 'loading' } | { status: 'missing' } | { status: 'failed' } | { status: 'ready'; round: Judged }; @@ -36,12 +37,13 @@ export default function SavedSparringScreen() { return { owner: userId, state: kept ? { status: 'ready', round: kept } : { status: 'loading' } }; }); const state: State = held.owner === userId ? held.state : { status: 'loading' }; + const copies = useCopyHolder(); useEffect(() => { if (judged(sparringDraft.get(), id) || !userId) return; const forUser = userId; let active = true; - fetchSparringRound(forUser, id) + fetchSparringRound(forUser, id, copies.mark()) .then((round) => { if (!active) return; const ready = judged(round, id); @@ -54,7 +56,7 @@ export default function SavedSparringScreen() { return () => { active = false; }; - }, [id, userId]); + }, [id, userId, copies]); if (state.status !== 'ready') { return ( diff --git a/apps/mobile/src/app/sparring/index.tsx b/apps/mobile/src/app/sparring/index.tsx index 455139cca..d95a5279f 100644 --- a/apps/mobile/src/app/sparring/index.tsx +++ b/apps/mobile/src/app/sparring/index.tsx @@ -41,7 +41,7 @@ import { fetchFeatureConfig } from '@/lib/feature-config'; import { decideMotionTarget, shouldPrefillMotion } from '@/lib/motion'; import { chooseMotionTarget } from '@/lib/motion-gate'; import { useSettings } from '@/lib/settings'; -import { attachSparring, judgeSparring, newSparringId, SparringResponseError, sparringDraft, sparringTurn } from '@/lib/sparring'; +import { attachSparring, judgeSparring, newSparringId, noteJudgedRound, SparringResponseError, sparringDraft, sparringTurn } from '@/lib/sparring'; import { formatClock } from '@/lib/timer'; import { tapTurnStart, useKeepAwakeWhile } from '@/lib/timer-signals'; import { radius } from '@/theme/colors'; @@ -152,6 +152,8 @@ export default function SparringScreen() { { action: 'score', sessionId: snapshot.id, motion: snapshot.motion, userSide: snapshot.userSide, format: snapshot.format, difficulty: snapshot.difficulty, speeches: snapshot.speeches, language: outputLanguage }, { config, debateId: snapshot.debateId ?? null, signal: controller.signal }, ); + // The server saved the round as judged, so this account's saved lists change whatever happens next. + noteJudgedRound(forUser, snapshot.id, snapshot.debateId ?? null); if (controller.signal.aborted || owner.current !== forUser) return; const latest = sessionRef.current; const completed = latest?.id === snapshot.id ? completeSparringSession(latest, scorecard, Date.now()) : null; @@ -160,7 +162,7 @@ export default function SparringScreen() { commit(completed); // The server saved the judged round before it answered; file it in its debate. if (completed.debateId) { - attachSparring(completed.id, completed.debateId, completed.userSide).catch((cause: unknown) => { + attachSparring(forUser, completed.id, completed.debateId, completed.userSide).catch((cause: unknown) => { console.warn('Could not file the round in its debate:', cause instanceof Error ? cause.message : cause); if (live.current && owner.current === forUser) setNotice(t('debate.recordFailed')); }); diff --git a/apps/mobile/src/components/ask-history.tsx b/apps/mobile/src/components/ask-history.tsx index 8d8fb4c75..3d5e95ff6 100644 --- a/apps/mobile/src/components/ask-history.tsx +++ b/apps/mobile/src/components/ask-history.tsx @@ -10,6 +10,7 @@ import { Text } from './text'; import { useAuth } from '@/lib/auth'; import { fetchChats, type ChatSummary } from '@/lib/chats'; +import { useCopyHolder } from '@/lib/use-copy-holder'; import { formatDate } from '@/lib/dates'; import { useTheme } from '@/theme/use-theme'; @@ -36,13 +37,17 @@ export function AskHistory({ const userId = session?.user.id ?? null; const [chats, setChats] = useState(null); const [failed, setFailed] = useState(false); + const copies = useCopyHolder(); useEffect(() => { - if (!visible || !userId) return; + if (!visible || !userId) { + copies.release(); + return; + } let active = true; setChats(null); setFailed(false); - fetchChats(userId, debateId) + fetchChats(userId, debateId, copies.mark()) .then((next) => { if (active) setChats(next); }) @@ -53,7 +58,7 @@ export function AskHistory({ return () => { active = false; }; - }, [visible, userId, debateId]); + }, [visible, userId, debateId, copies]); return ( diff --git a/apps/mobile/src/components/document.tsx b/apps/mobile/src/components/document.tsx index 6af82b916..1d6d6d612 100644 --- a/apps/mobile/src/components/document.tsx +++ b/apps/mobile/src/components/document.tsx @@ -6,6 +6,7 @@ import { Chip, CiteBadge } from './chip'; import type { CitationTarget } from './citation-target'; import { Icon } from './icon'; import { MarkdownView } from './markdown-view'; +import { OfflineNotice } from './offline-notice'; import { Text } from './text'; import { radius } from '@/theme/colors'; @@ -47,6 +48,7 @@ export function DocumentScreen({ + {children} {notice ? ( diff --git a/apps/mobile/src/components/icon-paths.ts b/apps/mobile/src/components/icon-paths.ts index c74077206..675976ed2 100644 --- a/apps/mobile/src/components/icon-paths.ts +++ b/apps/mobile/src/components/icon-paths.ts @@ -51,6 +51,8 @@ export const iconPaths = { send: '', mic: '', alert: '', + cloudOff: + '', } as const; export type IconName = keyof typeof iconPaths; diff --git a/apps/mobile/src/components/offline-notice.tsx b/apps/mobile/src/components/offline-notice.tsx new file mode 100644 index 000000000..69b1c4c74 --- /dev/null +++ b/apps/mobile/src/components/offline-notice.tsx @@ -0,0 +1,44 @@ +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native'; + +import { Icon } from '@/components/icon'; +import { Text } from '@/components/text'; +import { useOfflineStatus } from '@/lib/network-status'; +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +/** + * Shown while the device is offline or the screen's data came from saved + * copies. AI and saving stay where they are and fail with their network + * error, so the notice says they wait for the connection. + */ +export function OfflineNotice({ style }: { style?: StyleProp }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const { offline, savedCopies } = useOfflineStatus(); + if (!offline && !savedCopies) return null; + return ( + + + + {t('mobile.offline.notice')} + + + ); +} + +const styles = StyleSheet.create({ + notice: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + borderRadius: radius.field, + paddingHorizontal: 12, + paddingVertical: 8, + }, + text: { flex: 1, fontSize: 13.5, lineHeight: 18 }, +}); diff --git a/apps/mobile/src/components/screen.tsx b/apps/mobile/src/components/screen.tsx index e747b1e27..a316bbfc6 100644 --- a/apps/mobile/src/components/screen.tsx +++ b/apps/mobile/src/components/screen.tsx @@ -2,9 +2,12 @@ import type { ReactNode } from 'react'; import { ScrollView, StyleSheet } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { OfflineNotice } from './offline-notice'; + /** * A tab's scrolling content. On iOS the native header and tab bar adjust the - * insets automatically; `safeTop` is for screens without a header. + * insets automatically; `safeTop` is for screens without a header. The + * offline notice leads every tab while saved copies are shown. */ export function Screen({ children, safeTop, gap }: { children: ReactNode; safeTop?: boolean; gap?: number }) { const insets = useSafeAreaInsets(); @@ -12,6 +15,7 @@ export function Screen({ children, safeTop, gap }: { children: ReactNode; safeTo + {children} ); diff --git a/apps/mobile/src/lib/auth.tsx b/apps/mobile/src/lib/auth.tsx index 85ab720d0..120607da9 100644 --- a/apps/mobile/src/lib/auth.tsx +++ b/apps/mobile/src/lib/auth.tsx @@ -2,6 +2,7 @@ import type { Session } from '@supabase/supabase-js'; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { getInstallId, sessionStorage } from './device-storage'; +import { setCacheOwner } from './offline-cache'; import { setSessionOwner } from './session-memory'; import { supabase } from './supabase'; import { sessionStorageKey } from './supabase-config'; @@ -78,10 +79,11 @@ async function signOutError(scope: 'global' | 'local'): Promise { export function AuthProvider({ children }: { children: ReactNode }) { const [session, setSessionState] = useState(null); - // The session memory is cleared before an account change renders, so no - // screen ever shows the previous account's forms or results. + // The session memory and the saved copies are cleared before an account + // change renders, so no screen ever shows the previous account's work. const setSession = useCallback((next: Session | null) => { setSessionOwner(next?.user.id ?? null); + setCacheOwner(next?.user.id ?? null); setSessionState(next); }, []); const [loading, setLoading] = useState(true); diff --git a/apps/mobile/src/lib/chats.ts b/apps/mobile/src/lib/chats.ts index 7f2d76fbf..cf131e39c 100644 --- a/apps/mobile/src/lib/chats.ts +++ b/apps/mobile/src/lib/chats.ts @@ -1,5 +1,7 @@ import type { AskOutput } from '@debatelab/core/ai/schemas/ask'; +import { debateListScopes } from './debates'; +import { afterWrite, cachedRead, pickFailure, readFailed, type CopyMark } from './offline-cache'; import { supabase } from './supabase'; /** @@ -54,14 +56,18 @@ interface MessageRow { */ /** Chats in a debate, or the unsorted ones in one-off mode, newest first, as the website's saved chats list. */ -export async function fetchChats(userId: string, debateId: string | null): Promise { +export function fetchChats(userId: string, debateId: string | null, mark?: CopyMark): Promise { + return cachedRead(userId, 'chats', debateId ?? 'one-off', () => loadChats(userId, debateId), mark); +} + +async function loadChats(userId: string, debateId: string | null): Promise { const query = supabase .from('chats') .select('id,title,created_at,messages(count)') .eq('user_id', userId) .order('created_at', { ascending: false }); - const { data, error } = await (debateId ? query.eq('debate_id', debateId) : query.is('debate_id', null)); - if (error) throw new Error(error.message); + const { data, error, status } = await (debateId ? query.eq('debate_id', debateId) : query.is('debate_id', null)); + if (error) throw readFailed(error, status); return ((data ?? []) as { id: string; title: string; created_at: string; messages: { count: number }[] }[]).map((row) => ({ id: row.id, title: row.title, @@ -70,13 +76,17 @@ export async function fetchChats(userId: string, debateId: string | null): Promi })); } -export async function fetchChat(userId: string, chatId: string): Promise<{ debateId: string | null; messages: ChatMessage[] }> { +export function fetchChat(userId: string, chatId: string, mark?: CopyMark): Promise<{ debateId: string | null; messages: ChatMessage[] }> { + return cachedRead(userId, 'chat', chatId, () => loadChat(userId, chatId), mark); +} + +async function loadChat(userId: string, chatId: string): Promise<{ debateId: string | null; messages: ChatMessage[] }> { const [chat, messages] = await Promise.all([ supabase.from('chats').select('debate_id').eq('user_id', userId).eq('id', chatId).single(), supabase.from('messages').select('id,role,content,output,created_at').eq('chat_id', chatId).order('created_at', { ascending: true }), ]); - if (chat.error) throw new Error(chat.error.message); - if (messages.error) throw new Error(messages.error.message); + const failures = [chat, messages].flatMap((result) => (result.error ? [readFailed(result.error, result.status)] : [])); + if (failures.length > 0) throw pickFailure(failures); return { debateId: (chat.data as { debate_id: string | null }).debate_id ?? null, messages: ((messages.data ?? []) as MessageRow[]).map((row) => ({ @@ -108,6 +118,7 @@ export async function saveChat(userId: string, record: ChatRecord, messages: Cha if (error || !data) throw new Error(error?.message ?? 'Could not save the chat'); chatId = (data as { id: string }).id; debateId = activeDebateId; + afterWrite(userId, [{ group: 'chats', key: debateId }, ...debateListScopes(debateId, 'chat')]); } const pending = messages.slice(record.persisted); const { error } = await supabase.from('messages').insert(pending.map((message) => ({ @@ -119,5 +130,7 @@ export async function saveChat(userId: string, record: ChatRecord, messages: Cha }))); // The chat exists now; keep it so the next save adds these messages to it instead of creating another. if (error) throw new ChatSaveError(error.message, { chatId, debateId, persisted: record.persisted }); + // The conversation and the message count in its debate's list changed. + afterWrite(userId, [{ group: 'chat', key: chatId }, { group: 'chats', key: debateId ?? 'one-off' }]); return { chatId, debateId, persisted: messages.length }; } diff --git a/apps/mobile/src/lib/crossfire.test.ts b/apps/mobile/src/lib/crossfire.test.ts index 3e7aa4b0f..b19160531 100644 --- a/apps/mobile/src/lib/crossfire.test.ts +++ b/apps/mobile/src/lib/crossfire.test.ts @@ -6,6 +6,7 @@ const fetchMock = vi.hoisted(() => vi.fn()); const getSession = vi.hoisted(() => vi.fn(() => new Promise(() => {}))); vi.mock('./supabase', () => ({ supabase: { auth: { getSession } } })); vi.mock('expo/fetch', () => ({ fetch: fetchMock })); +vi.mock('./offline-cache', () => ({ afterWrite: vi.fn(), cachedRead: vi.fn(), readFailed: vi.fn(), updateCopy: vi.fn() })); import { AIError, postFunction, untilAborted } from './ai-request'; import { withTurnDeadline } from './crossfire'; diff --git a/apps/mobile/src/lib/crossfire.ts b/apps/mobile/src/lib/crossfire.ts index 496fa5403..ac5b3b14c 100644 --- a/apps/mobile/src/lib/crossfire.ts +++ b/apps/mobile/src/lib/crossfire.ts @@ -14,7 +14,9 @@ import { import type { DebateContextRequest } from '@debatelab/core/contract/debate-context'; import { abortError, AIError, functionOutput, postFunction, PROVIDER, responseExtras, type ResponseExtras } from './ai-request'; +import { debateListScopes } from './debates'; import type { FeatureConfig } from './feature-config'; +import { afterWrite } from './offline-cache'; import { sessionSlot } from './session-memory'; import { supabase } from './supabase'; @@ -180,6 +182,7 @@ export async function recordCrossfire( .select('id') .single(); if (error || !data) throw new Error(error?.message ?? 'Could not save the crossfire'); + afterWrite(userId, debateListScopes(debateId, 'crossfire')); return (data as { id: string }).id; } diff --git a/apps/mobile/src/lib/debates.ts b/apps/mobile/src/lib/debates.ts index 2d0b95f94..287019c0e 100644 --- a/apps/mobile/src/lib/debates.ts +++ b/apps/mobile/src/lib/debates.ts @@ -1,3 +1,4 @@ +import { cachedRead, pickFailure, readAll, readFailed, updateCopy, type CopyMark, type CopyScope } from './offline-cache'; import { supabase } from './supabase'; /** @@ -64,11 +65,22 @@ export function sortDebates(debates: Debate[]): Debate[] { return [...debates].sort((a, b) => b.lastActivityAt - a.lastActivityAt); } +interface DebateList { + debates: Debate[]; + currentId: string | null; +} + +/** Keeps the saved debates list in step with a write, so an offline restart opens what the student chose. */ +function keepInCopy(userId: string, change: (list: DebateList) => DebateList) { + updateCopy(userId, 'debates', 'all', (copy) => copy && change(copy)); +} + async function setCurrentPointer(userId: string, debateId: string) { const { error } = await supabase .from('current_debates') .upsert({ user_id: userId, debate_id: debateId, updated_at: new Date().toISOString() }, { onConflict: 'user_id' }); if (error) throw new Error(error.message); + keepInCopy(userId, (list) => ({ ...list, currentId: list.debates.some((debate) => debate.id === debateId) ? debateId : list.currentId })); } /** @@ -76,14 +88,18 @@ async function setCurrentPointer(userId: string, debateId: string) { * the real boundary; the filter keeps a wrong session from mixing accounts. */ -/** Every debate, newest activity first, and the current one when it is still in the list. */ -export async function fetchDebates(userId: string): Promise<{ debates: Debate[]; currentId: string | null }> { +/** Every debate, newest activity first, and the current one when it is still in the list. Offline, the saved copy. */ +export function fetchDebates(userId: string): Promise { + return cachedRead(userId, 'debates', 'all', () => loadDebates(userId)); +} + +async function loadDebates(userId: string): Promise { const [debatesResult, currentResult] = await Promise.all([ supabase.from('debates').select(DEBATE_COLUMNS).eq('user_id', userId).order('last_activity_at', { ascending: false }), supabase.from('current_debates').select('debate_id').eq('user_id', userId).maybeSingle(), ]); - if (debatesResult.error) throw new Error(debatesResult.error.message); - if (currentResult.error) throw new Error(currentResult.error.message); + const failures = [debatesResult, currentResult].flatMap((result) => (result.error ? [readFailed(result.error, result.status)] : [])); + if (failures.length > 0) throw pickFailure(failures); const debates = ((debatesResult.data ?? []) as DebateRow[]).map(fromRow); const currentId = (currentResult.data as { debate_id?: string } | null)?.debate_id ?? null; return { @@ -106,6 +122,7 @@ export async function createDebate( .single(); if (error || !data) throw new Error(error?.message ?? 'Could not create debate'); const debate = fromRow(data as DebateRow); + keepInCopy(userId, (list) => ({ ...list, debates: sortDebates([debate, ...list.debates.filter((item) => item.id !== debate.id)]) })); await setCurrentPointer(userId, debate.id); return debate; } @@ -118,6 +135,8 @@ export async function switchDebate(userId: string, debateId: string): Promise ({ ...list, debates: sortDebates(list.debates.map((item) => (item.id === debateId ? { ...item, lastActivityAt: now } : item))) })); return now; } @@ -161,27 +180,35 @@ async function countRows(userId: string, table: string, debateId: string, sides? .eq('user_id', userId) .eq('debate_id', debateId); if (sides) query = query.in('side', sides); - const { count, error } = await query; - if (error) throw new Error(error.message); + const { count, error, status } = await query; + if (error) throw readFailed(error, status); return count ?? 0; } const sum = (counts: number[]) => counts.reduce((total, count) => total + count, 0); /** Sources, saved results per side and Sparring rounds in one debate. One-off work has no debate and never counts. */ -export async function fetchDebateCounts(userId: string, debateId: string): Promise { - const [sources, rounds, pro, con] = await Promise.all([ +export function fetchDebateCounts(userId: string, debateId: string, mark?: CopyMark): Promise { + return cachedRead(userId, 'debate-lists', `counts:${debateId}`, () => loadDebateCounts(userId, debateId), mark); +} + +async function loadDebateCounts(userId: string, debateId: string): Promise { + const [sources, rounds, pro, con] = await readAll([ countRows(userId, 'sources', debateId), countRows(userId, 'sparring_sessions', debateId), - Promise.all(RESULT_TABLES.map((table) => countRows(userId, table, debateId, SIDES_FOR.pro))), - Promise.all(RESULT_TABLES.map((table) => countRows(userId, table, debateId, SIDES_FOR.con))), + readAll(RESULT_TABLES.map((table) => countRows(userId, table, debateId, SIDES_FOR.pro))), + readAll(RESULT_TABLES.map((table) => countRows(userId, table, debateId, SIDES_FOR.con))), ]); return { sources, rounds, results: { pro: sum(pro), con: sum(con) } }; } /** Library sources per debate, for the switcher's list. */ -export async function fetchSourceCounts(userId: string, debateIds: string[]): Promise> { - const counts = await Promise.all(debateIds.map((id) => countRows(userId, 'sources', id))); +export function fetchSourceCounts(userId: string, debateIds: string[], mark?: CopyMark): Promise> { + return cachedRead(userId, 'debate-lists', `source-counts:${debateIds.join(',')}`, () => loadSourceCounts(userId, debateIds), mark); +} + +async function loadSourceCounts(userId: string, debateIds: string[]): Promise> { + const counts = await readAll(debateIds.map((id) => countRows(userId, 'sources', id))); return Object.fromEntries(debateIds.map((id, index) => [id, counts[index]])); } @@ -199,8 +226,8 @@ async function fetchResultRows(userId: string, debateId: string, table: ResultTa .eq('user_id', userId) .eq('debate_id', debateId); if (sides) query = query.in('side', sides); - const { data, error } = await query.order('created_at', { ascending: false }).limit(limit); - if (error) throw new Error(error.message); + const { data, error, status } = await query.order('created_at', { ascending: false }).limit(limit); + if (error) throw readFailed(error, status); return ((data ?? []) as { id: string; title: string; created_at: string }[]).map((row): DebateItem => ({ id: row.id, kind: KIND_BY_TABLE[table], @@ -212,17 +239,16 @@ async function fetchResultRows(userId: string, debateId: string, table: ResultTa const newestFirst = (lists: DebateItem[][], limit: number) => lists.flat().sort((a, b) => b.createdAt - a.createdAt).slice(0, limit); /** The newest saved results and rounds for one side of a debate, like the website's side lists. */ -export async function fetchRecentWork( - userId: string, - debateId: string, - side: DebateSide, - limit: number, -): Promise { - const lists = await Promise.all([ +export function fetchRecentWork(userId: string, debateId: string, side: DebateSide, limit: number, mark?: CopyMark): Promise { + return cachedRead(userId, 'debate-lists', `recent:${debateId}:${side}:${limit}`, () => loadRecentWork(userId, debateId, side, limit), mark); +} + +async function loadRecentWork(userId: string, debateId: string, side: DebateSide, limit: number): Promise { + const lists = await readAll([ ...RESULT_TABLES.map((table) => fetchResultRows(userId, debateId, table, SIDES_FOR[side], limit)), (async () => { // The website lists a judged round under its motion and the side the student spoke, ordered by its last update. - const { data, error } = await supabase + const { data, error, status } = await supabase .from('sparring_sessions') .select('id,motion,updated_at') .eq('user_id', userId) @@ -231,7 +257,7 @@ export async function fetchRecentWork( .eq('status', 'complete') .order('updated_at', { ascending: false }) .limit(limit); - if (error) throw new Error(error.message); + if (error) throw readFailed(error, status); return ((data ?? []) as { id: string; motion: string; updated_at: string }[]).map((row): DebateItem => ({ id: row.id, kind: 'sparring', @@ -245,9 +271,43 @@ export async function fetchRecentWork( /** The Prepare tools' tables. */ const PREPARE_TABLES = ['cases', 'brainstorms', 'factoids', 'flowcharts'] as const satisfies readonly ResultTable[]; +const PREPARE_KINDS = new Set(PREPARE_TABLES.map((table) => KIND_BY_TABLE[table])); + +/** + * The saved counts and lists of one debate that a write changed: adding or + * removing a source changes its counts; a saved result or round also changes + * the recent work, and a Prepare result the Prepare list. + */ +export function debateListScopes(debateId: string | null, change: 'source' | DebateItemKind): CopyScope[] { + return debateId ? listScopes(debateId, change) : []; +} + +/** + * The saved counts and lists a delete changed, from the debates its deleted + * rows name. A delete that returned no row (the row was already gone, say + * deleted on the website) does not say which debate held it, so every + * debate's counts and lists of that kind are dropped. + */ +export function deletedListScopes(deletedFrom: (string | null)[], change: 'source' | DebateItemKind): CopyScope[] { + return deletedFrom.length > 0 ? deletedFrom.flatMap((debateId) => debateListScopes(debateId, change)) : listScopes(undefined, change); +} + +/** One debate's counts and lists, or every debate's when `debateId` is undefined. */ +function listScopes(debateId: string | undefined, change: 'source' | DebateItemKind): CopyScope[] { + const of = debateId ? `${debateId}:` : ''; + const scopes: CopyScope[] = [debateId ? { group: 'debate-lists', key: `counts:${debateId}` } : { group: 'debate-lists', prefix: 'counts:' }]; + if (change === 'source') return [...scopes, { group: 'debate-lists', prefix: 'source-counts:', about: debateId }]; + scopes.push({ group: 'debate-lists', prefix: `recent:${of}` }); + if (PREPARE_KINDS.has(change)) scopes.push({ group: 'debate-lists', prefix: `prepare:${of}` }); + return scopes; +} /** A debate's saved Prepare results for both sides, newest first, for the Prepare tab. */ -export async function fetchPrepareWork(userId: string, debateId: string, limit: number): Promise { - const lists = await Promise.all(PREPARE_TABLES.map((table) => fetchResultRows(userId, debateId, table, null, limit))); +export function fetchPrepareWork(userId: string, debateId: string, limit: number, mark?: CopyMark): Promise { + return cachedRead(userId, 'debate-lists', `prepare:${debateId}:${limit}`, () => loadPrepareWork(userId, debateId, limit), mark); +} + +async function loadPrepareWork(userId: string, debateId: string, limit: number): Promise { + const lists = await readAll(PREPARE_TABLES.map((table) => fetchResultRows(userId, debateId, table, null, limit))); return newestFirst(lists, limit); } diff --git a/apps/mobile/src/lib/flowcharts.ts b/apps/mobile/src/lib/flowcharts.ts index 11bff836d..12d866403 100644 --- a/apps/mobile/src/lib/flowcharts.ts +++ b/apps/mobile/src/lib/flowcharts.ts @@ -1,5 +1,7 @@ import { readFlowchart, type FlowchartDocument } from '@debatelab/core/flowchart'; +import { debateListScopes, deletedListScopes } from './debates'; +import { afterWrite, cachedRead, CopyHolder, readFailed, updateCopy, type CopyMark } from './offline-cache'; import { supabase } from './supabase'; /** @@ -44,10 +46,14 @@ function fromRow(row: FlowchartRow): SavedFlowchart { } /** The maps saved in one debate, or one-off maps for null, newest first, as the website's saved list shows them. */ -export async function fetchFlowchartList(userId: string, debateId: string | null): Promise { +export function fetchFlowchartList(userId: string, debateId: string | null, mark?: CopyMark): Promise { + return cachedRead(userId, 'flowcharts', debateId ?? 'one-off', () => loadFlowchartList(userId, debateId), mark); +} + +async function loadFlowchartList(userId: string, debateId: string | null): Promise { const query = supabase.from('flowcharts').select('id,title,created_at').eq('user_id', userId); - const { data, error } = await (debateId ? query.eq('debate_id', debateId) : query.is('debate_id', null)).order('created_at', { ascending: false }); - if (error) throw new Error(error.message); + const { data, error, status } = await (debateId ? query.eq('debate_id', debateId) : query.is('debate_id', null)).order('created_at', { ascending: false }); + if (error) throw readFailed(error, status); return ((data ?? []) as { id: string; title: string; created_at: string }[]).map((row) => ({ id: row.id, title: row.title, @@ -55,9 +61,16 @@ export async function fetchFlowchartList(userId: string, debateId: string | null })); } -export async function fetchFlowchart(userId: string, id: string): Promise { - const { data, error } = await supabase.from('flowcharts').select(COLUMNS).eq('user_id', userId).eq('id', id).maybeSingle(); - if (error) throw new Error(error.message); +/** The copies the Flowchart screen shows, including a map the saved maps sheet opened for it. */ +export const flowchartScreenCopies = new CopyHolder(); + +export function fetchFlowchart(userId: string, id: string, mark?: CopyMark): Promise { + return cachedRead(userId, 'flowchart', id, () => loadFlowchart(userId, id), mark); +} + +async function loadFlowchart(userId: string, id: string): Promise { + const { data, error, status } = await supabase.from('flowcharts').select(COLUMNS).eq('user_id', userId).eq('id', id).maybeSingle(); + if (error) throw readFailed(error, status); return data ? fromRow(data as FlowchartRow) : null; } @@ -69,7 +82,10 @@ export async function insertFlowchart(userId: string, input: { title: string; de .select(COLUMNS) .single(); if (error || !data) throw new Error(error?.message ?? 'Could not save the flowchart'); - return fromRow(data as FlowchartRow); + const saved = fromRow(data as FlowchartRow); + afterWrite(userId, [{ group: 'flowcharts', key: saved.debateId ?? 'one-off' }, ...debateListScopes(saved.debateId, 'flowchart')]); + updateCopy(userId, 'flowchart', saved.id, () => saved); + return saved; } /** Saves over a map the app opened. Resolves false when the row is gone, for example deleted on the website. */ @@ -79,12 +95,22 @@ export async function updateFlowchart(userId: string, id: string, doc: Flowchart .update({ nodes: doc.nodes, edges: doc.edges, updated_at: new Date().toISOString() }) .eq('user_id', userId) .eq('id', id) - .select('id'); + .select(COLUMNS); if (error) throw new Error(error.message); - return (data ?? []).length > 0; + const row = ((data ?? []) as FlowchartRow[])[0]; + // The saved copy becomes the map as saved, so offline it opens this version; a gone row drops it. + updateCopy(userId, 'flowchart', id, () => (row ? fromRow(row) : undefined)); + return Boolean(row); } export async function deleteFlowchart(userId: string, id: string): Promise { - const { error } = await supabase.from('flowcharts').delete().eq('user_id', userId).eq('id', id); + const { data, error } = await supabase.from('flowcharts').delete().eq('user_id', userId).eq('id', id).select('debate_id'); if (error) throw new Error(error.message); + const deletedFrom = ((data ?? []) as { debate_id: string | null }[]).map((row) => row.debate_id); + // With no row, it was already gone and any debate's list, or the one-off list, may still hold it. + afterWrite(userId, [ + { group: 'flowchart', key: id }, + ...(deletedFrom.length > 0 ? deletedFrom.map((debate) => ({ group: 'flowcharts' as const, key: debate ?? 'one-off' })) : [{ group: 'flowcharts' as const }]), + ...deletedListScopes(deletedFrom, 'flowchart'), + ]); } diff --git a/apps/mobile/src/lib/network-status.ts b/apps/mobile/src/lib/network-status.ts new file mode 100644 index 000000000..2ede8725a --- /dev/null +++ b/apps/mobile/src/lib/network-status.ts @@ -0,0 +1,70 @@ +import { addNetworkStateListener, getNetworkStateAsync, type NetworkState } from 'expo-network'; +import { useSyncExternalStore } from 'react'; + +/** + * Whether the app is reading saved copies instead of the network. expo-network + * says when the device is offline; offline-cache.ts says while any screen's + * data is a saved copy, which covers a network that is up but cannot reach + * the server. Each copy counts until its own data is read from the server + * again, or the screens showing it close or move on (offline-cache.ts, CopyHolder). + */ + +export interface OfflineStatus { + /** expo-network reports no connection. */ + offline: boolean; + /** Some data on screen is a saved copy, not the server's answer. */ + savedCopies: boolean; +} + +let status: OfflineStatus = { offline: false, savedCopies: false }; +const listeners = new Set<() => void>(); + +function update(patch: Partial) { + const next = { ...status, ...patch }; + if (next.offline === status.offline && next.savedCopies === status.savedCopies) return; + status = next; + listeners.forEach((listener) => listener()); +} + +// iOS reports isInternetReachable as null until it knows; only a definite false means offline. +function isOffline(state: NetworkState): boolean { + return state.isConnected === false || state.isInternetReachable === false; +} + +export function isKnownOffline(): boolean { + return status.offline; +} + +export function setSavedCopiesShown(savedCopies: boolean) { + update({ savedCopies }); +} + +/** Follows the device's connection until the returned function is called. */ +export function watchNetwork(): () => void { + let active = true; + const subscription = addNetworkStateListener((state) => update({ offline: isOffline(state) })); + getNetworkStateAsync() + .then((state) => { + if (active) update({ offline: isOffline(state) }); + }) + .catch((cause: unknown) => console.warn('Could not read the network state:', cause instanceof Error ? cause.message : cause)); + return () => { + active = false; + subscription.remove(); + }; +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function getOfflineStatus(): OfflineStatus { + return status; +} + +export function useOfflineStatus(): OfflineStatus { + return useSyncExternalStore(subscribe, getOfflineStatus, getOfflineStatus); +} diff --git a/apps/mobile/src/lib/offline-cache.test.ts b/apps/mobile/src/lib/offline-cache.test.ts new file mode 100644 index 000000000..1eb1a2a12 --- /dev/null +++ b/apps/mobile/src/lib/offline-cache.test.ts @@ -0,0 +1,351 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const storage = vi.hoisted(() => new Map()); +// A Supabase client whose queries answer with the next queued result, for the modules that write. +const supabase = vi.hoisted(() => ({ results: [] as unknown[], from: () => undefined as unknown })); +vi.mock('./supabase', () => ({ supabase })); +const network = vi.hoisted(() => ({ listener: null as ((state: { isConnected: boolean; isInternetReachable: boolean }) => void) | null })); + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: async (key: string) => storage.get(key) ?? null, + setItem: async (key: string, value: string) => void storage.set(key, value), + removeItem: async (key: string) => void storage.delete(key), + getAllKeys: async () => [...storage.keys()], + multiSet: async (pairs: [string, string][]) => pairs.forEach(([key, value]) => storage.set(key, value)), + multiRemove: async (keys: string[]) => keys.forEach((key) => storage.delete(key)), + }, +})); + +vi.mock('expo-network', () => ({ + addNetworkStateListener: (listener: typeof network.listener) => { + network.listener = listener; + return { remove: () => undefined }; + }, + getNetworkStateAsync: async () => ({ isConnected: true, isInternetReachable: true }), +})); + +// A read that got no answer from the server, as supabase-js reports it (status 0). Set by load(). +let offlineFailure: () => Promise; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function query(result: unknown): unknown { + const builder: unknown = new Proxy({}, { + get: (_target, prop) => (prop === 'then' ? (resolve: (value: unknown) => void) => resolve(result) : () => builder), + }); + return builder; +} +supabase.from = () => query(supabase.results.shift() ?? { data: null, error: null, status: 200 }); + +// A fresh module per test: the owner and tickets are module state, as in the app. +async function load() { + vi.resetModules(); + const cache = await import('./offline-cache'); + const status = await import('./network-status'); + const writers = { ...(await import('./debates')), ...(await import('./sources')), ...(await import('./flowcharts')) }; + offlineFailure = () => Promise.reject(cache.readFailed({ message: 'TypeError: Network request failed' }, 0)); + return { ...cache, ...status, ...writers }; +} + +beforeEach(() => { + storage.clear(); + supabase.results = []; +}); + +describe('saved copies between accounts', () => { + it('never answers account B from account A, and drops A on the switch', async () => { + const { cachedRead, setCacheOwner } = await load(); + setCacheOwner('user-a'); + await cachedRead('user-a', 'sources', 'debate-1', async () => ['A secret source']); + await expect(cachedRead('user-a', 'sources', 'debate-1', offlineFailure)).resolves.toEqual(['A secret source']); + + setCacheOwner('user-b'); + await expect(cachedRead('user-b', 'sources', 'debate-1', offlineFailure)).rejects.toThrow('TypeError: Network request failed'); + // A's own key is not readable either once B owns the cache. + await expect(cachedRead('user-a', 'sources', 'debate-1', offlineFailure)).rejects.toThrow('TypeError: Network request failed'); + expect([...storage.keys()].some((key) => key.includes('user-a'))).toBe(false); + expect([...storage.values()].some((value) => value.includes('A secret source'))).toBe(false); + }); + + it('removes every copy on sign-out and keeps them for the same account after a restart', async () => { + let cache = await load(); + cache.setCacheOwner('user-a'); + await cache.cachedRead('user-a', 'debates', 'all', async () => ({ debates: ['motion'] })); + + // A restart signed in as the same account still reads its copies offline. + cache = await load(); + cache.setCacheOwner('user-a'); + await expect(cache.cachedRead('user-a', 'debates', 'all', offlineFailure)).resolves.toEqual({ debates: ['motion'] }); + + cache.setCacheOwner(null); + cache.setCacheOwner('user-a'); + await expect(cache.cachedRead('user-a', 'debates', 'all', offlineFailure)).rejects.toThrow(); + expect([...storage.keys()].filter((key) => key !== 'debatelab.cache.v1.owner')).toEqual([]); + }); + + it('drops copies from a restart with a different account, even without a sign-out', async () => { + let cache = await load(); + cache.setCacheOwner('user-a'); + await cache.cachedRead('user-a', 'result:case', 'case-1', async () => ({ title: 'A case' })); + + cache = await load(); + cache.setCacheOwner('user-b'); + await expect(cache.cachedRead('user-b', 'result:case', 'case-1', offlineFailure)).rejects.toThrow(); + expect([...storage.values()].some((value) => value.includes('A case'))).toBe(false); + }); + + it('does not keep an answer for A that lands after the switch to B', async () => { + const { cachedRead, setCacheOwner } = await load(); + setCacheOwner('user-a'); + const slow = deferred(); + const read = cachedRead('user-a', 'sources', 'debate-1', () => slow.promise); + setCacheOwner('user-b'); + slow.resolve(['A late source']); + await read; + await cachedRead('user-b', 'sources', 'other', async () => []); + expect([...storage.values()].some((value) => value.includes('A late source'))).toBe(false); + }); +}); + +describe('stale answers', () => { + it('a slow answer never overwrites a newer saved copy', async () => { + const { cachedRead, setCacheOwner } = await load(); + setCacheOwner('user-a'); + const slow = deferred(); + const older = cachedRead('user-a', 'flowchart', 'map-1', () => slow.promise); + await cachedRead('user-a', 'flowchart', 'map-1', async () => 'newer'); + slow.resolve('older'); + await older; + await expect(cachedRead('user-a', 'flowchart', 'map-1', offlineFailure)).resolves.toBe('newer'); + }); + + it('a read already running when a row is deleted does not bring its copy back', async () => { + const { cachedRead, afterWrite, setCacheOwner } = await load(); + setCacheOwner('user-a'); + await cachedRead('user-a', 'result:case', 'case-1', async () => 'saved case'); + const slow = deferred(); + const running = cachedRead('user-a', 'result:case', 'case-1', () => slow.promise); + afterWrite('user-a', [{ group: 'result:case', key: 'case-1' }]); + slow.resolve('saved case'); + await running; + await expect(cachedRead('user-a', 'result:case', 'case-1', offlineFailure)).rejects.toThrow(); + }); + + it('keeps a saved null apart from no copy', async () => { + const { cachedRead, setCacheOwner } = await load(); + setCacheOwner('user-a'); + await cachedRead('user-a', 'source', 'gone', async () => null); + await expect(cachedRead('user-a', 'source', 'gone', offlineFailure)).resolves.toBeNull(); + }); +}); + +describe('offline', () => { + it('answers from the copy without waiting on the network, and says so', async () => { + const { cachedRead, setCacheOwner, watchNetwork, isKnownOffline } = await load(); + setCacheOwner('user-a'); + await cachedRead('user-a', 'chats', 'debate-1', async () => ['chat']); + watchNetwork(); + network.listener?.({ isConnected: false, isInternetReachable: false }); + expect(isKnownOffline()).toBe(true); + const never = vi.fn(() => new Promise(() => undefined)); + await expect(cachedRead('user-a', 'chats', 'debate-1', never)).resolves.toEqual(['chat']); + expect(never).not.toHaveBeenCalled(); + }); + + it('drops the least recently fetched copies beyond a group limit', async () => { + const { cachedRead, setCacheOwner } = await load(); + setCacheOwner('user-a'); + const now = vi.spyOn(Date, 'now'); + for (let i = 0; i < 21; i += 1) { + now.mockReturnValue(1_000 + i); + await cachedRead('user-a', 'sources', `debate-${i}`, async () => [i]); + } + now.mockRestore(); + await cachedRead('user-a', 'sources', 'flush', offlineFailure).catch(() => undefined); + await expect(cachedRead('user-a', 'sources', 'debate-0', offlineFailure)).rejects.toThrow(); + await expect(cachedRead('user-a', 'sources', 'debate-20', offlineFailure)).resolves.toEqual([20]); + }); +}); + +describe('which failures fall back', () => { + it('serves the copy when no answer came back from the server', async () => { + const { cachedRead, setCacheOwner } = await load(); + setCacheOwner('user-a'); + await cachedRead('user-a', 'sources', 'debate-1', async () => ['source']); + await expect(cachedRead('user-a', 'sources', 'debate-1', offlineFailure)).resolves.toEqual(['source']); + }); + + it('throws a refused session, a policy error or any other server answer instead of serving the copy', async () => { + const { cachedRead, readFailed, setCacheOwner } = await load(); + setCacheOwner('user-a'); + await cachedRead('user-a', 'sources', 'debate-1', async () => ['source']); + const refusals = [ + readFailed({ message: 'JWT expired' }, 401), + readFailed({ message: 'permission denied for table sources' }, 403), + readFailed({ message: 'Server error' }, 500), + new Error('Flowchart rows did not check out'), + ]; + for (const refusal of refusals) { + await expect(cachedRead('user-a', 'sources', 'debate-1', () => Promise.reject(refusal))).rejects.toBe(refusal); + } + }); + + it('throws a refusal from one of several queries even when another got no answer', async () => { + const cache = await load(); + cache.setCacheOwner('user-a'); + const unreachable = { error: { message: 'Network request failed' }, status: 0 }; + const refused = { error: { message: 'JWT expired' }, status: 401 }; + supabase.results.push({ count: 1, error: null, status: 200 }, { count: 2, error: null, status: 200 }); + await cache.fetchSourceCounts('user-a', ['debate-1', 'debate-2']); + supabase.results.push(unreachable, refused); + await expect(cache.fetchSourceCounts('user-a', ['debate-1', 'debate-2'])).rejects.toThrow('JWT expired'); + + supabase.results.push({ data: [], error: null, status: 200 }, { data: null, error: null, status: 200 }); + await cache.fetchDebates('user-a'); + supabase.results.push(unreachable, refused); + await expect(cache.fetchDebates('user-a')).rejects.toThrow('JWT expired'); + }); + + it('throws a refusal at once instead of waiting for a query that never answers', async () => { + const cache = await load(); + const refused = Promise.reject(cache.readFailed({ message: 'JWT expired' }, 401)); + const outcome = cache.readAll([refused, new Promise(() => {})]).catch((cause: unknown) => cause); + const first = await Promise.race([outcome, new Promise((resolve) => setTimeout(() => resolve('still waiting'), 50))]); + expect(first).toMatchObject({ message: 'JWT expired' }); + }); +}); + +describe('the saved-copies notice', () => { + it('stays while another screen still shows a copy, and goes when that copy is read from the server', async () => { + const cache = await load(); + cache.setCacheOwner('user-a'); + await cache.cachedRead('user-a', 'source', 'source-1', async () => 'text'); + await cache.cachedRead('user-a', 'sources', 'debate-1', async () => ['source']); + await cache.cachedRead('user-a', 'source', 'source-1', offlineFailure); + const shown = () => cache.getOfflineStatus().savedCopies; + expect(shown()).toBe(true); + await cache.cachedRead('user-a', 'sources', 'debate-1', async () => ['source']); + expect(shown()).toBe(true); + await cache.cachedRead('user-a', 'source', 'source-1', async () => 'text'); + expect(shown()).toBe(false); + }); + + it('goes when the screen showing a copy closes or reads something else', async () => { + const cache = await load(); + cache.setCacheOwner('user-a'); + await cache.cachedRead('user-a', 'source', 'source-1', async () => 'text'); + await cache.cachedRead('user-a', 'source', 'source-2', async () => 'other'); + const shown = () => cache.getOfflineStatus().savedCopies; + const sheet = new cache.CopyHolder(); + await cache.cachedRead('user-a', 'source', 'source-1', offlineFailure, sheet.mark()); + expect(shown()).toBe(true); + sheet.release(); + expect(shown()).toBe(false); + + // The screen moves on to another source, read from the server: the first copy no longer counts. + await cache.cachedRead('user-a', 'source', 'source-1', offlineFailure, sheet.mark()); + await cache.cachedRead('user-a', 'source', 'source-2', async () => 'other', sheet.mark()); + expect(shown()).toBe(false); + + // A read still running when the screen closed does not count once it lands. + const slow = deferred(); + const late = cache.cachedRead('user-a', 'source', 'source-1', () => slow.promise.then(offlineFailure), sheet.mark()); + sheet.release(); + slow.resolve(); + await expect(late).resolves.toBe('text'); + expect(shown()).toBe(false); + }); +}); + +describe('after a write', () => { + const flowchartRow = (title: string) => ({ id: 'map-1', title, debate_id: 'debate-1', nodes: [], edges: [], created_at: '2026-10-14T10:00:00.000Z' }); + + it('a read started before saving over a map cannot bring the old map back, and offline opens the saved one', async () => { + const cache = await load(); + cache.setCacheOwner('user-a'); + supabase.results.push({ data: flowchartRow('Before'), error: null, status: 200 }); + await cache.fetchFlowchart('user-a', 'map-1'); + const slow = deferred(); + const before = cache.cachedRead('user-a', 'flowchart', 'map-1', () => slow.promise); + supabase.results.push({ data: [flowchartRow('After')], error: null, status: 200 }); + await expect(cache.updateFlowchart('user-a', 'map-1', { nodes: [], edges: [] })).resolves.toBe(true); + slow.resolve({ title: 'Before' }); + await before; + await expect(cache.cachedRead('user-a', 'flowchart', 'map-1', offlineFailure)).resolves.toMatchObject({ title: 'After' }); + }); + + it('a change to a debate stops a list read already running for it, and leaves other debates alone', async () => { + const { cachedRead, afterWrite, debateListScopes, setCacheOwner } = await load(); + setCacheOwner('user-a'); + await cachedRead('user-a', 'debate-lists', 'recent:debate-1:pro:5', async () => ['old']); + await cachedRead('user-a', 'debate-lists', 'recent:debate-2:pro:5', async () => ['other']); + const slow = deferred(); + const running = cachedRead('user-a', 'debate-lists', 'recent:debate-1:pro:5', () => slow.promise); + afterWrite('user-a', debateListScopes('debate-1', 'case')); + slow.resolve(['old']); + await running; + await expect(cachedRead('user-a', 'debate-lists', 'recent:debate-1:pro:5', offlineFailure)).rejects.toThrow(); + await expect(cachedRead('user-a', 'debate-lists', 'recent:debate-2:pro:5', offlineFailure)).resolves.toEqual(['other']); + }); + + it('deleting a source drops it from the Library and the counts that held it', async () => { + const cache = await load(); + cache.setCacheOwner('user-a'); + await cache.cachedRead('user-a', 'sources', 'debate-1', async () => [{ id: 'source-1', content: 'full text' }]); + await cache.cachedRead('user-a', 'source', 'source-1', async () => ({ id: 'source-1', content: 'full text' })); + await cache.cachedRead('user-a', 'debate-lists', 'counts:debate-1', async () => ({ sources: 1 })); + await cache.cachedRead('user-a', 'debate-lists', 'source-counts:debate-1,debate-2', async () => ({ 'debate-1': 1 })); + await cache.cachedRead('user-a', 'sources', 'debate-2', async () => [{ id: 'source-2' }]); + supabase.results.push({ data: [{ debate_id: 'debate-1' }], error: null, status: 200 }); + await cache.removeSource('user-a', 'source-1'); + await cache.cachedRead('user-a', 'sources', 'flush', offlineFailure).catch(() => undefined); + expect([...storage.values()].some((value) => value.includes('full text'))).toBe(false); + await expect(cache.cachedRead('user-a', 'debate-lists', 'counts:debate-1', offlineFailure)).rejects.toThrow(); + await expect(cache.cachedRead('user-a', 'debate-lists', 'source-counts:debate-1,debate-2', offlineFailure)).rejects.toThrow(); + await expect(cache.cachedRead('user-a', 'sources', 'debate-2', offlineFailure)).resolves.toEqual([{ id: 'source-2' }]); + }); + + it('a delete whose row was already gone still drops every list and count that may hold it', async () => { + const cache = await load(); + cache.setCacheOwner('user-a'); + await cache.cachedRead('user-a', 'sources', 'debate-1', async () => [{ id: 'source-1' }]); + await cache.cachedRead('user-a', 'debate-lists', 'counts:debate-1', async () => ({ sources: 1 })); + await cache.cachedRead('user-a', 'debate-lists', 'source-counts:debate-1', async () => ({ 'debate-1': 1 })); + await cache.cachedRead('user-a', 'flowcharts', 'one-off', async () => [{ id: 'map-1' }]); + await cache.cachedRead('user-a', 'debate-lists', 'prepare:debate-1:20', async () => [{ id: 'map-1' }]); + // Deleted on the website first: the delete succeeds and returns no row. + supabase.results.push({ data: [], error: null, status: 200 }, { data: [], error: null, status: 200 }); + await cache.removeSource('user-a', 'source-1'); + await cache.deleteFlowchart('user-a', 'map-1'); + await cache.cachedRead('user-a', 'sources', 'flush', offlineFailure).catch(() => undefined); + for (const [group, key] of [['sources', 'debate-1'], ['debate-lists', 'counts:debate-1'], ['debate-lists', 'source-counts:debate-1'], ['flowcharts', 'one-off'], ['debate-lists', 'prepare:debate-1:20']] as const) { + await expect(cache.cachedRead('user-a', group, key, offlineFailure), `${group} ${key}`).rejects.toThrow(); + } + }); + + it('switching debates changes the saved Current Debate, so an offline restart opens the new one', async () => { + let cache = await load(); + cache.setCacheOwner('user-a'); + const row = (id: string, at: string) => ({ id, motion: id, settings: {}, instructions: '', last_activity_at: at, created_at: at }); + supabase.results.push( + { data: [row('debate-1', '2026-10-14T10:00:00.000Z'), row('debate-2', '2026-10-13T10:00:00.000Z')], error: null, status: 200 }, + { data: { debate_id: 'debate-1' }, error: null, status: 200 }, + ); + await cache.fetchDebates('user-a'); + await cache.switchDebate('user-a', 'debate-2'); + await cache.cachedRead('user-a', 'sources', 'flush', offlineFailure).catch(() => undefined); + + cache = await load(); + cache.setCacheOwner('user-a'); + const offline = await cache.cachedRead<{ debates: { id: string }[]; currentId: string }>('user-a', 'debates', 'all', offlineFailure); + expect(offline.currentId).toBe('debate-2'); + expect(offline.debates[0].id).toBe('debate-2'); + }); +}); diff --git a/apps/mobile/src/lib/offline-cache.ts b/apps/mobile/src/lib/offline-cache.ts new file mode 100644 index 000000000..65cc4e539 --- /dev/null +++ b/apps/mobile/src/lib/offline-cache.ts @@ -0,0 +1,407 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +import { isKnownOffline, setSavedCopiesShown } from './network-status'; +import { createSerialQueue } from './serial-queue'; + +/** + * Saved copies of what the app reads, so debates, sources, saved results and + * past rounds stay readable without a connection. A read goes to the network + * first and keeps a copy of the answer; when no answer comes back from the + * server, or the device is known to be offline, the copy answers instead. A + * server that answers with an error (a refused session, a policy, a bad + * request) is not a connection problem, so that error is thrown as it is. + * Nothing is written offline: saving waits for the network, and every write + * that succeeds updates or drops the copies it changed (`afterWrite`). + * + * The copies belong to one account. Each is stored under its user id and + * checked against it when read, and all of them are removed when the account + * changes, including signing out, before the next account's first read: every + * storage step runs in one queue, behind the removal. + */ + +/** How many copies each group keeps; the least recently fetched beyond that are dropped. */ +const LIMITS = { + /** The debates list with the Current Debate. */ + debates: 1, + /** Counts and recent work per debate and side. */ + 'debate-lists': 60, + /** A debate's Library, with each source's text. */ + sources: 20, + source: 60, + 'result:case': 40, + 'result:attack': 40, + 'result:brainstorm': 40, + 'result:factoids': 40, + flowcharts: 20, + flowchart: 40, + chats: 20, + chat: 40, + /** Judged Sparring rounds per debate, and each saved round. */ + 'sparring-rounds': 20, + sparring: 40, +} as const; + +export type CacheGroup = keyof typeof LIMITS; + +/** A copy larger than this is not kept; web storage holds about 5 MB in all. */ +const MAX_ENTRY_CHARS = 512_000; + +const PREFIX = 'debatelab.cache.v1'; +const OWNER_KEY = `${PREFIX}.owner`; +const indexKey = (userId: string) => `${PREFIX}.index:${userId}`; +const entryKey = (userId: string, group: CacheGroup, key: string) => `${PREFIX}:${userId}:${group}:${key}`; + +type Index = Record; + +/** + * A read that failed. `unreachable` when no answer came back from the server: + * no connection, a DNS failure or a timeout. Only those fall back to a copy. + */ +export class ReadError extends Error { + constructor(message: string, readonly unreachable: boolean) { + super(message); + this.name = 'ReadError'; + } +} + +/** + * The error to throw for a failed Supabase read. supabase-js answers a request + * that never reached the server with status 0, and the server's own refusals + * (401, 403, 404, 406, 5xx) with their HTTP status. + */ +export function readFailed(error: { message: string }, status: number): ReadError { + return new ReadError(error.message, status === 0); +} + +const isUnreachable = (cause: unknown) => cause instanceof ReadError && cause.unreachable; + +/** + * The failure to throw when several queries of one read failed: a server's + * refusal wins over a query that got no answer, so a saved copy never hides it. + */ +export function pickFailure(causes: unknown[]): unknown { + return causes.find((cause) => !isUnreachable(cause)) ?? causes[0]; +} + +/** + * Promise.all for the queries of one read. A server's refusal is thrown as + * soon as it arrives; a query that got no answer is held until the others + * settle, so a refusal from one of them still wins over it. + */ +export function readAll(reads: T): Promise<{ -readonly [K in keyof T]: Awaited }> { + return new Promise((resolve, reject) => { + const values: unknown[] = new Array(reads.length); + const unanswered: unknown[] = []; + let pending = reads.length; + const settle = () => { + pending -= 1; + if (pending > 0) return; + if (unanswered.length > 0) reject(unanswered[0]); + else resolve(values as { -readonly [K in keyof T]: Awaited }); + }; + if (pending === 0) { + resolve(values as { -readonly [K in keyof T]: Awaited }); + return; + } + reads.forEach((read, i) => { + Promise.resolve(read).then( + (value) => { + values[i] = value; + settle(); + }, + (cause: unknown) => { + if (!isUnreachable(cause)) reject(cause); + else unanswered.push(cause); + settle(); + }, + ); + }); + }); +} + +const queue = createSerialQueue(); +// undefined until the auth provider names the first owner, so that first call always checks storage. +let owner: string | null | undefined; +// Bumped on every owner change; work started for an earlier owner never writes. +let generation = 0; +// Every read takes a ticket when it starts. A copy is only replaced by an answer +// from a read that started later, so a slow answer never overwrites a newer one. +let tickets = 0; +const newest = new Map(); +// A write that changed several keys at once stops the reads of those keys that +// started before it. Kept only while such a read is still running. +let floors: { scope: CopyScope; ticket: number }[] = []; +const running = new Set(); +// The copies screens are showing right now instead of the server's answer, and +// who shows each: a screen's CopyHolder, or null for a read that named none. A +// key leaves when it is read from the server again, when a write drops it, or +// when the last screen showing it lets it go. +const shown = new Map }>(); +let index: { userId: string; entries: Index } | null = null; + +function warn(what: string) { + return (cause: unknown) => console.warn(`${what}:`, cause instanceof Error ? cause.message : cause); +} + +/** + * Called by the auth provider before it adopts a session. Copies kept for + * another account, or for no known account, are removed; the same account + * after a restart keeps them. + */ +export function setCacheOwner(userId: string | null) { + if (userId === owner) return; + owner = userId; + generation += 1; + newest.clear(); + floors = []; + shown.clear(); + setSavedCopiesShown(false); + index = null; + void queue(async () => { + if ((await AsyncStorage.getItem(OWNER_KEY)) === userId && userId !== null) return; + const keys = (await AsyncStorage.getAllKeys()).filter((key) => key.startsWith(`${PREFIX}:`) || key.startsWith(`${PREFIX}.index:`)); + if (keys.length > 0) await AsyncStorage.multiRemove(keys); + if (userId) await AsyncStorage.setItem(OWNER_KEY, userId); + else await AsyncStorage.removeItem(OWNER_KEY); + }).catch(warn('Could not clear saved copies')); +} + +async function loadIndex(userId: string): Promise { + if (index?.userId === userId) return index.entries; + let entries: Index = {}; + try { + const raw = await AsyncStorage.getItem(indexKey(userId)); + const parsed: unknown = raw ? JSON.parse(raw) : null; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) entries = parsed as Index; + } catch { + // An unreadable index starts again; its copies are dropped by the next account change. + } + index = { userId, entries }; + return entries; +} + +/** The saved copy, or undefined when there is none. A saved null (a row that was gone) is a copy too. */ +function readCopy(userId: string, group: CacheGroup, key: string): Promise<{ value: T } | undefined> { + return queue(async () => { + if (userId !== owner) return undefined; + const raw = await AsyncStorage.getItem(entryKey(userId, group, key)); + if (!raw || userId !== owner) return undefined; + const entry = JSON.parse(raw) as { userId?: unknown; value: T }; + return entry.userId === userId ? { value: entry.value } : undefined; + }); +} + +function writeCopy(userId: string, group: CacheGroup, key: string, value: unknown, ticket: number, gen: number): Promise { + const storageKey = entryKey(userId, group, key); + return queue(async () => { + try { + if (gen !== generation || userId !== owner || ticket < (newest.get(storageKey) ?? 0)) return; + if (floors.some((floor) => floor.ticket > ticket && inScope(userId, floor.scope, group, storageKey))) return; + newest.set(storageKey, ticket); + await storeEntry(userId, group, storageKey, value); + } finally { + settle(ticket); + } + }); +} + +/** A read is done with its ticket; floors that no running read started before are dropped. */ +function settle(ticket: number) { + running.delete(ticket); + const oldest = running.size > 0 ? Math.min(...running) : Infinity; + floors = floors.filter((floor) => floor.ticket > oldest); +} + +/** Stores a copy and drops the group's oldest beyond its limit. Runs inside the queue. */ +async function storeEntry(userId: string, group: CacheGroup, storageKey: string, value: unknown) { + const raw = JSON.stringify({ userId, value }); + const entries = await loadIndex(userId); + if (raw.length > MAX_ENTRY_CHARS) { + await removeEntries(userId, [storageKey]); + return; + } + entries[storageKey] = { group, at: Date.now() }; + const dropped = Object.entries(entries) + .filter(([, entry]) => entry.group === group) + .sort(([, a], [, b]) => b.at - a.at) + .slice(LIMITS[group]) + .map(([droppedKey]) => droppedKey); + for (const droppedKey of dropped) delete entries[droppedKey]; + await AsyncStorage.multiSet([[storageKey, raw], [indexKey(userId), JSON.stringify(entries)]]); + if (dropped.length > 0) await AsyncStorage.multiRemove(dropped); +} + +/** Removes copies and their index entries. Runs inside the queue. */ +async function removeEntries(userId: string, storageKeys: string[]) { + if (storageKeys.length === 0) return; + const entries = await loadIndex(userId); + for (const storageKey of storageKeys) delete entries[storageKey]; + await AsyncStorage.multiSet([[indexKey(userId), JSON.stringify(entries)]]); + await AsyncStorage.multiRemove(storageKeys); +} + +/** + * The reads of one screen, or of one part of it, so the saved copies it shows + * stop counting once it moves on. Each load takes a `mark()` and passes it to + * its reads; the first of them to finish lets go of what the screen's earlier + * loads showed, and a read from an earlier load finishing late no longer + * counts. `release()` lets go of everything, for when the screen closes or + * stops showing that data. + */ +export class CopyHolder { + loads = 0; + settled = 0; + readonly keys = new Set(); + + mark(): CopyMark { + return { holder: this, load: ++this.loads }; + } + + release() { + this.settled = ++this.loads; + letGo(this); + setSavedCopiesShown(shown.size > 0); + } +} + +export interface CopyMark { + holder: CopyHolder; + load: number; +} + +function letGo(holder: CopyHolder) { + for (const storageKey of holder.keys) { + const entry = shown.get(storageKey); + entry?.holders.delete(holder); + if (entry?.holders.size === 0) shown.delete(storageKey); + } + holder.keys.clear(); +} + +/** Whether a finished read of this load still counts for its screen; a newer load first lets go of the older ones' copies. */ +function settleMark({ holder, load }: CopyMark): boolean { + if (load < holder.settled) return false; + if (load > holder.settled) { + holder.settled = load; + letGo(holder); + } + return true; +} + +function noteShown(storageKey: string, group: CacheGroup, fromCopy: boolean, mark: CopyMark | undefined) { + const counts = !mark || settleMark(mark); + if (!fromCopy) shown.delete(storageKey); + else if (counts) { + const holder = mark?.holder ?? null; + const entry = shown.get(storageKey) ?? { group, holders: new Set() }; + entry.holders.add(holder); + shown.set(storageKey, entry); + holder?.keys.add(storageKey); + } + setSavedCopiesShown(shown.size > 0); +} + +/** + * Reads through the network and keeps a copy for this account. Offline, or + * when no answer came back from the server, answers from the copy; with no + * copy, or when the server answered with an error, fails as the read did. + * A screen passes its load's `mark` so a copy it shows stops counting once it moves on. + */ +export async function cachedRead(userId: string, group: CacheGroup, key: string, load: () => Promise, mark?: CopyMark): Promise { + const ticket = ++tickets; + const gen = generation; + const storageKey = entryKey(userId, group, key); + running.add(ticket); + if (isKnownOffline()) { + const copy = await readCopy(userId, group, key).catch(() => undefined); + if (copy) { + settle(ticket); + if (gen === generation) noteShown(storageKey, group, true, mark); + return copy.value; + } + } + let value: T; + try { + value = await load(); + } catch (cause) { + settle(ticket); + if (!isUnreachable(cause)) throw cause; + const copy = await readCopy(userId, group, key).catch(() => undefined); + if (!copy) throw cause; + if (gen === generation) noteShown(storageKey, group, true, mark); + return copy.value; + } + if (gen === generation) noteShown(storageKey, group, false, mark); + writeCopy(userId, group, key, value, ticket, gen).catch(warn('Could not keep a saved copy')); + return value; +} + +/** + * The copies a write changed: one `key`, or every key of the group that starts + * with `prefix` and names the id `about` (a debate, `one-off` or a row), or + * the whole group. + */ +export interface CopyScope { + group: CacheGroup; + key?: string; + prefix?: string; + about?: string; +} + +const keyOf = (userId: string, group: CacheGroup, storageKey: string) => storageKey.slice(entryKey(userId, group, '').length); + +function inScope(userId: string, scope: CopyScope, group: CacheGroup, storageKey: string): boolean { + if (scope.group !== group) return false; + const key = keyOf(userId, group, storageKey); + if (scope.key !== undefined) return key === scope.key; + return (!scope.prefix || key.startsWith(scope.prefix)) && (!scope.about || key.split(/[:,]/).includes(scope.about)); +} + +/** + * Called after a write succeeded. Drops the copies it changed, so an offline + * restart cannot show what was there before, and stops every read that + * started before the write from saving its older answer. + */ +export function afterWrite(userId: string, scopes: CopyScope[]) { + const gen = generation; + if (userId !== owner) return; + const ticket = ++tickets; + for (const scope of scopes) { + if (scope.key !== undefined) newest.set(entryKey(userId, scope.group, scope.key), ticket); + else if (running.size > 0) floors.push({ scope, ticket }); + } + for (const [storageKey, { group }] of shown) { + if (scopes.some((scope) => inScope(userId, scope, group, storageKey))) shown.delete(storageKey); + } + setSavedCopiesShown(shown.size > 0); + void queue(async () => { + if (gen !== generation || userId !== owner) return; + const entries = await loadIndex(userId); + const dropped = Object.entries(entries) + .filter(([storageKey, entry]) => scopes.some((scope) => inScope(userId, scope, entry.group, storageKey))) + .map(([storageKey]) => storageKey); + await removeEntries(userId, dropped); + }).catch(warn('Could not drop saved copies')); +} + +/** + * Called after a write succeeded when the new value of one copy is known: + * `update` gets the copy (undefined when there is none) and returns its + * replacement, or undefined to drop it. Reads that started before this call + * cannot save over it. + */ +export function updateCopy(userId: string, group: CacheGroup, key: string, update: (copy: T | undefined) => T | undefined) { + const storageKey = entryKey(userId, group, key); + const gen = generation; + if (userId !== owner) return; + newest.set(storageKey, ++tickets); + void queue(async () => { + if (gen !== generation || userId !== owner) return; + const raw = await AsyncStorage.getItem(storageKey); + const entry = raw ? (JSON.parse(raw) as { userId?: unknown; value: T }) : null; + const next = update(entry?.userId === userId ? entry.value : undefined); + if (next === undefined) await removeEntries(userId, [storageKey]); + else await storeEntry(userId, group, storageKey, next); + }).catch(warn('Could not update a saved copy')); +} + diff --git a/apps/mobile/src/lib/results.test.ts b/apps/mobile/src/lib/results.test.ts index 2641a42dc..0ec8300db 100644 --- a/apps/mobile/src/lib/results.test.ts +++ b/apps/mobile/src/lib/results.test.ts @@ -4,6 +4,7 @@ const addSource = vi.hoisted(() => vi.fn()); vi.mock('./supabase', () => ({ supabase: {} })); vi.mock('expo/fetch', () => ({ fetch: vi.fn() })); +vi.mock('./offline-cache', () => ({ cachedRead: vi.fn(), afterWrite: vi.fn(), updateCopy: vi.fn(), readFailed: vi.fn() })); vi.mock('./sources', () => ({ addSource, applySourceAssessments: vi.fn(), fetchSourceIdsInFolder: vi.fn(), removeSource: vi.fn() })); import { autosaveSources, readBrainstorm, readFactoids } from './results'; diff --git a/apps/mobile/src/lib/results.ts b/apps/mobile/src/lib/results.ts index 267dd43dc..c03b44cd9 100644 --- a/apps/mobile/src/lib/results.ts +++ b/apps/mobile/src/lib/results.ts @@ -18,6 +18,8 @@ import type { DebateContextRequest } from '@debatelab/core/contract/debate-conte import { AIError, functionOutput, postFunction, PROVIDER, responseExtras, type ResponseExtras } from './ai-request'; import type { FeatureConfig } from './feature-config'; +import { debateListScopes, deletedListScopes } from './debates'; +import { afterWrite, cachedRead, readFailed, type CopyMark } from './offline-cache'; import { addSource, applySourceAssessments, fetchSourceIdsInFolder, removeSource } from './sources'; import { supabase } from './supabase'; @@ -223,6 +225,7 @@ export async function recordResult( .select('id') .single(); if (error || !data) throw new Error(error?.message ?? `Could not save the ${kind}`); + afterWrite(userId, debateListScopes(record.debateId, kind)); return (data as { id: string }).id; } @@ -247,14 +250,19 @@ interface ResultRow { output: unknown; } -export async function fetchResult(userId: string, kind: K, id: string): Promise | null> { - const { data, error } = await supabase +/** A saved result; offline, the saved copy. */ +export function fetchResult(userId: string, kind: K, id: string, mark?: CopyMark): Promise | null> { + return cachedRead(userId, `result:${kind}`, id, () => loadResult(userId, kind, id), mark); +} + +async function loadResult(userId: string, kind: K, id: string): Promise | null> { + const { data, error, status } = await supabase .from(TABLES[kind]) .select('id,title,side,debate_id,created_at,input,output') .eq('user_id', userId) .eq('id', id) .maybeSingle(); - if (error) throw new Error(error.message); + if (error) throw readFailed(error, status); if (!data) return null; const row = data as ResultRow; return { @@ -270,8 +278,10 @@ export async function fetchResult(userId: string, kind: K, } export async function deleteResult(userId: string, kind: ResultKind, id: string): Promise { - const { error } = await supabase.from(TABLES[kind]).delete().eq('user_id', userId).eq('id', id); + const { data, error } = await supabase.from(TABLES[kind]).delete().eq('user_id', userId).eq('id', id).select('debate_id'); if (error) throw new Error(error.message); + const deletedFrom = ((data ?? []) as { debate_id: string | null }[]).map((row) => row.debate_id); + afterWrite(userId, [{ group: `result:${kind}`, key: id }, ...deletedListScopes(deletedFrom, kind)]); } /** The guard the website's results pages use: an old or broken row shows a notice instead of a crash. */ diff --git a/apps/mobile/src/lib/sources.test.ts b/apps/mobile/src/lib/sources.test.ts index c07b1ae2a..3c08a7d52 100644 --- a/apps/mobile/src/lib/sources.test.ts +++ b/apps/mobile/src/lib/sources.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it, vi } from 'vitest'; const supabase = vi.hoisted(() => ({ from: vi.fn() })); vi.mock('./supabase', () => ({ supabase })); +// Reads go straight to the network here; offline-cache.test.ts covers the saved copies. +vi.mock('./offline-cache', () => ({ cachedRead: (_user: string, _group: string, _key: string, load: () => Promise) => load(), afterWrite: vi.fn(), updateCopy: vi.fn(), readFailed: vi.fn() })); import { applySourceAssessments } from './sources'; diff --git a/apps/mobile/src/lib/sources.ts b/apps/mobile/src/lib/sources.ts index 9a6283402..eaa5b6f94 100644 --- a/apps/mobile/src/lib/sources.ts +++ b/apps/mobile/src/lib/sources.ts @@ -3,6 +3,8 @@ import { SourceImportOutputSchema, type SourceImportOutput } from '@debatelab/co import { assessedSemantic, isAssessedSource, type SourceAssessmentUpdate } from '@debatelab/core/ai/source-assessments'; import { getEdgeFunctionErrorMessage } from '@debatelab/core/supabase/edge-function-error'; +import { debateListScopes, deletedListScopes } from './debates'; +import { afterWrite, cachedRead, readFailed, type CopyMark, type CopyScope } from './offline-cache'; import { supabase } from './supabase'; /** @@ -61,26 +63,34 @@ function fromRow(row: SourceRow): Source { * the real boundary; the filter keeps a wrong session from mixing accounts. */ -/** One debate's sources, newest first, as the website's Library lists them by default. */ -export async function fetchSources(userId: string, debateId: string): Promise { - const { data, error } = await supabase +/** One debate's sources, newest first, as the website's Library lists them by default. Offline, the saved copy. */ +export function fetchSources(userId: string, debateId: string, mark?: CopyMark): Promise { + return cachedRead(userId, 'sources', debateId, () => loadSources(userId, debateId), mark); +} + +async function loadSources(userId: string, debateId: string): Promise { + const { data, error, status } = await supabase .from('sources') .select(SOURCE_COLUMNS) .eq('user_id', userId) .eq('debate_id', debateId) .order('created_at', { ascending: false }); - if (error) throw new Error(error.message); + if (error) throw readFailed(error, status); return ((data ?? []) as SourceRow[]).map(fromRow); } -export async function fetchSource(userId: string, id: string): Promise { - const { data, error } = await supabase +export function fetchSource(userId: string, id: string, mark?: CopyMark): Promise { + return cachedRead(userId, 'source', id, () => loadSource(userId, id), mark); +} + +async function loadSource(userId: string, id: string): Promise { + const { data, error, status } = await supabase .from('sources') .select(SOURCE_COLUMNS) .eq('user_id', userId) .eq('id', id) .maybeSingle(); - if (error) throw new Error(error.message); + if (error) throw readFailed(error, status); return data ? fromRow(data as SourceRow) : null; } @@ -123,6 +133,7 @@ export async function addSource( .select(SOURCE_COLUMNS) .single(); if (error || !data) throw new Error(error?.message ?? 'Could not save the source'); + afterWrite(userId, [{ group: 'sources', key: input.debateId }, ...debateListScopes(input.debateId, 'source')]); notifySourcesChanged(); return fromRow(data as SourceRow); } @@ -142,8 +153,14 @@ export async function fetchSourceIdsInFolder(userId: string, debateId: string, f /** Removes a source; with a debate, only while the source is still in that debate. */ export async function removeSource(userId: string, id: string, debateId?: string): Promise { const query = supabase.from('sources').delete().eq('user_id', userId).eq('id', id); - const { error } = await (debateId ? query.eq('debate_id', debateId) : query); + const { data, error } = await (debateId ? query.eq('debate_id', debateId) : query).select('debate_id'); if (error) throw new Error(error.message); + // The deleted row names its debate, whose Library and counts change. With no row, every Library may hold it. + const deletedFrom = ((data ?? []) as { debate_id: string | null }[]).map((row) => row.debate_id); + const libraries: CopyScope[] = deletedFrom.length > 0 + ? deletedFrom.flatMap((debate) => (debate ? [{ group: 'sources' as const, key: debate }] : [])) + : [{ group: 'sources' }]; + afterWrite(userId, [{ group: 'source', key: id }, ...libraries, ...deletedListScopes(deletedFrom, 'source')]); notifySourcesChanged(); } @@ -175,6 +192,8 @@ export async function applySourceAssessments(userId: string, debateId: string, a const results = await Promise.all( Array.from(updates, ([id, metadata]) => supabase.from('sources').update({ metadata }).eq('user_id', userId).eq('debate_id', debateId).eq('id', id)), ); + // The Library and the source sheet show how often a source was used. + afterWrite(userId, [{ group: 'sources', key: debateId }, ...Array.from(updates.keys(), (id) => ({ group: 'source' as const, key: id }))]); notifySourcesChanged(); const failed = results.find((result) => result.error); if (failed?.error) throw new Error(failed.error.message); diff --git a/apps/mobile/src/lib/sparring.ts b/apps/mobile/src/lib/sparring.ts index fec9c74ab..f40fe61d8 100644 --- a/apps/mobile/src/lib/sparring.ts +++ b/apps/mobile/src/lib/sparring.ts @@ -14,6 +14,8 @@ import * as Crypto from 'expo-crypto'; import { AIError, functionOutput, postFunction, PROVIDER } from './ai-request'; import type { FeatureConfig } from './feature-config'; import { sessionSlot } from './session-memory'; +import { debateListScopes, deletedListScopes } from './debates'; +import { afterWrite, cachedRead, readFailed, type CopyMark, type CopyScope } from './offline-cache'; import { supabase } from './supabase'; /** @@ -81,16 +83,33 @@ export function judgeSparring(input: SparringScoreInput, options: RequestOptions }); } +/** The saved lists a judged round appears in, or leaves, in this debate or outside every debate. */ +const roundListScopes = (debateId: string | null): CopyScope[] => [{ group: 'sparring-rounds', prefix: `${debateId ?? 'one-off'}:` }]; + +/** + * Called once the judge answered: the server saved the round as judged, so it + * now shows in the lists of the debate it was started in, or outside every debate. + */ +export function noteJudgedRound(userId: string, id: string, debateId: string | null) { + afterWrite(userId, [{ group: 'sparring', key: id }, ...roundListScopes(debateId), ...debateListScopes(debateId, 'sparring')]); +} + /** Files a judged round in its debate under the side the student spoke, as the store's attachSessionToDebate. */ -export async function attachSparring(id: string, debateId: string, side: SparringSide): Promise { +export async function attachSparring(userId: string, id: string, debateId: string, side: SparringSide): Promise { const { error } = await supabase.rpc('attach_to_debate', { p_kind: 'sparring', p_id: id, p_debate_id: debateId, p_side: side }); if (error) throw new Error(error.message); + // The round may have been outside every debate until now. + afterWrite(userId, [{ group: 'sparring', key: id }, ...roundListScopes(debateId), ...roundListScopes(null), ...debateListScopes(debateId, 'sparring')]); } -/** A saved, judged round; null when there is none or it does not check out. */ -export async function fetchSparringRound(userId: string, id: string): Promise { - const { data, error } = await supabase.from('sparring_sessions').select(SPARRING_SESSION_COLUMNS).eq('user_id', userId).eq('id', id).maybeSingle(); - if (error) throw new Error(error.message); +/** A saved, judged round; null when there is none or it does not check out. Offline, the saved copy. */ +export async function fetchSparringRound(userId: string, id: string, mark?: CopyMark): Promise { + return cachedRead(userId, 'sparring', id, () => loadSparringRound(userId, id), mark); +} + +async function loadSparringRound(userId: string, id: string): Promise { + const { data, error, status } = await supabase.from('sparring_sessions').select(SPARRING_SESSION_COLUMNS).eq('user_id', userId).eq('id', id).maybeSingle(); + if (error) throw readFailed(error, status); return data ? parseSavedSparringRows([data])[0] ?? null : null; } @@ -105,11 +124,15 @@ export interface SparringRoundSummary { * The judged rounds in one debate, or outside every debate when `debateId` is * null, newest first, as the website's history lists them. */ -export async function fetchSparringRounds(userId: string, debateId: string | null, limit: number): Promise { +export async function fetchSparringRounds(userId: string, debateId: string | null, limit: number, mark?: CopyMark): Promise { + return cachedRead(userId, 'sparring-rounds', `${debateId ?? 'one-off'}:${limit}`, () => loadSparringRounds(userId, debateId, limit), mark); +} + +async function loadSparringRounds(userId: string, debateId: string | null, limit: number): Promise { let query = supabase.from('sparring_sessions').select('id,motion,user_side,updated_at').eq('user_id', userId).eq('status', 'complete'); query = debateId ? query.eq('debate_id', debateId) : query.is('debate_id', null); - const { data, error } = await query.order('updated_at', { ascending: false }).limit(limit); - if (error) throw new Error(error.message); + const { data, error, status } = await query.order('updated_at', { ascending: false }).limit(limit); + if (error) throw readFailed(error, status); return ((data ?? []) as { id: string; motion: string; user_side: SparringSide; updated_at: string }[]).map((row) => ({ id: row.id, motion: row.motion, @@ -119,8 +142,12 @@ export async function fetchSparringRounds(userId: string, debateId: string | nul } export async function deleteSparringRound(userId: string, id: string): Promise { - const { error } = await supabase.from('sparring_sessions').delete().eq('user_id', userId).eq('id', id); + const { data, error } = await supabase.from('sparring_sessions').delete().eq('user_id', userId).eq('id', id).select('debate_id'); if (error) throw new Error(error.message); + const deletedFrom = ((data ?? []) as { debate_id: string | null }[]).map((row) => row.debate_id); + // With no row, it was already gone and any debate's rounds, or the one-off rounds, may still list it. + const roundLists: CopyScope[] = deletedFrom.length > 0 ? deletedFrom.flatMap(roundListScopes) : [{ group: 'sparring-rounds' }]; + afterWrite(userId, [{ group: 'sparring', key: id }, ...roundLists, ...deletedListScopes(deletedFrom, 'sparring')]); } /** A new round's id. The server takes it as the saved row's id, so it must be a UUID. */ diff --git a/apps/mobile/src/lib/use-copy-holder.ts b/apps/mobile/src/lib/use-copy-holder.ts new file mode 100644 index 000000000..c23bc4dd0 --- /dev/null +++ b/apps/mobile/src/lib/use-copy-holder.ts @@ -0,0 +1,14 @@ +import { useEffect, useState } from 'react'; + +import { CopyHolder } from './offline-cache'; + +/** + * A CopyHolder for one screen's reads; the copies it shows stop counting when + * the screen closes. A screen that also shows what another screen read for it + * passes the holder they share. + */ +export function useCopyHolder(shared?: CopyHolder): CopyHolder { + const [holder] = useState(() => shared ?? new CopyHolder()); + useEffect(() => () => holder.release(), [holder]); + return holder; +} diff --git a/apps/mobile/src/lib/use-result-document.ts b/apps/mobile/src/lib/use-result-document.ts index 144d80206..4143bd8ff 100644 --- a/apps/mobile/src/lib/use-result-document.ts +++ b/apps/mobile/src/lib/use-result-document.ts @@ -9,6 +9,7 @@ import { useDebates } from './debate-context'; import { forgetOpenResult, keepOpenResult, openResult, updateOpenResult, type OpenResult } from './open-results'; import { deleteResult, fetchAutoSavedSourceIds, fetchResult, removeSources, saveCase, type ArrivalNotice, type ResultKind } from './results'; import { fetchSources, subscribeSourcesChanged, type Source } from './sources'; +import { useCopyHolder } from './use-copy-holder'; export type DocumentState = | { status: 'loading' } @@ -64,6 +65,8 @@ export function useResultDocument(kind: K, id: string) { const [busy, setBusy] = useState(false); const live = useRef(true); const owner = useRef(userId); + const resultCopies = useCopyHolder(); + const libraryCopies = useCopyHolder(); owner.current = userId; const noticeTimer = useRef | null>(null); @@ -89,7 +92,7 @@ export function useResultDocument(kind: K, id: string) { return; } let active = true; - fetchResult(userId, kind, id) + fetchResult(userId, kind, id, resultCopies.mark()) .then((row) => { if (!active) return; if (!row) { @@ -107,7 +110,7 @@ export function useResultDocument(kind: K, id: string) { return () => { active = false; }; - }, [userId, kind, id]); + }, [userId, kind, id, resultCopies]); const result = state.status === 'ready' ? state.result : null; const resultDebateId = result?.debateId ?? null; @@ -116,11 +119,12 @@ export function useResultDocument(kind: K, id: string) { useEffect(() => { if (!userId || !resultDebateId) { setLibrary([]); + libraryCopies.release(); return; } let active = true; const load = () => - fetchSources(userId, resultDebateId) + fetchSources(userId, resultDebateId, libraryCopies.mark()) .then((next) => { if (active) setLibrary(next); }) @@ -131,7 +135,7 @@ export function useResultDocument(kind: K, id: string) { active = false; unsubscribe(); }; - }, [userId, resultDebateId]); + }, [userId, resultDebateId, libraryCopies]); // The website's undo covers the Current Debate's Auto-saved folder, also in one-off mode. const currentId = current?.id ?? null; diff --git a/package-lock.json b/package-lock.json index 6b3bbe924..58f804f3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -138,6 +138,7 @@ "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.12", "expo-localization": "~57.0.2", + "expo-network": "~57.0.2", "expo-print": "~57.0.2", "expo-router": "~57.0.25", "expo-secure-store": "~57.0.4", @@ -792,6 +793,16 @@ "react-native": "*" } }, + "apps/mobile/node_modules/expo-network": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-network/-/expo-network-57.0.2.tgz", + "integrity": "sha512-BfK1kdftZcM6o9o1FAG7/y0l6vWX0n8td0M5lwkvaMeRXZ9LkRwOi9CQXwvsMTQGQiPwtQXgwiAze13cqjt75g==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*" + } + }, "apps/mobile/node_modules/expo-print": { "version": "57.0.2", "resolved": "https://registry.npmjs.org/expo-print/-/expo-print-57.0.2.tgz", diff --git a/src/locales/en.json b/src/locales/en.json index cbc7f663a..38a528392 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2428,6 +2428,9 @@ "common": { "done": "Done" }, + "offline": { + "notice": "Offline: showing saved copies. AI and saving wait for the connection." + }, "debates": { "search": "Search motions", "noMatches": "No debates match your search.", diff --git a/src/locales/es.json b/src/locales/es.json index 01373bce9..f81d1e003 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2428,6 +2428,9 @@ "common": { "done": "Listo" }, + "offline": { + "notice": "Sin conexión: se muestran las copias guardadas. La IA y el guardado esperan a la conexión." + }, "debates": { "search": "Buscar mociones", "noMatches": "Ningún debate coincide con tu búsqueda.", diff --git a/src/locales/hu.json b/src/locales/hu.json index 0bf45e686..3feb0fc63 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2428,6 +2428,9 @@ "common": { "done": "Kész" }, + "offline": { + "notice": "Offline: a mentett másolatokat látod. Az MI és a mentés megvárja a kapcsolatot." + }, "debates": { "search": "Tételek keresése", "noMatches": "Nincs a keresésnek megfelelő vita.",