From 2e741bbb3dc30c024744ffd8d67cd9c7e7f1a104 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 12:41:04 +0000 Subject: [PATCH 01/12] Add History to the app History mirrors the website's History page: saved cases, chats, attacks, brainstorms, factoids and Sparring rounds for the Current Debate or any other debate, and one-off work with Library sources outside every debate, each with the suggested debate and Add to debate through attach_to_debate. Rows open their documents or the Flowchart, chats reopen in Ask, and cases swipe to delete with the website's confirmation. Sparring rounds stay read-only until Sparring is in the app. The Debate tab's See all and a row in You open it. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 3 +- apps/mobile/src/app/(tabs)/(debate)/index.tsx | 14 +- apps/mobile/src/app/(tabs)/(you)/you.tsx | 19 + apps/mobile/src/app/_layout.tsx | 2 + apps/mobile/src/app/ask.tsx | 12 +- apps/mobile/src/app/history.tsx | 535 ++++++++++++++++++ apps/mobile/src/components/work-row.tsx | 4 +- apps/mobile/src/lib/back-label.ts | 4 +- apps/mobile/src/lib/history.ts | 208 +++++++ apps/mobile/src/lib/motion.ts | 5 + src/locales/en.json | 9 +- src/locales/es.json | 9 +- src/locales/hu.json | 9 +- 13 files changed, 819 insertions(+), 14 deletions(-) create mode 100644 apps/mobile/src/app/history.tsx create mode 100644 apps/mobile/src/lib/history.ts diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 5c2f84e46..a09b5545e 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -17,7 +17,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp ## Layout -- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; the form sheets `debates.tsx` (switcher), `new-debate.tsx`, `delete-account.tsx`, `add-link.tsx`, `new-note.tsx` and `source/[id].tsx`; and `ask.tsx`, `generate.tsx`, `case/[id].tsx`, `attack/`, `brainstorm/` and `factoids/` (each a form `index.tsx` and a document `[id].tsx`), `crossfire.tsx` and `timer.tsx` (a full-screen modal), and `flowchart/` (the screen `index.tsx` with its `card`, `claim` and `maps` form sheets), full screen above the tabs. All are declared in the root `_layout.tsx`. `(practice)/soon/[tool].tsx` stands in for Practice tools not in the app yet. +- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; the form sheets `debates.tsx` (switcher), `new-debate.tsx`, `delete-account.tsx`, `add-link.tsx`, `new-note.tsx` and `source/[id].tsx`; and `ask.tsx`, `generate.tsx`, `case/[id].tsx`, `attack/`, `brainstorm/` and `factoids/` (each a form `index.tsx` and a document `[id].tsx`), `crossfire.tsx` and `timer.tsx` (a full-screen modal), `flowchart/` (the screen `index.tsx` with its `card`, `claim` and `maps` form sheets), and `history.tsx`, full screen above the tabs. All are declared in the root `_layout.tsx`. `(practice)/soon/[tool].tsx` stands in for Practice tools not in the app yet. - `src/components/`: shared UI. `app-tabs.tsx` uses native tabs; `app-tabs.web.tsx` draws the mock's tab bar for the web preview. - `src/lib/`: auth (`auth.tsx`), the Supabase client, session storage (AES key in SecureStore, ciphertext in AsyncStorage) and i18n. `debates.ts`, `settings.tsx`, `subscription.ts` and `account.ts` read and write the website's tables the way its stores do (`src/lib/stores/debate-store.ts`, `settings-store.ts`, `src/hooks/use-subscription.ts`, `src/lib/account.ts`), so the app and the website share one Current Debate and one set of settings. Change them together with the website code they mirror. - `sources.ts` mirrors the session store's `addSource` and `removeSource` and `src/lib/source-import.ts`. The Library always shows the Current Debate, also in one-off mode, as on the website. Adding a file (the website parses files in the browser) and Scan pages are not in the app yet. @@ -27,6 +27,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp - Timer mirrors `src/pages/TimerPage.tsx`: `timer.ts` holds the formats and a clock made of start timestamps, never counted ticks, so it is right after the app returns from the background (`timer.test.ts`). `timer-signals.ts` plays the bundled beeps in `assets/sounds` (made by `scripts/render-timer-sounds.ts`) with expo-audio, taps with expo-haptics and keeps the screen awake while a speech runs; `timer-signals.web.ts` only records the signals for the screenshot script. Lock-screen timers wait for the development build (`TODO(live-activity)`). - Crossfire is the website's typed mode: `crossfire.ts` sends `crossfire` and its signal requests as `src/lib/ai/client.ts` does (Ask's model settings, 75 s and 5 s deadlines) and saves a finished round to `crossfires` in its debate, as CrossfirePage's `recordResult` call; the reducer and schemas are `packages/core/src/crossfire`. The unfinished round is a session slot, so leaving the screen keeps it. Voice rounds need the development build, so the voice switch shows disabled. The website cannot reopen a saved round, so neither can the app. - 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`). +- History mirrors `src/pages/HistoryPage.tsx` and `UnsortedWork.tsx`: `history.ts` reads each table for one debate, or every kind plus Library sources outside all debates, and adds unsorted work to a debate through the website's `attach_to_debate` function. Chats reopen in Ask (`/ask?chat=`). Sparring rounds are listed read-only until Sparring is in the app. - 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 and Crossfire 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. - `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. diff --git a/apps/mobile/src/app/(tabs)/(debate)/index.tsx b/apps/mobile/src/app/(tabs)/(debate)/index.tsx index e53a03cd7..15b170b41 100644 --- a/apps/mobile/src/app/(tabs)/(debate)/index.tsx +++ b/apps/mobile/src/app/(tabs)/(debate)/index.tsx @@ -199,9 +199,15 @@ function DebateDetails({ debateId }: { debateId: string }) { ))} - - {t('mobile.debate.pickUp')} - + + + {t('mobile.debate.pickUp')} + + router.push({ pathname: '/history', params: { from: 'debate' } })} + /> + {failed ? ( @@ -331,6 +337,8 @@ const styles = StyleSheet.create({ sparTitle: { color: '#ffffff', fontSize: 18, lineHeight: 23 }, sparLine: { color: 'rgba(255,255,255,0.88)', fontSize: 14.5, lineHeight: 20 }, section: { fontSize: 18.5, lineHeight: 24, letterSpacing: -0.2, marginTop: 20, marginBottom: 9, marginHorizontal: 2 }, + sectionRow: { flexDirection: 'row', alignItems: 'center', gap: 12, marginTop: 10, marginRight: 2 }, + sectionTitle: { flex: 1, marginTop: 0, marginBottom: 0 }, grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 }, tile: { flexGrow: 1, diff --git a/apps/mobile/src/app/(tabs)/(you)/you.tsx b/apps/mobile/src/app/(tabs)/(you)/you.tsx index 27d4238c4..c4275e598 100644 --- a/apps/mobile/src/app/(tabs)/(you)/you.tsx +++ b/apps/mobile/src/app/(tabs)/(you)/you.tsx @@ -53,6 +53,25 @@ export default function YouScreen() { + + router.push({ pathname: '/history', params: { from: 'you' } })} + testID="you-history" + style={({ pressed }) => [styles.row, pressed && styles.pressed]}> + + + + {t('nav.history')} + + + {t('mobile.you.historyDesc')} + + + + + + {t('settings.globalSettings')} diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index af126945e..2c2fd3179 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -81,6 +81,8 @@ function Root() { {/* Full screen above the tabs, as in mock 07. */} + {/* History, full screen above the tabs like the documents it opens. */} + {/* Generate, Attack, Brainstorm, Factoids and their documents, full screen above the tabs, as in mock 03. */} diff --git a/apps/mobile/src/app/ask.tsx b/apps/mobile/src/app/ask.tsx index 9b9678d0c..5d431a0db 100644 --- a/apps/mobile/src/app/ask.tsx +++ b/apps/mobile/src/app/ask.tsx @@ -50,7 +50,7 @@ const nextId = () => `local-${Date.now()}-${(localId += 1)}`; export default function AskScreen() { const { t } = useTranslation(); const router = useRouter(); - const { prompt } = useLocalSearchParams<{ prompt?: string }>(); + const { prompt, chat: resumeChatId } = useLocalSearchParams<{ prompt?: string; chat?: string }>(); const insets = useSafeAreaInsets(); const { colors } = useTheme(); const { session } = useAuth(); @@ -318,6 +318,16 @@ export default function AskScreen() { } }; + // A chat picked in History opens here, as the website's Resume loads it into Ask. Once per opening. + const resumed = useRef(false); + useEffect(() => { + if (resumed.current || !resumeChatId || !userId) return; + resumed.current = true; + void openChat(resumeChatId); + // openChat reads refs and state setters; it runs once per opening. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resumeChatId, userId]); + const subtitle = oneOff ? t('debate.oneOffActive') : current?.motion ?? ''; const busy = loading || opening; diff --git a/apps/mobile/src/app/history.tsx b/apps/mobile/src/app/history.tsx new file mode 100644 index 000000000..f7c5e8f8b --- /dev/null +++ b/apps/mobile/src/app/history.tsx @@ -0,0 +1,535 @@ +import { useFocusEffect, useLocalSearchParams, useRouter, type Href } from 'expo-router'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import ReanimatedSwipeable, { type SwipeableMethods } from 'react-native-gesture-handler/ReanimatedSwipeable'; + +import { ActionMenu } from '@/components/action-menu'; +import { PrimaryButton, TextButton } from '@/components/buttons'; +import { Card, IconTile } from '@/components/card'; +import { Chip } from '@/components/chip'; +import { DocumentHeader } from '@/components/document-header'; +import { Icon } from '@/components/icon'; +import { Screen } from '@/components/screen'; +import { Text } from '@/components/text'; +import { KIND_ICONS, workHref } from '@/components/work-row'; +import { useAuth } from '@/lib/auth'; +import { backLabel } from '@/lib/back-label'; +import { confirmDestructive, showNotice } from '@/lib/confirm'; +import { formatWhen } from '@/lib/dates'; +import { useDebates } from '@/lib/debate-context'; +import type { Debate } from '@/lib/debates'; +import { + attachToDebate, + fetchDebateHistory, + fetchUnsortedWork, + HISTORY_TABS, + type HistoryItem, + type HistoryLists, + type HistoryTab, + type UnsortedEntry, +} from '@/lib/history'; +import { keepIfSame } from '@/lib/keep-if-same'; +import { suggestDebate } from '@/lib/motion'; +import { deleteResult } from '@/lib/results'; +import { useTheme } from '@/theme/use-theme'; + +/** The History scope for work that belongs to no debate, as the website's OUTSIDE_DEBATES. */ +const OUTSIDE = 'outside'; + +type Loaded = + | { scope: string; kind: 'debate'; lists: HistoryLists } + | { scope: typeof OUTSIDE; kind: 'unsorted'; entries: UnsortedEntry[] }; + +const EMPTY: Record = { + cases: { title: 'historyPage.emptyCases', desc: 'historyPage.emptyCasesDesc', action: 'historyPage.createCase', href: { pathname: '/generate', params: { from: 'history' } } }, + chats: { title: 'historyPage.emptyChats', desc: 'historyPage.emptyChatsDesc', action: 'historyPage.startChat', href: '/ask' }, + attacks: { title: 'historyPage.emptyAttacks', desc: 'historyPage.emptyAttacksDesc', action: 'historyPage.attackCase', href: { pathname: '/attack', params: { from: 'history' } } }, + brainstorms: { title: 'historyPage.emptyBrainstorms', desc: 'historyPage.emptyBrainstormsDesc', action: 'historyPage.brainstormIdeas', href: { pathname: '/brainstorm', params: { from: 'history' } } }, + factoids: { title: 'historyPage.emptyFactoids', desc: 'historyPage.emptyFactoidsDesc', action: 'historyPage.findEvidence', href: { pathname: '/factoids', params: { from: 'history' } } }, + // Sparring is not in the app yet (PR 5b); the Practice tab is where it will start. + sparring: { title: 'historyPage.emptySparring', desc: 'historyPage.emptySparringDesc', action: 'historyPage.startSparring', href: '/practice' }, +}; + +/** + * Where a History row opens, or null when the app cannot open it. Chats load + * back into Ask, as the website's Resume does. The website cannot reopen a + * Crossfire round, so neither can the app. + * TODO(PR 5b): open and delete Sparring rounds once Sparring is in the app. + */ +function historyHref(item: HistoryItem): Href | null { + switch (item.kind) { + case 'chat': + return { pathname: '/ask', params: { chat: item.id } }; + case 'sparring': + case 'crossfire': + return null; + default: + return workHref(item, 'history'); + } +} + +/** The website's History (src/pages/HistoryPage.tsx): saved work per debate, and work outside every debate. */ +export default function HistoryScreen() { + const { t } = useTranslation(); + const { from } = useLocalSearchParams<{ from?: string }>(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const { status, debates, current, refresh: refreshDebates } = useDebates(); + // History opens on the Current Debate, also in one-off mode, as on the website; other debates stay reachable. + const [scopeChoice, setScopeChoice] = useState(null); + const scope = scopeChoice && (scopeChoice === OUTSIDE || debates.some((debate) => debate.id === scopeChoice)) + ? scopeChoice + : current?.id ?? OUTSIDE; + const [tab, setTab] = useState('cases'); + const [loaded, setLoaded] = useState(null); + const [failed, setFailed] = useState(null); + const [scopeOpen, setScopeOpen] = useState(false); + + // Only the latest load lands, and nothing lands after an account change or after leaving. + const loads = useRef(0); + const owner = useRef(userId); + const live = useRef(true); + useEffect(() => { + owner.current = userId; + loads.current += 1; + setLoaded(null); + }, [userId]); + useEffect(() => { + live.current = true; + return () => { + live.current = false; + loads.current += 1; + }; + }, []); + + const load = useCallback(async (target: string) => { + if (!userId) return; + const forUser = userId; + const request = ++loads.current; + const isCurrent = () => live.current && loads.current === request && owner.current === forUser; + try { + const next: Loaded = target === OUTSIDE + ? { scope: OUTSIDE, kind: 'unsorted', entries: await fetchUnsortedWork(forUser) } + : { scope: target, kind: 'debate', lists: await fetchDebateHistory(forUser, target) }; + if (!isCurrent()) return; + // Focus refreshes usually reread the same rows; keep the shown lists when they did not change. + setLoaded((previous) => keepIfSame(previous, next)); + setFailed(null); + } catch (cause) { + if (!isCurrent()) return; + console.warn('Could not load history:', cause instanceof Error ? cause.message : cause); + setFailed(target); + } + }, [userId]); + + // Reload on focus, so a case deleted in its document or work saved on the website shows up. + // The scope follows the Current Debate, so wait for the debates instead of loading one-off work first. + const debatesKnown = status !== 'loading'; + useFocusEffect( + useCallback(() => { + if (debatesKnown) void load(scope); + }, [debatesKnown, load, scope]), + ); + + const shown = loaded?.scope === scope ? loaded : null; + const scopeDebate = debates.find((debate) => debate.id === scope) ?? null; + const scopeLabel = scope === OUTSIDE + ? t('debate.outsideDebates') + : `${scope === current?.id ? `${t('nav.currentDebate')}: ` : ''}${scopeDebate?.motion ?? ''}`; + + const remove = async (item: HistoryItem): Promise => { + const forUser = owner.current; + if (!forUser || item.kind !== 'case') return false; + const confirmed = await confirmDestructive({ + title: t('historyPage.deleteCaseTitle'), + message: t('historyPage.deleteCaseDesc'), + confirm: t('common.delete'), + cancel: t('common.cancel'), + }); + if (!confirmed || owner.current !== forUser) return false; + try { + await deleteResult(forUser, 'case', item.id); + } catch (cause) { + console.warn('Could not delete the case:', cause instanceof Error ? cause.message : cause); + if (live.current && owner.current === forUser) showNotice(t('historyPage.deleteFailed')); + return false; + } + if (!live.current || owner.current !== forUser) return true; + // Any load still running read the list before this delete. + loads.current += 1; + setLoaded((previous) => previous?.kind === 'debate' + ? { ...previous, lists: { ...previous.lists, cases: previous.lists.cases.filter((entry) => entry.id !== item.id) } } + : previous); + return true; + }; + + const attached = (entry: UnsortedEntry) => { + loads.current += 1; + setLoaded((previous) => previous?.kind === 'unsorted' + ? { ...previous, entries: previous.entries.filter((candidate) => !(candidate.kind === entry.kind && candidate.id === entry.id)) } + : previous); + // The debate's last activity moved; the switcher and the Debate tab show the new order. + void refreshDebates(); + }; + + return ( + + + + + {t('historyPage.title')} + + setScopeOpen(true)} /> + ({ + key: debate.id, + label: `${debate.id === current?.id ? `${t('nav.currentDebate')}: ` : ''}${debate.motion}`, + })), + { key: OUTSIDE, label: t('debate.outsideDebates') }, + ]} + cancelLabel={t('common.cancel')} + onClose={() => setScopeOpen(false)} + onSelect={setScopeChoice} + testID="history-scope-menu" + /> + + {scope !== OUTSIDE ? ( + + {HISTORY_TABS.map((entry) => ( + setTab(entry.key)} + testID={`history-tab-${entry.key}`} + /> + ))} + + ) : null} + + {failed === scope && !shown ? ( + + + {t('mobile.history.loadFailed')} + + void load(scope)} /> + + ) : !shown ? ( + + ) : shown.kind === 'unsorted' ? ( + + ) : ( + + )} + + + ); +} + +function ScopePicker({ label, onPress }: { label: string; onPress: () => void }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + return ( + [styles.scope, { backgroundColor: colors.card, borderColor: colors.border }, pressed && styles.pressed]}> + + + {t('debate.historyScope')} + + + {label} + + + + + ); +} + +function TabList({ tab, items, onDelete }: { tab: HistoryTab; items: HistoryItem[]; onDelete: (item: HistoryItem) => Promise }) { + const { t } = useTranslation(); + const router = useRouter(); + if (items.length === 0) { + const empty = EMPTY[tab]; + return ( + + + {t(empty.title)} + + + {t(empty.desc)} + + {empty.href ? router.push(empty.href!)} /> : null} + + ); + } + return ( + + {items.map((item, index) => { + const href = historyHref(item); + return ( + router.push(href) : undefined} + // The website deletes cases and Sparring rounds here; rounds wait for Sparring in the app. + onDelete={item.kind === 'case' ? () => onDelete(item) : undefined} + /> + ); + })} + + ); +} + +function itemMeta(t: ReturnType['t'], item: HistoryItem | UnsortedEntry): string { + const kind = t(`debate.kinds.${item.kind}`); + const when = formatWhen(item.createdAt); + if (item.kind === 'source') return t('mobile.debate.itemMeta', { kind, when }); + const parts: string[] = []; + if (item.side) parts.push(item.side.toUpperCase()); + if (item.messageCount !== undefined) parts.push(t('askChat.messageCount', { count: item.messageCount })); + if (item.sparring) { + parts.push(t(`sparring.format.${item.sparring.format}`, { defaultValue: item.sparring.format.toUpperCase() })); + parts.push(item.sparring.userSide === 'pro' ? t('export.proposition') : t('export.opposition')); + if (item.sparring.score) parts.push(t('historyPage.score', { user: item.sparring.score.user, ai: item.sparring.score.ai })); + } + return [t('mobile.debate.itemMeta', { kind, when }), ...parts].join(' · '); +} + +/** One saved result. Tapping opens it where the app can; a case swipes left to delete, with the website's confirmation. */ +function HistoryRow({ item, first, onPress, onDelete }: { item: HistoryItem; first: boolean; onPress?: () => void; onDelete?: () => Promise }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const swipeable = useRef(null); + const title = item.title || t(`debate.kinds.${item.kind}`); + + const remove = async () => { + const removed = await onDelete?.(); + if (!removed) swipeable.current?.close(); + }; + + const body = ( + { + if (event.nativeEvent.actionName === 'delete') void remove(); + }} + disabled={!onPress} + onPress={onPress} + testID="history-row" + style={({ pressed }) => [styles.row, { backgroundColor: colors.card }, pressed && styles.pressed]}> + + + + {title} + + + {itemMeta(t, item)} + + + {onPress ? : null} + + ); + + const divider = !first && { borderTopWidth: 1, borderTopColor: colors.line }; + if (!onDelete) return {body}; + return ( + ( + void remove()} + style={[styles.deleteAction, { backgroundColor: colors.destructive }]}> + + + {t('common.delete')} + + + )}> + {body} + + ); +} + +/** The website's UnsortedWork: one-off and older work outside every debate, each with "Add to debate". */ +function UnsortedList({ + entries, + debates, + userId, + onAttached, +}: { + entries: UnsortedEntry[]; + debates: Debate[]; + userId: string | null; + onAttached: (entry: UnsortedEntry) => void; +}) { + const { t } = useTranslation(); + const [notice, setNotice] = useState(null); + if (entries.length === 0) { + return ( + + + {t('debate.unsortedEmpty')} + + + {t('debate.unsortedEmptyDesc')} + + {notice ? ( + + {notice} + + ) : null} + + ); + } + return ( + <> + + {t('debate.unsortedDesc')} + + {notice ? ( + + {notice} + + ) : null} + {entries.map((entry) => ( + { + setNotice(t('debate.attached')); + onAttached(entry); + }} + /> + ))} + + ); +} + +function UnsortedRow({ entry, debates, userId, onAttached }: { entry: UnsortedEntry; debates: Debate[]; userId: string | null; onAttached: () => void }) { + const { t } = useTranslation(); + const router = useRouter(); + const { colors } = useTheme(); + const suggestion = suggestDebate(debates, entry.kind === 'source' ? [entry.title] : [entry.motion, entry.title]); + const [target, setTarget] = useState(suggestion?.id ?? ''); + const [picking, setPicking] = useState(false); + const [busy, setBusy] = useState(false); + const live = useRef(true); + useEffect(() => () => { + live.current = false; + }, []); + const chosen = debates.find((debate) => debate.id === target); + const href = entry.kind === 'source' ? null : historyHref(entry); + const title = entry.title || t(`debate.kinds.${entry.kind}`); + + const attach = async () => { + if (!chosen || !userId || busy) return; + setBusy(true); + try { + await attachToDebate(userId, entry, chosen.id); + if (live.current) onAttached(); + } catch (cause) { + console.warn('Could not add to the debate:', cause instanceof Error ? cause.message : cause); + if (live.current) showNotice(t('debate.attachFailed')); + } finally { + if (live.current) setBusy(false); + } + }; + + return ( + + + + + + {title} + + + {itemMeta(t, entry)} + + + + {href ? router.push(href)} /> : null} + {suggestion ? ( + + + + {t('debate.suggested')} + + + {suggestion.motion} + + ) : null} + {debates.length > 0 ? ( + <> + setPicking(true)} + disabled={busy} + testID="unsorted-target" + style={({ pressed }) => [styles.target, { borderColor: colors.border, backgroundColor: colors.input }, pressed && styles.pressed]}> + + {chosen?.motion ?? t('debate.chooseDebate')} + + + + ({ key: debate.id, label: debate.motion }))} + cancelLabel={t('common.cancel')} + onClose={() => setPicking(false)} + onSelect={setTarget} + /> + void attach()} /> + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1 }, + title: { fontSize: 32, lineHeight: 38, letterSpacing: -0.8 }, + body: { fontSize: 15, lineHeight: 21 }, + loading: { marginTop: 32 }, + pressed: { opacity: 0.7 }, + scope: { flexDirection: 'row', alignItems: 'center', gap: 12, borderWidth: 1, borderRadius: 14, paddingVertical: 11, paddingHorizontal: 14 }, + tabRow: { marginHorizontal: -20, flexGrow: 0 }, + tabs: { gap: 8, paddingHorizontal: 20 }, + list: { padding: 0, gap: 0, overflow: 'hidden' }, + row: { flexDirection: 'row', alignItems: 'center', gap: 13, paddingVertical: 12, paddingHorizontal: 14 }, + rowText: { flex: 1, gap: 2 }, + rowTitle: { fontSize: 16, lineHeight: 21 }, + meta: { fontSize: 14, lineHeight: 19 }, + deleteAction: { width: 92, alignItems: 'center', justifyContent: 'center', gap: 3 }, + deleteLabel: { color: '#ffffff', fontSize: 13 }, + unsortedHead: { flexDirection: 'row', alignItems: 'flex-start', gap: 13 }, + suggested: { flexDirection: 'row', alignItems: 'flex-start', gap: 8 }, + suggestedMotion: { flex: 1 }, + badge: { borderRadius: 999, paddingHorizontal: 9, paddingVertical: 3 }, + badgeText: { fontSize: 12.5 }, + target: { flexDirection: 'row', alignItems: 'center', gap: 10, borderWidth: 1, borderRadius: 12, paddingVertical: 11, paddingHorizontal: 13 }, + targetText: { flex: 1 }, +}); diff --git a/apps/mobile/src/components/work-row.tsx b/apps/mobile/src/components/work-row.tsx index 1b575ae63..8da75f698 100644 --- a/apps/mobile/src/components/work-row.tsx +++ b/apps/mobile/src/components/work-row.tsx @@ -22,8 +22,8 @@ export const KIND_ICONS: Record = { sparring: 'swords', }; -/** The tab a saved result's document goes back to. */ -export type OpenedFrom = 'debate' | 'prepare' | 'practice'; +/** The tab, or History, a saved result's document goes back to. */ +export type OpenedFrom = 'debate' | 'prepare' | 'practice' | 'history'; /** * Where a saved item opens. Cases, attacks, brainstorms and factoids open diff --git a/apps/mobile/src/lib/back-label.ts b/apps/mobile/src/lib/back-label.ts index c21bf44b5..bf6d6f584 100644 --- a/apps/mobile/src/lib/back-label.ts +++ b/apps/mobile/src/lib/back-label.ts @@ -1,7 +1,9 @@ import type { TFunction } from 'i18next'; -/** The back button names the tab a document was opened from, as mock 03's "Prepare". */ +/** The back button names the tab a document was opened from, as mock 03's "Prepare", or History. */ export function backLabel(t: TFunction, from: string | undefined, fallback: 'debate' | 'prepare' | 'practice'): string { + if (from === 'history') return t('historyPage.title'); + if (from === 'you') return t('mobile.tabs.you'); const tab = from === 'debate' || from === 'prepare' || from === 'practice' ? from : from ? null : fallback; return tab ? t(`mobile.tabs.${tab}`) : t('common.back'); } diff --git a/apps/mobile/src/lib/history.ts b/apps/mobile/src/lib/history.ts new file mode 100644 index 000000000..baaab2115 --- /dev/null +++ b/apps/mobile/src/lib/history.ts @@ -0,0 +1,208 @@ +import { SparringScorecardSchema, SparringSpeechSchema } from '@debatelab/core/ai/schemas/sparring'; + +import type { DebateItem, DebateItemKind } from './debates'; +import { supabase } from './supabase'; + +/** + * The website's History page (src/pages/HistoryPage.tsx) and its unsorted + * view (src/components/features/debate/UnsortedWork.tsx), read from the same + * tables. A debate shows six lists; work outside every debate is one list of + * every kind, plus Library sources, that can be added to a debate. + */ + +export type HistoryTab = 'cases' | 'chats' | 'attacks' | 'brainstorms' | 'factoids' | 'sparring'; + +/** The website's tabs, in its order. */ +export const HISTORY_TABS: readonly { key: HistoryTab; kind: DebateItemKind }[] = [ + { key: 'cases', kind: 'case' }, + { key: 'chats', kind: 'chat' }, + { key: 'attacks', kind: 'attack' }, + { key: 'brainstorms', kind: 'brainstorm' }, + { key: 'factoids', kind: 'factoids' }, + { key: 'sparring', kind: 'sparring' }, +]; + +export interface HistoryItem extends DebateItem { + /** The side a case was generated for, as the website's case badge reads it. */ + side?: string; + messageCount?: number; + sparring?: { format: string; userSide: 'pro' | 'con'; score: { user: number; ai: number } }; + /** The motion saved with the work, for the suggested debate. */ + motion?: string; +} + +export type HistoryLists = Record; + +/** One-off work, or a Library source, outside every debate. */ +export type UnsortedEntry = HistoryItem | { kind: 'source'; id: string; title: string; createdAt: number }; + +/** The website keeps the 50 newest rounds (MAX_SAVED_SESSIONS in its sparring store). */ +const MAX_ROUNDS = 50; + +const TABLES: Record, string> = { + case: 'cases', + attack: 'attacks', + brainstorm: 'brainstorms', + factoids: 'factoids', + chat: 'chats', + flowchart: 'flowcharts', + crossfire: 'crossfires', +}; + +/** Tables whose saved output may carry the motion, read where the website reads it: output.metadata.motion. */ +const WITH_OUTPUT = new Set(['cases', 'attacks', 'brainstorms', 'factoids', 'crossfires']); + +const time = (value: string) => new Date(value).getTime(); + +/** + * Every read below filters on the user. Row level security is the real + * boundary; the filter keeps a wrong session from mixing accounts. + */ + +type Scope = { debateId: string } | { unsorted: true }; + +function inScope Q; is: (column: string, value: null) => Q }>(query: Q, scope: Scope): Q { + return 'debateId' in scope ? query.eq('debate_id', scope.debateId) : query.is('debate_id', null); +} + +async function fetchRows(userId: string, kind: Exclude, scope: Scope): Promise { + const table = TABLES[kind]; + const columns = [ + 'id,title,created_at', + ...(WITH_OUTPUT.has(table) ? ['motion:output->metadata->>motion'] : []), + ...(kind === 'case' ? ['case_side:output->metadata->>side'] : []), + ].join(','); + const query = supabase.from(table).select(columns).eq('user_id', userId); + const { data, error } = await inScope(query, scope).order('created_at', { ascending: false }); + if (error) throw new Error(error.message); + return ((data ?? []) as unknown as { id: string; title: string; created_at: string; motion?: string | null; case_side?: string | null }[]).map((row) => ({ + id: row.id, + kind, + title: row.title, + createdAt: time(row.created_at), + ...(row.motion ? { motion: row.motion } : {}), + ...(row.case_side ? { side: row.case_side } : {}), + })); +} + +/** Chats with their message count, as the website's chat list shows them. */ +async function fetchChatRows(userId: string, scope: Scope): Promise { + const query = supabase.from('chats').select('id,title,created_at,messages(count)').eq('user_id', userId); + const { data, error } = await inScope(query, scope).order('created_at', { ascending: false }); + if (error) throw new Error(error.message); + return ((data ?? []) as { id: string; title: string; created_at: string; messages: { count: number }[] }[]).map((row) => ({ + id: row.id, + kind: 'chat', + title: row.title, + createdAt: time(row.created_at), + messageCount: row.messages[0]?.count ?? 0, + })); +} + +interface RoundRow { + id: string; + motion: string; + user_side: string; + format: string; + turns: unknown; + final_evaluation: unknown; + updated_at: string; +} + +/** + * Finished Sparring rounds, as the website's sparring store keeps them: a + * round counts only when it is complete and its speeches and scorecard read. + */ +async function fetchRounds(userId: string, scope: Scope): Promise { + const query = supabase + .from('sparring_sessions') + .select('id,motion,user_side,format,turns,final_evaluation,status,updated_at') + .eq('user_id', userId) + .eq('status', 'complete'); + const { data, error } = await inScope(query, scope).order('updated_at', { ascending: false }).limit(MAX_ROUNDS); + if (error) throw new Error(error.message); + return ((data ?? []) as RoundRow[]).flatMap((row): HistoryItem[] => { + const turns = Array.isArray(row.turns) && row.turns.length <= 12 ? row.turns : null; + const scorecard = SparringScorecardSchema.safeParse(row.final_evaluation); + const side = row.user_side === 'pro' || row.user_side === 'con' ? row.user_side : null; + if (!side || !turns?.every((turn) => SparringSpeechSchema.safeParse(turn).success) || !scorecard.success) return []; + return [{ + id: row.id, + kind: 'sparring', + title: row.motion, + motion: row.motion, + createdAt: time(row.updated_at), + sparring: { + format: row.format, + userSide: side, + score: { user: scorecard.data.userScore, ai: scorecard.data.aiScore }, + }, + }]; + }); +} + +/** The six lists of one debate. */ +export async function fetchDebateHistory(userId: string, debateId: string): Promise { + const scope = { debateId }; + const [cases, chats, attacks, brainstorms, factoids, sparring] = await Promise.all([ + fetchRows(userId, 'case', scope), + fetchChatRows(userId, scope), + fetchRows(userId, 'attack', scope), + fetchRows(userId, 'brainstorm', scope), + fetchRows(userId, 'factoids', scope), + fetchRounds(userId, scope), + ]); + return { cases, chats, attacks, brainstorms, factoids, sparring }; +} + +/** Work of every kind and Library sources that belong to no debate, newest first. */ +export async function fetchUnsortedWork(userId: string): Promise { + const scope = { unsorted: true } as const; + const lists = await Promise.all([ + fetchRows(userId, 'case', scope), + fetchRows(userId, 'attack', scope), + fetchRows(userId, 'brainstorm', scope), + fetchRows(userId, 'factoids', scope), + fetchChatRows(userId, scope), + fetchRows(userId, 'flowchart', scope), + fetchRows(userId, 'crossfire', scope), + fetchRounds(userId, scope), + (async (): Promise => { + const { data, error } = await supabase + .from('sources') + .select('id,title,created_at') + .eq('user_id', userId) + .is('debate_id', null) + .order('created_at', { ascending: false }); + if (error) throw new Error(error.message); + return ((data ?? []) as { id: string; title: string; created_at: string }[]).map((row) => ({ + kind: 'source' as const, + id: row.id, + title: row.title, + createdAt: time(row.created_at), + })); + })(), + ]); + return lists.flat().sort((a, b) => b.createdAt - a.createdAt); +} + +/** + * Adds one-off work or a source to one of the student's debates through the + * website's attach_to_debate function, as the session store's attachItem and + * the sparring store's attachSessionToDebate do. The function checks that the + * debate and the item belong to the signed-in account and that the item is in + * no debate yet. It acts for whoever holds the session, so the session must + * still be the account the screen showed this work for. + */ +export async function attachToDebate(userId: string, entry: UnsortedEntry, debateId: string): Promise { + const { data } = await supabase.auth.getSession(); + if (data.session?.user.id !== userId) throw new Error('The signed-in account changed'); + const { error } = await supabase.rpc('attach_to_debate', { + p_kind: entry.kind, + p_id: entry.id, + p_debate_id: debateId, + // A saved round keeps the side the student spoke, as attachSessionToDebate passes it. + ...(entry.kind === 'sparring' && 'sparring' in entry && entry.sparring ? { p_side: entry.sparring.userSide } : {}), + }); + if (error) throw new Error(error.message); +} diff --git a/apps/mobile/src/lib/motion.ts b/apps/mobile/src/lib/motion.ts index 861826203..764593435 100644 --- a/apps/mobile/src/lib/motion.ts +++ b/apps/mobile/src/lib/motion.ts @@ -14,6 +14,11 @@ export function sameMotion(a: string | undefined | null, b: string | undefined | return Boolean(a && b) && normalizeMotion(a!) === normalizeMotion(b!); } +/** The website's suggestDebate (UnsortedWork.tsx): a debate only on an exact motion or title match; the student still confirms. */ +export function suggestDebate(debates: Debate[], candidates: (string | undefined)[]): Debate | undefined { + return debates.find((debate) => candidates.some((candidate) => sameMotion(debate.motion, candidate))); +} + export type MotionDecision = | { kind: 'attach'; debateId: string } | { kind: 'one-off' } diff --git a/src/locales/en.json b/src/locales/en.json index b995e085e..4464db3f3 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2422,7 +2422,8 @@ "pickUp": "Pick up where you left off", "itemMeta": "{{kind}}, {{when}}", "loadFailed": "Could not load your debate.", - "viewAria": "Show preparation for" + "viewAria": "Show preparation for", + "seeAll": "See all" }, "common": { "done": "Done" @@ -2442,7 +2443,8 @@ "you": { "usage": "{{used}} of {{limit}} AI uses today", "loadFailed": "Could not load your subscription.", - "saveFailed": "Could not save your settings. Try again." + "saveFailed": "Could not save your settings. Try again.", + "historyDesc": "Everything you saved, by debate" }, "deleteAccount": { "failedHint": "Your account is still here. Check your connection and try again." @@ -2619,6 +2621,9 @@ "discardTitle": "Start a new map?", "discardDesc": "Your unsaved map will be replaced by the starter map.", "startNew": "Start new" + }, + "history": { + "loadFailed": "Could not load your history." } } } diff --git a/src/locales/es.json b/src/locales/es.json index 99eb6035b..c47ff2019 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2422,7 +2422,8 @@ "pickUp": "Continúa donde lo dejaste", "itemMeta": "{{kind}}, {{when}}", "loadFailed": "No se pudo cargar tu debate.", - "viewAria": "Mostrar la preparación de" + "viewAria": "Mostrar la preparación de", + "seeAll": "Ver todo" }, "common": { "done": "Listo" @@ -2442,7 +2443,8 @@ "you": { "usage": "{{used}} de {{limit}} usos de IA hoy", "loadFailed": "No se pudo cargar tu suscripción.", - "saveFailed": "No se pudieron guardar tus ajustes. Inténtalo de nuevo." + "saveFailed": "No se pudieron guardar tus ajustes. Inténtalo de nuevo.", + "historyDesc": "Todo lo que guardaste, por debate" }, "deleteAccount": { "failedHint": "Tu cuenta sigue activa. Comprueba tu conexión e inténtalo de nuevo." @@ -2619,6 +2621,9 @@ "discardTitle": "¿Empezar un mapa nuevo?", "discardDesc": "Tu mapa sin guardar se sustituirá por el mapa inicial.", "startNew": "Empezar nuevo" + }, + "history": { + "loadFailed": "No se pudo cargar tu historial." } } } diff --git a/src/locales/hu.json b/src/locales/hu.json index aa83b303b..4f5ee5511 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2422,7 +2422,8 @@ "pickUp": "Folytasd, ahol abbahagytad", "itemMeta": "{{kind}}, {{when}}", "loadFailed": "Nem sikerült betölteni a vitát.", - "viewAria": "Felkészülés megjelenítése" + "viewAria": "Felkészülés megjelenítése", + "seeAll": "Összes" }, "common": { "done": "Kész" @@ -2442,7 +2443,8 @@ "you": { "usage": "Ma {{used}} / {{limit}} MI-használat", "loadFailed": "Nem sikerült betölteni az előfizetésedet.", - "saveFailed": "Nem sikerült menteni a beállításokat. Próbáld újra." + "saveFailed": "Nem sikerült menteni a beállításokat. Próbáld újra.", + "historyDesc": "Minden mentett munkád, vitánként" }, "deleteAccount": { "failedHint": "A fiókod megmaradt. Ellenőrizd a kapcsolatot, és próbáld újra." @@ -2619,6 +2621,9 @@ "discardTitle": "Új térképet kezdesz?", "discardDesc": "A mentetlen térképed helyére a kezdő térkép kerül.", "startNew": "Új kezdése" + }, + "history": { + "loadFailed": "Nem sikerült betölteni az előzményeidet." } } } From cb130dc5913aad717b7a05c696c90c58267ea658 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 12:44:31 +0000 Subject: [PATCH 02/12] Show announcements on the Debate tab The app lists the account's active announcements through the announcements Edge Function and shows them one at a time at the top of the Debate tab, with the website's position counter and dismiss. Dismissals are stored on the server, so they are shared with the website. The startsAt/expiresAt filter and the response schema move to packages/core so both clients use one copy; the website's behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 1 + apps/mobile/src/app/(tabs)/(debate)/index.tsx | 12 +- .../src/components/announcement-banner.tsx | 69 +++++++++ apps/mobile/src/components/icon-paths.ts | 1 + apps/mobile/src/lib/announcements.ts | 60 ++++++++ apps/mobile/src/lib/use-announcements.ts | 135 ++++++++++++++++++ packages/core/src/announcements.ts | 46 ++++++ src/components/layout/AppLayout.tsx | 9 +- src/lib/announcements.ts | 42 ++---- 9 files changed, 332 insertions(+), 43 deletions(-) create mode 100644 apps/mobile/src/components/announcement-banner.tsx create mode 100644 apps/mobile/src/lib/announcements.ts create mode 100644 apps/mobile/src/lib/use-announcements.ts create mode 100644 packages/core/src/announcements.ts diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index a09b5545e..71ef0ac8a 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -28,6 +28,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp - Crossfire is the website's typed mode: `crossfire.ts` sends `crossfire` and its signal requests as `src/lib/ai/client.ts` does (Ask's model settings, 75 s and 5 s deadlines) and saves a finished round to `crossfires` in its debate, as CrossfirePage's `recordResult` call; the reducer and schemas are `packages/core/src/crossfire`. The unfinished round is a session slot, so leaving the screen keeps it. Voice rounds need the development build, so the voice switch shows disabled. The website cannot reopen a saved round, so neither can the app. - 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`). - History mirrors `src/pages/HistoryPage.tsx` and `UnsortedWork.tsx`: `history.ts` reads each table for one debate, or every kind plus Library sources outside all debates, and adds unsorted work to a debate through the website's `attach_to_debate` function. Chats reopen in Ask (`/ask?chat=`). Sparring rounds are listed read-only until Sparring is in the app. +- Announcements mirror the website's banner (`AppLayout.tsx`, `AnnouncementBanner.tsx`) at the top of the Debate tab: `announcements.ts` lists and dismisses through the `announcements` Edge Function, and `use-announcements.ts` keeps the website's startsAt/expiresAt filter (shared in `packages/core/src/announcements.ts`), its one-minute reload guard, and reloads on focus, foreground and account change. Dismissals are stored on the server, so they are shared with the website. - 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 and Crossfire 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. - `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. diff --git a/apps/mobile/src/app/(tabs)/(debate)/index.tsx b/apps/mobile/src/app/(tabs)/(debate)/index.tsx index 15b170b41..39846a7cf 100644 --- a/apps/mobile/src/app/(tabs)/(debate)/index.tsx +++ b/apps/mobile/src/app/(tabs)/(debate)/index.tsx @@ -4,6 +4,7 @@ import { useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; +import { AnnouncementBanner } from '@/components/announcement-banner'; import { MainActionButton, TextButton } from '@/components/buttons'; import { Card, IconTile } from '@/components/card'; import { Icon } from '@/components/icon'; @@ -23,6 +24,7 @@ import { type DebateItem, type DebateSide, } from '@/lib/debates'; +import { useAnnouncements } from '@/lib/use-announcements'; import { mainActionGradient, radius } from '@/theme/colors'; import { useTheme } from '@/theme/use-theme'; @@ -33,6 +35,9 @@ export default function DebateScreen() { const { t } = useTranslation(); const router = useRouter(); const { status, current, refresh } = useDebates(); + // The website's announcement banner, at the top of the Debate tab. + const announcements = useAnnouncements(); + const refreshAnnouncements = announcements.refresh; // Debates switched on the website show up when the tab comes back into view. const focusedBefore = useRef(false); @@ -40,7 +45,8 @@ export default function DebateScreen() { useCallback(() => { if (focusedBefore.current) void refresh(); focusedBefore.current = true; - }, [refresh]), + refreshAnnouncements(); + }, [refresh, refreshAnnouncements]), ); const openSwitcher = () => router.push('/debates'); @@ -56,6 +62,7 @@ export default function DebateScreen() { if (status === 'error') { return ( + {t('mobile.debate.loadFailed')} @@ -69,6 +76,7 @@ export default function DebateScreen() { if (!current) { return ( + @@ -86,6 +94,7 @@ export default function DebateScreen() { return ( + @@ -336,6 +345,7 @@ const styles = StyleSheet.create({ sparText: { flex: 1, gap: 2 }, sparTitle: { color: '#ffffff', fontSize: 18, lineHeight: 23 }, sparLine: { color: 'rgba(255,255,255,0.88)', fontSize: 14.5, lineHeight: 20 }, + banner: { marginBottom: 14 }, section: { fontSize: 18.5, lineHeight: 24, letterSpacing: -0.2, marginTop: 20, marginBottom: 9, marginHorizontal: 2 }, sectionRow: { flexDirection: 'row', alignItems: 'center', gap: 12, marginTop: 10, marginRight: 2 }, sectionTitle: { flex: 1, marginTop: 0, marginBottom: 0 }, diff --git a/apps/mobile/src/components/announcement-banner.tsx b/apps/mobile/src/components/announcement-banner.tsx new file mode 100644 index 000000000..5f06d5a1d --- /dev/null +++ b/apps/mobile/src/components/announcement-banner.tsx @@ -0,0 +1,69 @@ +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, Pressable, StyleSheet, View, type ViewStyle } from 'react-native'; + +import { IconTile } from './card'; +import { Icon } from './icon'; +import { Text } from './text'; + +import type { Announcements } from '@/lib/use-announcements'; +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +/** The website's AnnouncementBanner: one announcement at a time, its position, and a dismiss button. */ +export function AnnouncementBanner({ announcements, style }: { announcements: Announcements; style?: ViewStyle }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const { announcement, total, dismissing, dismissFailed, dismiss } = announcements; + if (!announcement) return null; + return ( + + + + + + {announcement.title} + + {total > 1 ? ( + + {t('announcements.position', { position: 1, total })} + + ) : null} + + + {announcement.body} + + {dismissFailed ? ( + + {t('announcements.dismissError')} + + ) : null} + + [styles.dismiss, pressed && styles.pressed]}> + {dismissing ? : } + + + ); +} + +const styles = StyleSheet.create({ + banner: { flexDirection: 'row', alignItems: 'flex-start', gap: 12, borderWidth: 1, borderRadius: radius.card, padding: 14 }, + text: { flex: 1, gap: 3 }, + titleRow: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'baseline', columnGap: 8 }, + title: { fontSize: 16, lineHeight: 21 }, + position: { fontSize: 13 }, + body: { fontSize: 14.5, lineHeight: 20 }, + dismiss: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center', marginTop: -4, marginRight: -6 }, + pressed: { opacity: 0.6 }, +}); diff --git a/apps/mobile/src/components/icon-paths.ts b/apps/mobile/src/components/icon-paths.ts index c74077206..43065e2ca 100644 --- a/apps/mobile/src/components/icon-paths.ts +++ b/apps/mobile/src/components/icon-paths.ts @@ -50,6 +50,7 @@ export const iconPaths = { volumeOff: '', send: '', mic: '', + megaphone: '', alert: '', } as const; diff --git a/apps/mobile/src/lib/announcements.ts b/apps/mobile/src/lib/announcements.ts new file mode 100644 index 000000000..c04281ed0 --- /dev/null +++ b/apps/mobile/src/lib/announcements.ts @@ -0,0 +1,60 @@ +import { ActiveAnnouncementsResponseSchema, normalizeAnnouncement, type UserAnnouncement } from '@debatelab/core/announcements'; +import { fetch } from 'expo/fetch'; + +import { abortError, payloadOf, untilAborted } from './ai-request'; +import { supabase } from './supabase'; +import { supabaseProject } from './supabase-config'; + +/** + * The website's src/lib/announcements.ts for students: list the active + * announcements and dismiss one, through the `announcements` Edge Function. + * Dismissals are stored on the server, so they are shared with the website. + */ + +const REQUEST_TIMEOUT_MS = 15_000; + +/** One request with the app's function headers. The deadline and the caller's abort start before the session read. */ +async function requestAnnouncements( + method: 'GET' | 'POST', + action: string | null, + body: unknown, + signal: AbortSignal | undefined, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + const abortForCaller = () => controller.abort(); + signal?.addEventListener('abort', abortForCaller); + if (signal?.aborted) controller.abort(); + try { + if (controller.signal.aborted) throw abortError(); + const { data } = await untilAborted(supabase.auth.getSession(), controller.signal); + const token = data.session?.access_token; + if (!token) throw new Error('Not authenticated'); + const query = action ? `?action=${encodeURIComponent(action)}` : ''; + const response = await fetch(`${supabaseProject.url}/functions/v1/announcements${query}`, { + method, + headers: { Authorization: `Bearer ${token}`, apikey: supabaseProject.anonKey, 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal, + }); + const payload = await payloadOf(response); + if (!response.ok) throw new Error(typeof payload?.error === 'string' ? payload.error : `Request failed: ${response.status}`); + return payload ?? {}; + } catch (error) { + if (signal?.aborted) throw abortError(); + if (controller.signal.aborted) throw new Error('The request took too long'); + throw error; + } finally { + clearTimeout(timeout); + signal?.removeEventListener('abort', abortForCaller); + } +} + +export async function listActiveAnnouncements(signal?: AbortSignal): Promise { + const payload = await requestAnnouncements('GET', null, undefined, signal); + return ActiveAnnouncementsResponseSchema.parse(payload).announcements.map(normalizeAnnouncement); +} + +export async function dismissAnnouncement(announcementId: string): Promise { + await requestAnnouncements('POST', 'dismiss', { announcementId }, undefined); +} diff --git a/apps/mobile/src/lib/use-announcements.ts b/apps/mobile/src/lib/use-announcements.ts new file mode 100644 index 000000000..339777d36 --- /dev/null +++ b/apps/mobile/src/lib/use-announcements.ts @@ -0,0 +1,135 @@ +import { filterVisibleAnnouncements, type UserAnnouncement } from '@debatelab/core/announcements'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { AppState } from 'react-native'; + +import { dismissAnnouncement, listActiveAnnouncements } from './announcements'; +import { useAuth } from './auth'; + +/** The website reloads at most once a minute on navigation (ANNOUNCEMENTS_STALE_MS in AppLayout.tsx). */ +const STALE_MS = 60_000; + +export interface Announcements { + /** The first visible announcement, shown one at a time as on the website. */ + announcement: UserAnnouncement | null; + total: number; + dismissing: boolean; + dismissFailed: boolean; + dismiss: () => void; + /** Reloads unless the last load is under a minute old; the Debate tab calls it on focus. */ + refresh: () => void; +} + +/** + * The website's announcement banner state (AppLayout.tsx): the signed-in + * account's active announcements, filtered by startsAt and expiresAt, reloaded + * when the app comes to the foreground and on account change. Nothing from an + * earlier account, or from before leaving, lands. + */ +export function useAnnouncements(): Announcements { + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const [items, setItems] = useState([]); + const [dismissingId, setDismissingId] = useState(null); + const [failedId, setFailedId] = useState(null); + const owner = useRef(userId); + // Bumped on every owner change and on unmount, so work started before either never lands. + const generation = useRef(0); + const active = useRef(null); + const lastRequestAt = useRef(null); + // Dismissed here, so a load that started before the dismissal cannot bring it back. + const dismissed = useRef(new Set()); + + const load = useCallback(async (force: boolean) => { + if (!owner.current) return; + const gen = generation.current; + const requestedAt = Date.now(); + if (!force && lastRequestAt.current !== null && requestedAt - lastRequestAt.current < STALE_MS) { + setItems((current) => filterVisibleAnnouncements(current, requestedAt)); + return; + } + active.current?.abort(); + const controller = new AbortController(); + active.current = controller; + lastRequestAt.current = requestedAt; + try { + const list = await listActiveAnnouncements(controller.signal); + if (controller.signal.aborted || generation.current !== gen) return; + setItems(filterVisibleAnnouncements(list.filter((item) => !dismissed.current.has(item.id)), Date.now())); + } catch (cause) { + if (controller.signal.aborted || generation.current !== gen || active.current !== controller) return; + lastRequestAt.current = null; + console.warn('Could not load announcements:', cause instanceof Error ? cause.message : cause); + setItems([]); + } finally { + if (active.current === controller) active.current = null; + } + }, []); + + useEffect(() => { + owner.current = userId; + generation.current += 1; + active.current?.abort(); + active.current = null; + lastRequestAt.current = null; + dismissed.current.clear(); + setItems([]); + setDismissingId(null); + setFailedId(null); + if (!userId) return; + void load(true); + const subscription = AppState.addEventListener('change', (state) => { + if (state === 'active') void load(false); + }); + return () => { + subscription.remove(); + generation.current += 1; + active.current?.abort(); + }; + }, [userId, load]); + + // An announcement leaves the moment it expires, as on the website. + useEffect(() => { + const nextExpiry = Math.min(...items.map((item) => (item.expiresAt ? new Date(item.expiresAt).getTime() : Infinity))); + if (!Number.isFinite(nextExpiry)) return; + const timer = setTimeout( + () => setItems((current) => filterVisibleAnnouncements(current, Date.now())), + Math.min(Math.max(0, nextExpiry - Date.now()), 2_147_483_647), + ); + return () => clearTimeout(timer); + }, [items]); + + const announcement = items[0] ?? null; + + const dismiss = useCallback(() => { + if (!announcement) return; + const id = announcement.id; + const gen = generation.current; + setDismissingId(id); + setFailedId(null); + void dismissAnnouncement(id) + .then(() => { + if (generation.current !== gen) return; + dismissed.current.add(id); + setItems((current) => current.filter((item) => item.id !== id)); + }) + .catch((cause: unknown) => { + if (generation.current !== gen) return; + console.warn('Could not dismiss the announcement:', cause instanceof Error ? cause.message : cause); + setFailedId(id); + }) + .finally(() => { + if (generation.current === gen) setDismissingId((current) => (current === id ? null : current)); + }); + }, [announcement]); + + const refresh = useCallback(() => void load(false), [load]); + + return { + announcement, + total: items.length, + dismissing: announcement !== null && dismissingId === announcement.id, + dismissFailed: announcement !== null && failedId === announcement.id, + dismiss, + refresh, + }; +} diff --git a/packages/core/src/announcements.ts b/packages/core/src/announcements.ts new file mode 100644 index 000000000..986f76e53 --- /dev/null +++ b/packages/core/src/announcements.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; + +/** An announcement as the `announcements` Edge Function lists it to a student. Shared by the website and the app. */ +export interface UserAnnouncement { + id: string; + title: string; + body: string; + startsAt: string; + expiresAt: string | null; + createdAt: string; +} + +export const AnnouncementRowSchema = z.object({ + id: z.uuid(), + title: z.string(), + body: z.string(), + starts_at: z.iso.datetime({ offset: true }), + expires_at: z.iso.datetime({ offset: true }).nullable(), + created_at: z.iso.datetime({ offset: true }), +}); + +export const ActiveAnnouncementsResponseSchema = z.object({ + announcements: z.array(AnnouncementRowSchema), +}); + +export type AnnouncementRow = z.infer; + +export function normalizeAnnouncement(row: AnnouncementRow): UserAnnouncement { + return { + id: row.id, + title: row.title, + body: row.body, + startsAt: row.starts_at, + expiresAt: row.expires_at, + createdAt: row.created_at, + }; +} + +/** The announcements that have started and not yet expired at `now`. */ +export function filterVisibleAnnouncements(announcements: UserAnnouncement[], now: number) { + return announcements.filter((announcement) => { + const startsAt = new Date(announcement.startsAt).getTime(); + const expiresAt = announcement.expiresAt ? new Date(announcement.expiresAt).getTime() : Infinity; + return startsAt <= now && expiresAt > now; + }); +} diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index a00e02ee4..ae478f60e 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -26,6 +26,7 @@ import { listActiveAnnouncements, type UserAnnouncement, } from '@/lib/announcements'; +import { filterVisibleAnnouncements } from '@debatelab/core/announcements'; // Features live on Home and the Current Debate dashboard, not in the header. const libraryNavItems = [ @@ -47,14 +48,6 @@ const desktopLinkClass = cn( const ANNOUNCEMENTS_STALE_MS = 60_000; -function filterVisibleAnnouncements(announcements: UserAnnouncement[], now: number) { - return announcements.filter((announcement) => { - const startsAt = new Date(announcement.startsAt).getTime(); - const expiresAt = announcement.expiresAt ? new Date(announcement.expiresAt).getTime() : Infinity; - return startsAt <= now && expiresAt > now; - }); -} - export function AppLayout() { const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [announcements, setAnnouncements] = useState([]); diff --git a/src/lib/announcements.ts b/src/lib/announcements.ts index 75a886775..71fa71a70 100644 --- a/src/lib/announcements.ts +++ b/src/lib/announcements.ts @@ -1,16 +1,15 @@ import { supabase, SUPABASE_URL } from '@/lib/supabase/client'; import { z } from 'zod'; +import { + ActiveAnnouncementsResponseSchema, + AnnouncementRowSchema, + normalizeAnnouncement, + type UserAnnouncement, +} from '@debatelab/core/announcements'; -export type AnnouncementTargetType = 'all' | 'users'; +export type { UserAnnouncement }; -export interface UserAnnouncement { - id: string; - title: string; - body: string; - startsAt: string; - expiresAt: string | null; - createdAt: string; -} +export type AnnouncementTargetType = 'all' | 'users'; export interface AdminAnnouncement extends UserAnnouncement { targetType: AnnouncementTargetType; @@ -28,15 +27,6 @@ export interface CreateAnnouncementInput { expiresAt?: string; } -const AnnouncementRowSchema = z.object({ - id: z.uuid(), - title: z.string(), - body: z.string(), - starts_at: z.iso.datetime({ offset: true }), - expires_at: z.iso.datetime({ offset: true }).nullable(), - created_at: z.iso.datetime({ offset: true }), -}); - const AdminAnnouncementRowSchema = AnnouncementRowSchema.extend({ target_type: z.enum(['all', 'users']), target_user_ids: z.array(z.uuid()).nullable(), @@ -45,10 +35,6 @@ const AdminAnnouncementRowSchema = AnnouncementRowSchema.extend({ created_by: z.uuid().nullable(), }); -const ActiveAnnouncementsResponseSchema = z.object({ - announcements: z.array(AnnouncementRowSchema), -}); - const AdminAnnouncementsResponseSchema = z.object({ announcements: z.array(AdminAnnouncementRowSchema), }); @@ -61,7 +47,6 @@ const DeactivateAnnouncementResponseSchema = z.object({ announcement: AdminAnnouncementRowSchema, }); -type AnnouncementRow = z.infer; type AdminAnnouncementRow = z.infer; async function requestFunction({ @@ -110,17 +95,6 @@ async function requestFunction({ return payload; } -function normalizeAnnouncement(row: AnnouncementRow): UserAnnouncement { - return { - id: row.id, - title: row.title, - body: row.body, - startsAt: row.starts_at, - expiresAt: row.expires_at, - createdAt: row.created_at, - }; -} - function normalizeAdminAnnouncement(row: AdminAnnouncementRow): AdminAnnouncement { return { ...normalizeAnnouncement(row), From fd91174701a5894b8c4897fd904acccbb825983a Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 12:47:43 +0000 Subject: [PATCH 03/12] Add data download, delete all data, Terms and Privacy to You Download my data asks the account function for its export, as the website does, and shares it as debatelab-data-.json. Delete all my data runs clear_current_user_data after the website's email-typed confirmation in a form sheet, then forgets this session's results and drafts, turns one-off mode off and reloads the debates. Both account-wide writes refuse to run once a different account holds the session. Terms and Privacy open the public website's pages in the in-app browser. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 3 +- apps/mobile/app.json | 3 + apps/mobile/src/app/(tabs)/(you)/you.tsx | 99 ++++++++++++++++++- apps/mobile/src/app/_layout.tsx | 1 + apps/mobile/src/app/delete-data.tsx | 119 +++++++++++++++++++++++ apps/mobile/src/lib/account.ts | 41 ++++++-- apps/mobile/src/lib/export.ts | 8 ++ apps/mobile/src/lib/export.web.ts | 7 +- apps/mobile/src/lib/owner-check.test.ts | 35 +++++++ apps/mobile/src/lib/session-memory.ts | 5 + apps/mobile/src/lib/website.ts | 12 +++ src/locales/en.json | 3 +- src/locales/es.json | 3 +- src/locales/hu.json | 3 +- 14 files changed, 327 insertions(+), 15 deletions(-) create mode 100644 apps/mobile/src/app/delete-data.tsx create mode 100644 apps/mobile/src/lib/owner-check.test.ts create mode 100644 apps/mobile/src/lib/website.ts diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 71ef0ac8a..2f732a576 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -17,7 +17,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp ## Layout -- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; the form sheets `debates.tsx` (switcher), `new-debate.tsx`, `delete-account.tsx`, `add-link.tsx`, `new-note.tsx` and `source/[id].tsx`; and `ask.tsx`, `generate.tsx`, `case/[id].tsx`, `attack/`, `brainstorm/` and `factoids/` (each a form `index.tsx` and a document `[id].tsx`), `crossfire.tsx` and `timer.tsx` (a full-screen modal), `flowchart/` (the screen `index.tsx` with its `card`, `claim` and `maps` form sheets), and `history.tsx`, full screen above the tabs. All are declared in the root `_layout.tsx`. `(practice)/soon/[tool].tsx` stands in for Practice tools not in the app yet. +- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; the form sheets `debates.tsx` (switcher), `new-debate.tsx`, `delete-account.tsx`, `delete-data.tsx`, `add-link.tsx`, `new-note.tsx` and `source/[id].tsx`; and `ask.tsx`, `generate.tsx`, `case/[id].tsx`, `attack/`, `brainstorm/` and `factoids/` (each a form `index.tsx` and a document `[id].tsx`), `crossfire.tsx` and `timer.tsx` (a full-screen modal), `flowchart/` (the screen `index.tsx` with its `card`, `claim` and `maps` form sheets), and `history.tsx`, full screen above the tabs. All are declared in the root `_layout.tsx`. `(practice)/soon/[tool].tsx` stands in for Practice tools not in the app yet. - `src/components/`: shared UI. `app-tabs.tsx` uses native tabs; `app-tabs.web.tsx` draws the mock's tab bar for the web preview. - `src/lib/`: auth (`auth.tsx`), the Supabase client, session storage (AES key in SecureStore, ciphertext in AsyncStorage) and i18n. `debates.ts`, `settings.tsx`, `subscription.ts` and `account.ts` read and write the website's tables the way its stores do (`src/lib/stores/debate-store.ts`, `settings-store.ts`, `src/hooks/use-subscription.ts`, `src/lib/account.ts`), so the app and the website share one Current Debate and one set of settings. Change them together with the website code they mirror. - `sources.ts` mirrors the session store's `addSource` and `removeSource` and `src/lib/source-import.ts`. The Library always shows the Current Debate, also in one-off mode, as on the website. Adding a file (the website parses files in the browser) and Scan pages are not in the app yet. @@ -29,6 +29,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp - 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`). - History mirrors `src/pages/HistoryPage.tsx` and `UnsortedWork.tsx`: `history.ts` reads each table for one debate, or every kind plus Library sources outside all debates, and adds unsorted work to a debate through the website's `attach_to_debate` function. Chats reopen in Ask (`/ask?chat=`). Sparring rounds are listed read-only until Sparring is in the app. - Announcements mirror the website's banner (`AppLayout.tsx`, `AnnouncementBanner.tsx`) at the top of the Debate tab: `announcements.ts` lists and dismisses through the `announcements` Edge Function, and `use-announcements.ts` keeps the website's startsAt/expiresAt filter (shared in `packages/core/src/announcements.ts`), its one-minute reload guard, and reloads on focus, foreground and account change. Dismissals are stored on the server, so they are shared with the website. +- You mirrors the website's Settings for what the app can do: Download my data (`exportAccountData` in `account.ts`, the `account` function's export written as `debatelab-data-.json` and shared through `shareJson`), Delete all my data (`delete-data.tsx`, the email-typed confirmation and `clear_current_user_data`; afterwards session memory is forgotten, one-off mode turns off and the debates reload, as the website clears its stores), and Terms and Privacy, opened in the in-app browser from `app.json`'s `extra.websiteUrl` (`website.ts`). Upgrade, Manage subscription and promo codes wait for in-app purchase. - 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 and Crossfire 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. - `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. diff --git a/apps/mobile/app.json b/apps/mobile/app.json index c68036b1a..c58b4543a 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -39,6 +39,9 @@ } ] ], + "extra": { + "websiteUrl": "https://debate.pi-stack.org" + }, "experiments": { "typedRoutes": true, "reactCompiler": true diff --git a/apps/mobile/src/app/(tabs)/(you)/you.tsx b/apps/mobile/src/app/(tabs)/(you)/you.tsx index c4275e598..849c64fa1 100644 --- a/apps/mobile/src/app/(tabs)/(you)/you.tsx +++ b/apps/mobile/src/app/(tabs)/(you)/you.tsx @@ -1,21 +1,27 @@ import { APP_LANGUAGES, type AppLanguage } from '@debatelab/core/languages'; import { useFocusEffect, useRouter } from 'expo-router'; -import { useCallback, useState, type ReactNode } from 'react'; +import * as WebBrowser from 'expo-web-browser'; +import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; import { TextButton } from '@/components/buttons'; import { Card, IconTile } from '@/components/card'; import { Icon } from '@/components/icon'; +import type { IconName } from '@/components/icon-paths'; import { ScreenTitle } from '@/components/screen-title'; import { Screen } from '@/components/screen'; import { SegmentedControl } from '@/components/segmented-control'; import { Text } from '@/components/text'; +import { exportAccountData } from '@/lib/account'; import { useAuth } from '@/lib/auth'; +import { showNotice } from '@/lib/confirm'; import { formatDate } from '@/lib/dates'; +import { shareJson } from '@/lib/export'; import { useSettings, type ThemePreference } from '@/lib/settings'; import { fetchSubscription, type Subscription, type SubscriptionTier } from '@/lib/subscription'; import { supabaseEnv } from '@/lib/supabase-config'; +import { websitePage } from '@/lib/website'; import { useTheme } from '@/theme/use-theme'; const TIER_LABELS: Record = { @@ -30,7 +36,7 @@ const LANGUAGE_OPTIONS = (Object.keys(APP_LANGUAGES) as AppLanguage[]).map((valu label: APP_LANGUAGES[value].label, })); -/** Plan, settings shared with the website, and the account. Download my data is not in the app yet. */ +/** Plan, History, settings shared with the website, the account and its data, and the website's Terms and Privacy. */ export default function YouScreen() { const { t } = useTranslation(); const router = useRouter(); @@ -39,6 +45,34 @@ export default function YouScreen() { const settings = useSettings(); const [saveFailed, setSaveFailed] = useState(false); + const [exporting, setExporting] = useState(false); + // A download started for one account is not shared after an account change or after leaving. + const owner = useRef(session?.user.id ?? null); + const live = useRef(true); + useEffect(() => { + owner.current = session?.user.id ?? null; + }, [session?.user.id]); + useEffect(() => () => { + live.current = false; + }, []); + + // The website's Download my data: the account function's export, as debatelab-data-.json, to the share sheet. + const download = async () => { + const forUser = owner.current; + if (!forUser || exporting) return; + setExporting(true); + try { + const { fileName, json } = await exportAccountData(); + if (!live.current || owner.current !== forUser) return; + await shareJson(json, fileName, t('settings.downloadData')); + } catch (cause) { + console.warn('Could not download the data:', cause instanceof Error ? cause.message : cause); + if (live.current && owner.current === forUser) showNotice(t('settings.downloadDataFailed')); + } finally { + if (live.current) setExporting(false); + } + }; + const save = (change: () => Promise) => { setSaveFailed(false); change().catch((cause: unknown) => { @@ -130,6 +164,45 @@ export default function YouScreen() { ) : null} + void download()} + testID="you-download-data" + style={({ pressed }) => [styles.row, { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> + + + + {exporting ? t('settings.downloadingData') : t('settings.downloadData')} + + + {t('settings.yourDataDesc')} + + + {exporting ? : null} + + + + + {t('settings.dangerZone')} + + + router.push('/delete-data')} + testID="you-delete-data" + style={({ pressed }) => [styles.row, pressed && styles.pressed]}> + + + {t('settings.deleteAllData')} + + + {t('mobile.you.deleteDataDesc')} + + + + router.push('/delete-account')} @@ -146,11 +219,33 @@ export default function YouScreen() { + + + + + void signOut()} /> ); } +/** A page of the website, opened in the in-app browser. */ +function LinkRow({ icon, label, url, first }: { icon: IconName; label: string; url: string; first?: boolean }) { + const { colors } = useTheme(); + return ( + void WebBrowser.openBrowserAsync(url)} + style={({ pressed }) => [styles.row, !first && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> + + + {label} + + + + ); +} + function Setting({ label, children }: { label: string; children: ReactNode }) { return ( diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 2c2fd3179..48a8122bf 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -76,6 +76,7 @@ function Root() { + diff --git a/apps/mobile/src/app/delete-data.tsx b/apps/mobile/src/app/delete-data.tsx new file mode 100644 index 000000000..8c70dcbad --- /dev/null +++ b/apps/mobile/src/app/delete-data.tsx @@ -0,0 +1,119 @@ +import { useRouter } from 'expo-router'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { PrimaryButton, TextButton } from '@/components/buttons'; +import { Field } from '@/components/field'; +import { SheetScrollView } from '@/components/sheet'; +import { Text } from '@/components/text'; +import { clearAllData } from '@/lib/account'; +import { useAuth } from '@/lib/auth'; +import { showNotice } from '@/lib/confirm'; +import { useDebates } from '@/lib/debate-context'; +import { forgetSessionMemory } from '@/lib/session-memory'; +import { useTheme } from '@/theme/use-theme'; + +/** + * The website's Delete all my data (SettingsPage.tsx): the student types their + * email, then clear_current_user_data deletes their saved work, sources and + * debates. Settings and the account stay. + */ +export default function DeleteDataSheet() { + const { t } = useTranslation(); + const router = useRouter(); + const { colors } = useTheme(); + const { session } = useAuth(); + const { refresh, setOneOff } = useDebates(); + const userId = session?.user.id ?? null; + const email = session?.user.email; + const [confirmation, setConfirmation] = useState(''); + const [deleting, setDeleting] = useState(false); + const [failed, setFailed] = useState(false); + // Nothing lands after an account change or after the sheet closes. + const owner = useRef(userId); + const live = useRef(true); + useEffect(() => { + owner.current = userId; + }, [userId]); + useEffect(() => () => { + live.current = false; + }, []); + + const matches = Boolean(email) && confirmation === email; + + const remove = async () => { + const forUser = owner.current; + if (!matches || deleting || !forUser) return; + setDeleting(true); + setFailed(false); + try { + await clearAllData(forUser); + } catch (cause) { + // The server's message is English and not meant for students; the sheet shows translated text only. + console.warn('Could not delete the data:', cause instanceof Error ? cause.message : cause); + if (live.current && owner.current === forUser) { + setDeleting(false); + setFailed(true); + } + return; + } + if (owner.current !== forUser) return; + // As the website clears its stores: this session's results and drafts, one-off mode, and the debates, now gone. + forgetSessionMemory(); + setOneOff(false); + void refresh(); + showNotice(t('settings.deleteSuccess'), t('settings.deleteSuccessDesc')); + if (live.current) router.back(); + }; + + return ( + + + + {t('settings.deleteDialogTitle')} + + router.back()} disabled={deleting} /> + + + {t('settings.deleteDialogDesc', { email: email ?? '' })} + + void remove()} + testID="delete-data-confirmation" + /> + {failed ? ( + + + {t('settings.deleteFailed')} + + {t('settings.deleteFailedDesc')} + + ) : null} + void remove()} + /> + + ); +} + +const styles = StyleSheet.create({ + header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, + title: { fontSize: 21, lineHeight: 26, letterSpacing: -0.3, flexShrink: 1 }, + body: { fontSize: 15, lineHeight: 21 }, + error: { gap: 2 }, +}); diff --git a/apps/mobile/src/lib/account.ts b/apps/mobile/src/lib/account.ts index c1d582933..0d78f76df 100644 --- a/apps/mobile/src/lib/account.ts +++ b/apps/mobile/src/lib/account.ts @@ -7,18 +7,43 @@ export function deletionConfirmation(email: string | undefined): string { return email || 'DELETE'; } +/** One request to the `account` Edge Function, as the website's src/lib/account.ts sends it. */ +async function invokeAccount(body: Record, fallback: string): Promise { + const { data: sessionData } = await supabase.auth.getSession(); + const accessToken = sessionData.session?.access_token; + if (!accessToken) throw new Error('Please sign in first'); + const { data, error } = await supabase.functions.invoke('account', { + headers: { Authorization: `Bearer ${accessToken}` }, + body, + }); + if (error) throw new Error((await getEdgeFunctionErrorMessage(error)) || fallback); + return data; +} + /** * Permanently deletes the signed-in account through the `account` Edge * Function, with the same request as the website's src/lib/account.ts. Any * Stripe subscription is canceled first. */ export async function deleteAccount(confirmation: string): Promise { - const { data: sessionData } = await supabase.auth.getSession(); - const accessToken = sessionData.session?.access_token; - if (!accessToken) throw new Error('Please sign in first'); - const { error } = await supabase.functions.invoke('account', { - headers: { Authorization: `Bearer ${accessToken}` }, - body: { action: 'delete', confirmation }, - }); - if (error) throw new Error((await getEdgeFunctionErrorMessage(error)) || 'Could not delete your account'); + await invokeAccount({ action: 'delete', confirmation }, 'Could not delete your account'); +} + +/** Everything the signed-in account owns, as the website's downloadAccountData fetches it, with its file name. */ +export async function exportAccountData(now: Date = new Date()): Promise<{ fileName: string; json: string }> { + const data = await invokeAccount({ action: 'export' }, 'Could not export your data'); + return { fileName: `debatelab-data-${now.toISOString().slice(0, 10)}.json`, json: JSON.stringify(data, null, 2) }; +} + +/** + * Deletes the account's saved work, sources, debates and API keys through the + * website's clear_current_user_data function, which acts for whoever holds the + * session. The session must still be the account the student confirmed for. + * Settings and the subscription stay. + */ +export async function clearAllData(userId: string): Promise { + const { data } = await supabase.auth.getSession(); + if (data.session?.user.id !== userId) throw new Error('The signed-in account changed'); + const { error } = await supabase.rpc('clear_current_user_data'); + if (error) throw new Error(error.message); } diff --git a/apps/mobile/src/lib/export.ts b/apps/mobile/src/lib/export.ts index 839e6544e..fb901f9d7 100644 --- a/apps/mobile/src/lib/export.ts +++ b/apps/mobile/src/lib/export.ts @@ -16,6 +16,14 @@ export async function sharePdf(html: string, fileName: string, dialogTitle: stri await Sharing.shareAsync(target.uri, { mimeType: 'application/pdf', UTI: 'com.adobe.pdf', dialogTitle }); } +/** A JSON file, as the website downloads it, handed to the share sheet so it can be saved to Files or sent. */ +export async function shareJson(json: string, fileName: string, dialogTitle: string): Promise { + const target = new File(Paths.cache, fileName); + target.create({ overwrite: true }); + target.write(json); + await Sharing.shareAsync(target.uri, { mimeType: 'application/json', UTI: 'public.json', dialogTitle }); +} + export async function copyText(text: string): Promise { await Clipboard.setStringAsync(text); } diff --git a/apps/mobile/src/lib/export.web.ts b/apps/mobile/src/lib/export.web.ts index 894085a3b..852277d0d 100644 --- a/apps/mobile/src/lib/export.web.ts +++ b/apps/mobile/src/lib/export.web.ts @@ -5,7 +5,7 @@ import * as Clipboard from 'expo-clipboard'; * exist there. It keeps the HTML it would print and the text it copied on * window, so the screenshot script can show and check them. */ -type ExportProbe = { __debatelabExport?: { html?: string; fileName?: string; copied?: string; shared?: string } }; +type ExportProbe = { __debatelabExport?: { html?: string; fileName?: string; copied?: string; shared?: string; json?: string } }; function probe() { const target = window as unknown as ExportProbe; @@ -18,6 +18,11 @@ export function sharePdf(html: string, fileName: string): Promise { return Promise.resolve(); } +export function shareJson(json: string, fileName: string): Promise { + Object.assign(probe(), { json, fileName }); + return Promise.resolve(); +} + export async function copyText(text: string): Promise { probe().copied = text; await Clipboard.setStringAsync(text).catch((): undefined => undefined); diff --git a/apps/mobile/src/lib/owner-check.test.ts b/apps/mobile/src/lib/owner-check.test.ts new file mode 100644 index 000000000..eec9b5e50 --- /dev/null +++ b/apps/mobile/src/lib/owner-check.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const supabase = vi.hoisted(() => ({ + auth: { getSession: vi.fn() }, + rpc: vi.fn(), +})); + +vi.mock('./supabase', () => ({ supabase })); + +import { clearAllData } from './account'; +import { attachToDebate } from './history'; + +// attach_to_debate and clear_current_user_data act for whoever holds the session, not for the account the screen showed. +describe('account-wide writes after an account switch', () => { + beforeEach(() => { + supabase.rpc.mockReset().mockResolvedValue({ error: null }); + supabase.auth.getSession.mockResolvedValue({ data: { session: { user: { id: 'user-b' } } } }); + }); + + it('does not add one account\'s unsorted work to a debate once another account is signed in', async () => { + const entry = { kind: 'case' as const, id: 'case-1', title: 'A case', createdAt: 0 }; + await expect(attachToDebate('user-a', entry, 'debate-1')).rejects.toThrow(); + expect(supabase.rpc).not.toHaveBeenCalled(); + }); + + it('does not delete all data once another account is signed in', async () => { + await expect(clearAllData('user-a')).rejects.toThrow(); + expect(supabase.rpc).not.toHaveBeenCalled(); + }); + + it('deletes all data for the account that confirmed', async () => { + await clearAllData('user-b'); + expect(supabase.rpc).toHaveBeenCalledWith('clear_current_user_data'); + }); +}); diff --git a/apps/mobile/src/lib/session-memory.ts b/apps/mobile/src/lib/session-memory.ts index 21bb3d813..c11fb5a9a 100644 --- a/apps/mobile/src/lib/session-memory.ts +++ b/apps/mobile/src/lib/session-memory.ts @@ -28,3 +28,8 @@ export function setSessionOwner(userId: string | null) { owner = userId; resets.forEach((reset) => reset()); } + +/** Forgets the session memory of the account still signed in, as after Delete all my data. */ +export function forgetSessionMemory() { + resets.forEach((reset) => reset()); +} diff --git a/apps/mobile/src/lib/website.ts b/apps/mobile/src/lib/website.ts new file mode 100644 index 000000000..091a7ee06 --- /dev/null +++ b/apps/mobile/src/lib/website.ts @@ -0,0 +1,12 @@ +import Constants from 'expo-constants'; + +/** + * The public website, from app.json's extra.websiteUrl. Staging builds link + * there too: the staging site is behind Vercel login, and Terms and Privacy + * are the same pages. + */ +const websiteUrl = (Constants.expoConfig?.extra as { websiteUrl?: string } | undefined)?.websiteUrl ?? 'https://debate.pi-stack.org'; + +export function websitePage(path: '/terms' | '/privacy'): string { + return `${websiteUrl}${path}`; +} diff --git a/src/locales/en.json b/src/locales/en.json index 4464db3f3..249a504bf 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2444,7 +2444,8 @@ "usage": "{{used}} of {{limit}} AI uses today", "loadFailed": "Could not load your subscription.", "saveFailed": "Could not save your settings. Try again.", - "historyDesc": "Everything you saved, by debate" + "historyDesc": "Everything you saved, by debate", + "deleteDataDesc": "Saved work, sources and debates. Your account and settings stay." }, "deleteAccount": { "failedHint": "Your account is still here. Check your connection and try again." diff --git a/src/locales/es.json b/src/locales/es.json index c47ff2019..70ac6506c 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2444,7 +2444,8 @@ "usage": "{{used}} de {{limit}} usos de IA hoy", "loadFailed": "No se pudo cargar tu suscripción.", "saveFailed": "No se pudieron guardar tus ajustes. Inténtalo de nuevo.", - "historyDesc": "Todo lo que guardaste, por debate" + "historyDesc": "Todo lo que guardaste, por debate", + "deleteDataDesc": "Trabajo guardado, fuentes y debates. Tu cuenta y tus ajustes se mantienen." }, "deleteAccount": { "failedHint": "Tu cuenta sigue activa. Comprueba tu conexión e inténtalo de nuevo." diff --git a/src/locales/hu.json b/src/locales/hu.json index 4f5ee5511..b4cb585bf 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2444,7 +2444,8 @@ "usage": "Ma {{used}} / {{limit}} MI-használat", "loadFailed": "Nem sikerült betölteni az előfizetésedet.", "saveFailed": "Nem sikerült menteni a beállításokat. Próbáld újra.", - "historyDesc": "Minden mentett munkád, vitánként" + "historyDesc": "Minden mentett munkád, vitánként", + "deleteDataDesc": "Mentett munkák, források és viták. A fiókod és a beállításaid megmaradnak." }, "deleteAccount": { "failedHint": "A fiókod megmaradt. Ellenőrizd a kapcsolatot, és próbáld újra." From c692fc70e6b9244083cff2f6eb10919ec441a2c5 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 12:52:59 +0000 Subject: [PATCH 04/12] Add the model picker to Generate, Attack, Brainstorm, Factoids and Ask Each form gets the website's ModelSelector as two native rows, AI model and Reasoning, and Ask gets them as chips beside its options. Pro models show but stay unselectable without Pro access, and reasoning stays at Low for Auto and for students without Pro, as on the website. A change writes the whole feature_configs object in the website's canonical shape through the settings provider's serial queue, filling the defaults when nothing was saved, and a failed write rolls back only its own field. Tools read their model through the same queue, so they run with the model just picked. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 2 +- apps/mobile/src/app/ask.tsx | 9 +- apps/mobile/src/app/attack/index.tsx | 7 +- apps/mobile/src/app/brainstorm/index.tsx | 7 +- apps/mobile/src/app/factoids/index.tsx | 7 +- apps/mobile/src/app/generate.tsx | 7 +- apps/mobile/src/components/action-menu.tsx | 7 +- .../mobile/src/components/action-menu.web.tsx | 4 +- apps/mobile/src/components/form-rows.tsx | 8 +- apps/mobile/src/components/model-picker.tsx | 205 ++++++++++++++++++ apps/mobile/src/lib/feature-config.test.ts | 23 ++ apps/mobile/src/lib/feature-config.ts | 61 ++++-- apps/mobile/src/lib/settings.tsx | 72 +++++- 13 files changed, 374 insertions(+), 45 deletions(-) create mode 100644 apps/mobile/src/components/model-picker.tsx create mode 100644 apps/mobile/src/lib/feature-config.test.ts diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 2f732a576..0502873bf 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -21,7 +21,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp - `src/components/`: shared UI. `app-tabs.tsx` uses native tabs; `app-tabs.web.tsx` draws the mock's tab bar for the web preview. - `src/lib/`: auth (`auth.tsx`), the Supabase client, session storage (AES key in SecureStore, ciphertext in AsyncStorage) and i18n. `debates.ts`, `settings.tsx`, `subscription.ts` and `account.ts` read and write the website's tables the way its stores do (`src/lib/stores/debate-store.ts`, `settings-store.ts`, `src/hooks/use-subscription.ts`, `src/lib/account.ts`), so the app and the website share one Current Debate and one set of settings. Change them together with the website code they mirror. - `sources.ts` mirrors the session store's `addSource` and `removeSource` and `src/lib/source-import.ts`. The Library always shows the Current Debate, also in one-off mode, as on the website. Adding a file (the website parses files in the browser) and Scan pages are not in the app yet. -- Ask mirrors `src/lib/ai/streaming.ts` and `askChat` in `src/lib/ai/client.ts`: `ask.ts` sends the requests (plain questions stream from `ai-stream` through `expo/fetch`; with Citations, Web search, Pros/Cons or Argument map on, the whole answer comes from `ai`, because `ai-stream` refuses those options), `ai-request.ts` holds the shared POST with the website's headers, timeout and error kinds, `feature-config.ts` reads each tool's model from `user_settings.feature_configs`, `context-request.ts` builds the Current Debate context with the website's default selection and pins, and `chats.ts` saves finished exchanges to `chats` and `messages`. The SSE parser is shared in `packages/core/src/ai/sse.ts`. `markdown.ts` reads answers, because react-markdown needs the DOM. +- Ask mirrors `src/lib/ai/streaming.ts` and `askChat` in `src/lib/ai/client.ts`: `ask.ts` sends the requests (plain questions stream from `ai-stream` through `expo/fetch`; with Citations, Web search, Pros/Cons or Argument map on, the whole answer comes from `ai`, because `ai-stream` refuses those options), `ai-request.ts` holds the shared POST with the website's headers, timeout and error kinds, `feature-config.ts` reads each tool's model from `user_settings.feature_configs` (the model picker, `components/model-picker.tsx`, writes it back through `settings.tsx`'s serial queue in the website's canonical shape; tools read it through the same queue, so they run with the model just picked), `context-request.ts` builds the Current Debate context with the website's default selection and pins, and `chats.ts` saves finished exchanges to `chats` and `messages`. The SSE parser is shared in `packages/core/src/ai/sse.ts`. `markdown.ts` reads answers, because react-markdown needs the DOM. - Generate and Attack mirror `generateCase` and `attackCase` in `src/lib/ai/client.ts` and the session store's `recordResult`: `results.ts` sends the requests through `ai-request.ts`, checks the output with the website's transform (moved to `packages/core/src/ai/generate-output.ts`), keeps researched sources in the debate's Auto-saved folder (`packages/core/src/ai/discovered-sources.ts`), saves the server's source rankings onto the Library sources a result used (`packages/core/src/ai/source-assessments.ts`, also for Ask's whole answers), says when the server shortened the input, and saves rows to `cases` and `attacks`. `motion.ts` and `motion-gate.ts` are the website's motion gate. `open-results.ts` holds results made in this session, so one-off work and failed saves still open as documents. It and the last Generate and Attack forms live in `session-memory.ts`, which the auth provider clears on every account change, including sign-out. `components/document.tsx` draws results as documents (mock 03); Brainstorm and Factoids reuse it. Export renders `export-html.ts` to a PDF with expo-print and shares it (`export.ts`); the web preview cannot print, so `export.web.ts` keeps the HTML and copied text on `window` for the screenshot script. Word export and Google Docs are not in the app. - Brainstorm and Factoids mirror `brainstorm` and `factoids` in `src/lib/ai/client.ts` and their pages, through the same `results.ts`: the brainstorm transform and the verified-factoid selection live in `packages/core/src/ai/brainstorm-output.ts` and `factoids-output.ts`, rows go to `brainstorms` and `factoids`. Brainstorm has the motion gate; Factoids, like Attack, follows the Current Debate and saves with side `neutral`. A factoid's bookmark saves it to the Current Debate's `Factoids` folder, and a search suggestion reopens the Factoids form with that query. `prepareToolHref` in `components/prepare-tools.ts` is where both tabs open a tool. - Timer mirrors `src/pages/TimerPage.tsx`: `timer.ts` holds the formats and a clock made of start timestamps, never counted ticks, so it is right after the app returns from the background (`timer.test.ts`). `timer-signals.ts` plays the bundled beeps in `assets/sounds` (made by `scripts/render-timer-sounds.ts`) with expo-audio, taps with expo-haptics and keeps the screen awake while a speech runs; `timer-signals.web.ts` only records the signals for the screenshot script. Lock-screen timers wait for the development build (`TODO(live-activity)`). diff --git a/apps/mobile/src/app/ask.tsx b/apps/mobile/src/app/ask.tsx index 5d431a0db..693b312a6 100644 --- a/apps/mobile/src/app/ask.tsx +++ b/apps/mobile/src/app/ask.tsx @@ -9,6 +9,7 @@ import { AskAnswer } from '@/components/ask-answer'; import { AskHistory } from '@/components/ask-history'; import { TextButton } from '@/components/buttons'; import { Chip } from '@/components/chip'; +import { ModelChips } from '@/components/model-picker'; import { Icon } from '@/components/icon'; import type { IconName } from '@/components/icon-paths'; import { OneOffNotice } from '@/components/one-off-notice'; @@ -16,7 +17,7 @@ import { Text } from '@/components/text'; import { AIError, isAbortError, type AIErrorKind, type ResponseExtras } from '@/lib/ai-request'; import { askOnce, canStream, isFinalAskError, isMessageWithinBudget, streamAsk, type AskOptions } from '@/lib/ask'; import { buildDebateContext } from '@/lib/context-request'; -import { fetchFeatureConfig, type FeatureConfig } from '@/lib/feature-config'; +import type { FeatureConfig } from '@/lib/feature-config'; import { useAuth } from '@/lib/auth'; import { ChatSaveError, fetchChat, saveChat, type ChatMessage, type ChatRecord } from '@/lib/chats'; import { useDebates } from '@/lib/debate-context'; @@ -56,7 +57,7 @@ export default function AskScreen() { const { session } = useAuth(); const userId = session?.user.id ?? null; const { current, oneOff, activeDebateId, refresh } = useDebates(); - const { outputLanguage } = useSettings(); + const { outputLanguage, readFeatureConfig } = useSettings(); const [messages, setMessages] = useState([]); const [streaming, setStreaming] = useState(null); @@ -87,7 +88,7 @@ export default function AskScreen() { }; const askConfig = () => { - if (!config.current && userId) config.current = fetchFeatureConfig(userId, 'ask'); + if (!config.current && userId) config.current = readFeatureConfig(userId, 'ask'); return config.current!; }; @@ -429,6 +430,8 @@ export default function AskScreen() { onPress={() => setOptions((previous) => ({ ...previous, [chip.key]: !previous[chip.key] }))} /> ))} + {/* The next question reads the model again, after this change is saved. */} + (config.current = null)} /> (lastForm.get); const [textError, setTextError] = useState(false); @@ -114,7 +114,7 @@ export default function AttackScreen() { setPhase('attacking'); try { const [config, debateContext] = await Promise.all([ - fetchFeatureConfig(forUser, 'attack'), + readFeatureConfig(forUser, 'attack'), debateId ? buildDebateContext(forUser, debateId, 'attack', 'both') : Promise.resolve(undefined), ]); if (!isCurrent()) return; @@ -231,6 +231,7 @@ export default function AttackScreen() { onChange={(analyzeWeighing) => setForm({ analyzeWeighing })} /> + {error ? void submit()} /> : null} )} diff --git a/apps/mobile/src/app/brainstorm/index.tsx b/apps/mobile/src/app/brainstorm/index.tsx index ea2db2207..3b67cc18a 100644 --- a/apps/mobile/src/app/brainstorm/index.tsx +++ b/apps/mobile/src/app/brainstorm/index.tsx @@ -9,6 +9,7 @@ import { DocumentScreen } from '@/components/document'; import { DocumentHeader } from '@/components/document-header'; import { Field } from '@/components/field'; import { FormGroup, SelectRow } from '@/components/form-rows'; +import { ModelPicker } from '@/components/model-picker'; import { Text } from '@/components/text'; import { ToolError, ToolProgress, ToolTitle, WorkingIn } from '@/components/tool-form'; import { AIError, isAbortError, type AIErrorKind } from '@/lib/ai-request'; @@ -16,7 +17,6 @@ import { useAuth } from '@/lib/auth'; import { backLabel } from '@/lib/back-label'; import { buildDebateContext } from '@/lib/context-request'; import { useDebates } from '@/lib/debate-context'; -import { fetchFeatureConfig } from '@/lib/feature-config'; import { decideMotionTarget, shouldPrefillMotion } from '@/lib/motion'; import { chooseMotionTarget } from '@/lib/motion-gate'; import { keepOpenResult } from '@/lib/open-results'; @@ -41,7 +41,7 @@ export default function BrainstormScreen() { const { session } = useAuth(); const userId = session?.user.id ?? null; const { debates, current, oneOff, create, switchTo, refresh } = useDebates(); - const { outputLanguage } = useSettings(); + const { outputLanguage, readFeatureConfig } = useSettings(); const { colors } = useTheme(); const [form, setFormState] = useState(lastForm.get); const [motionError, setMotionError] = useState(false); @@ -96,7 +96,7 @@ export default function BrainstormScreen() { setPhase('working'); try { const [config, debateContext] = await Promise.all([ - fetchFeatureConfig(forUser, 'brainstorm'), + readFeatureConfig(forUser, 'brainstorm'), debateId ? buildDebateContext(forUser, debateId, 'brainstorm', input.side) : Promise.resolve(undefined), ]); if (!isCurrent()) return; @@ -251,6 +251,7 @@ export default function BrainstormScreen() { testID="brainstorm-output" /> + (lastForm.get); const [claimError, setClaimError] = useState(false); @@ -104,7 +104,7 @@ export default function FactoidsScreen() { setPhase('working'); try { const [config, debateContext] = await Promise.all([ - fetchFeatureConfig(forUser, 'factoids'), + readFeatureConfig(forUser, 'factoids'), debateId ? buildDebateContext(forUser, debateId, 'factoids', input.direction === 'against' ? 'con' : 'pro') : Promise.resolve(undefined), ]); if (!isCurrent()) return; @@ -240,6 +240,7 @@ export default function FactoidsScreen() { testID="factoids-source-count" /> + (lastForm.get); const [motionError, setMotionError] = useState(false); @@ -93,7 +93,7 @@ export default function GenerateScreen() { setPhase('generating'); try { const [config, debateContext] = await Promise.all([ - fetchFeatureConfig(forUser, 'generate'), + readFeatureConfig(forUser, 'generate'), debateId ? buildDebateContext(forUser, debateId, 'generate', input.side) : Promise.resolve(undefined), ]); if (!isCurrent()) return; @@ -245,6 +245,7 @@ export default function GenerateScreen() { ]} /> + first diff --git a/apps/mobile/src/components/action-menu.tsx b/apps/mobile/src/components/action-menu.tsx index 429fb096d..90adbea16 100644 --- a/apps/mobile/src/components/action-menu.tsx +++ b/apps/mobile/src/components/action-menu.tsx @@ -8,6 +8,8 @@ export interface MenuItem { label: string; icon?: IconName; destructive?: boolean; + /** Shown but not selectable, as the website's ModelSelector shows Pro models to other students. */ + disabled?: boolean; } export interface ActionMenuProps { @@ -34,7 +36,7 @@ export function ActionMenu({ visible, title, items, cancelLabel, onSelect, onClo const { items: current, onSelect: select, onClose: close } = latest.current; close(); const item = index === undefined ? undefined : current[index]; - if (item) select(item.key); + if (item && !item.disabled) select(item.key); }; const labels = latest.current.items.map((item) => item.label); if (Platform.OS === 'ios') { @@ -44,12 +46,13 @@ export function ActionMenu({ visible, title, items, cancelLabel, onSelect, onClo options: [...labels, cancelLabel], cancelButtonIndex: labels.length, destructiveButtonIndex: latest.current.items.flatMap((item, index) => (item.destructive ? [index] : [])), + disabledButtonIndices: latest.current.items.flatMap((item, index) => (item.disabled ? [index] : [])), }, (index) => choose(index < labels.length ? index : undefined), ); } else { Alert.alert(title ?? '', undefined, [ - ...labels.map((label, index) => ({ text: label, onPress: () => choose(index) })), + ...labels.flatMap((label, index) => (latest.current.items[index].disabled ? [] : [{ text: label, onPress: () => choose(index) }])), { text: cancelLabel, style: 'cancel' as const, onPress: () => choose(undefined) }, ], { cancelable: true, onDismiss: () => choose(undefined) }); } diff --git a/apps/mobile/src/components/action-menu.web.tsx b/apps/mobile/src/components/action-menu.web.tsx index 651777f94..90c87d23b 100644 --- a/apps/mobile/src/components/action-menu.web.tsx +++ b/apps/mobile/src/components/action-menu.web.tsx @@ -23,12 +23,14 @@ export function ActionMenu({ visible, title, items, cancelLabel, onSelect, onClo { onClose(); onSelect(item.key); }} style={({ pressed }) => [styles.item, (index > 0 || title) && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && { backgroundColor: colors.line }]}> - {item.label} + {item.label} {item.icon ? : null} ))} diff --git a/apps/mobile/src/components/form-rows.tsx b/apps/mobile/src/components/form-rows.tsx index 88be58e9a..67eafbb4a 100644 --- a/apps/mobile/src/components/form-rows.tsx +++ b/apps/mobile/src/components/form-rows.tsx @@ -46,7 +46,7 @@ export function SelectRow({ }: { label: string; value: T; - options: { value: T; label: string }[]; + options: { value: T; label: string; disabled?: boolean }[]; onChange: (value: T) => void; first?: boolean; disabled?: boolean; @@ -61,10 +61,11 @@ export function SelectRow({ setOpen(true)} testID={testID} - style={({ pressed }) => [pressed && styles.pressed]}> + style={({ pressed }) => [pressed && styles.pressed, disabled && styles.disabled]}> {label} @@ -78,7 +79,7 @@ export function SelectRow({ ({ key: option.value, label: option.label, icon: option.value === value ? 'check' : undefined }))} + items={options.map((option) => ({ key: option.value, label: option.label, icon: option.value === value ? 'check' : undefined, disabled: option.disabled }))} cancelLabel={t('common.cancel')} onClose={() => setOpen(false)} onSelect={(key) => onChange(key as T)} @@ -122,4 +123,5 @@ const styles = StyleSheet.create({ switchText: { flex: 1, gap: 2 }, description: { fontSize: 13.5, lineHeight: 18 }, pressed: { opacity: 0.7 }, + disabled: { opacity: 0.45 }, }); diff --git a/apps/mobile/src/components/model-picker.tsx b/apps/mobile/src/components/model-picker.tsx new file mode 100644 index 000000000..b5b0cc90b --- /dev/null +++ b/apps/mobile/src/components/model-picker.tsx @@ -0,0 +1,205 @@ +import type { FeatureTab } from '@debatelab/core/ai/feature-configs'; +import { + AUTO_FAST, + AUTO_MODEL_SELECTIONS, + MODEL_CATALOG, + isAutoModelSelection, + type ReasoningEffort, +} from '@debatelab/core/contract/model-catalog'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { ActionMenu } from './action-menu'; +import { Chip } from './chip'; +import { FormGroup, SelectRow } from './form-rows'; +import { Text } from './text'; + +import { useAuth } from '@/lib/auth'; +import { fetchFeatureSettings, type FeatureConfig } from '@/lib/feature-config'; +import { useSettings } from '@/lib/settings'; +import { useTheme } from '@/theme/use-theme'; + +/** The website's ModelSelector list: the Auto choices, then the catalog. */ +const MODELS = [...AUTO_MODEL_SELECTIONS, ...MODEL_CATALOG].map((model) => ({ id: model.id, name: model.name, pro: model.isProOnly })); +const EFFORTS: { value: ReasoningEffort; label: string }[] = [ + { value: 'low', label: 'modelSelector.reasoningLow' }, + { value: 'medium', label: 'modelSelector.reasoningMedium' }, + { value: 'high', label: 'modelSelector.reasoningHigh' }, +]; + +type Choice = Pick; +type Field = keyof Choice; + +/** + * One tool's model and reasoning, read from feature_configs and saved back + * through the settings provider's queue. A change shows at once; a failed + * save rolls back only its own field, and only while it is that field's + * latest change. Nothing lands after an account change or after leaving. + */ +function useModelChoice(feature: FeatureTab, onChange?: () => void) { + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const { setFeatureConfig } = useSettings(); + const [state, setState] = useState<{ owner: string; choice: Choice; pro: boolean } | null>(null); + const [failed, setFailed] = useState(false); + const owner = useRef(userId); + const live = useRef(true); + const loads = useRef(0); + const versions = useRef>({ model: 0, reasoningEffort: 0 }); + // Per field, what the row last held as far as the app knows. + const confirmed = useRef(null); + + useEffect(() => { + live.current = true; + return () => { + live.current = false; + }; + }, []); + + useEffect(() => { + owner.current = userId; + const request = ++loads.current; + confirmed.current = null; + setState(null); + setFailed(false); + if (!userId) return; + const started = { ...versions.current }; + void fetchFeatureSettings(userId, feature).then(({ config, pro }) => { + if (!live.current || loads.current !== request || owner.current !== userId) return; + const loaded = { model: config.model, reasoningEffort: config.reasoningEffort }; + confirmed.current = loaded; + // A field changed while this load ran keeps the change. + setState((previous) => { + const choice = { ...loaded }; + if (previous?.owner === userId) { + if (versions.current.model !== started.model) choice.model = previous.choice.model; + if (versions.current.reasoningEffort !== started.reasoningEffort) choice.reasoningEffort = previous.choice.reasoningEffort; + } + return { owner: userId, choice, pro }; + }); + }); + }, [userId, feature]); + + const change = (field: Field, value: string) => { + const forUser = owner.current; + if (!forUser || !state || state.owner !== forUser) return; + const version = ++versions.current[field]; + const update = { [field]: value } as Partial; + setState((current) => (current && current.owner === forUser ? { ...current, choice: { ...current.choice, ...update } } : current)); + setFailed(false); + onChange?.(); + setFeatureConfig(feature, update) + .then(() => { + if (!live.current || owner.current !== forUser) return; + if (confirmed.current) confirmed.current = { ...confirmed.current, ...update }; + }) + .catch((cause: unknown) => { + console.warn('Could not save the model:', cause instanceof Error ? cause.message : cause); + if (!live.current || owner.current !== forUser) return; + setFailed(true); + if (versions.current[field] !== version || !confirmed.current) return; + const previous = confirmed.current[field]; + setState((current) => (current && current.owner === forUser ? { ...current, choice: { ...current.choice, [field]: previous } } : current)); + }); + }; + + return { state, failed, change }; +} + +function useOptions(pro: boolean) { + const { t } = useTranslation(); + return { + models: MODELS.map((model) => ({ value: model.id, label: model.pro ? `${model.name} · Pro` : model.name, disabled: model.pro && !pro })), + efforts: EFFORTS.map((effort) => ({ value: effort.value, label: t(effort.label) })), + }; +} + +/** The website's reasoning choice: Pro only, and not for Auto, which then shows Low. */ +function reasoningFor(choice: Choice, pro: boolean) { + const automatic = isAutoModelSelection(choice.model); + return { value: !automatic && pro ? choice.reasoningEffort : 'low', disabled: !pro || automatic }; +} + +function autoDescription(t: ReturnType['t'], model: string): string | undefined { + if (!isAutoModelSelection(model)) return undefined; + return model === AUTO_FAST ? t('modelSelector.autoFastDescription') : t('modelSelector.autoProDescription'); +} + +/** The website's ModelSelector in a tool's form: the model and its reasoning, saved for that tool. */ +export function ModelPicker({ feature }: { feature: FeatureTab }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const { state, failed, change } = useModelChoice(feature); + const { models, efforts } = useOptions(state?.pro ?? false); + const loading = [{ value: '', label: t('common.loading') }]; + const reasoning = state ? reasoningFor(state.choice, state.pro) : null; + return ( + + + change('model', model)} + testID={`model-picker-${feature}`} + /> + change('reasoningEffort', effort)} + /> + + {failed ? ( + + {t('mobile.you.saveFailed')} + + ) : null} + + ); +} + +/** The same choice as chips beside Ask's options, where the website's Ask shows its ModelSelector. */ +export function ModelChips({ feature, onChange }: { feature: FeatureTab; onChange: () => void }) { + const { t } = useTranslation(); + const { state, failed, change } = useModelChoice(feature, onChange); + const { models, efforts } = useOptions(state?.pro ?? false); + const [open, setOpen] = useState(null); + if (!state) return null; + const reasoning = reasoningFor(state.choice, state.pro); + const modelName = MODELS.find((model) => model.id === state.choice.model)?.name ?? state.choice.model; + const effortLabel = efforts.find((effort) => effort.value === reasoning.value)?.label ?? reasoning.value; + return ( + <> + setOpen('model')} testID="ask-model" /> + {reasoning.disabled ? null : ( + setOpen('reasoningEffort')} testID="ask-reasoning" /> + )} + ({ key: model.value, label: model.label, disabled: model.disabled, icon: model.value === state.choice.model ? 'check' : undefined }))} + cancelLabel={t('common.cancel')} + onClose={() => setOpen(null)} + onSelect={(model) => change('model', model)} + /> + ({ key: effort.value, label: effort.label, icon: effort.value === reasoning.value ? 'check' : undefined }))} + cancelLabel={t('common.cancel')} + onClose={() => setOpen(null)} + onSelect={(effort) => change('reasoningEffort', effort)} + /> + + ); +} + +const styles = StyleSheet.create({ + wrap: { gap: 6 }, + failed: { fontSize: 13.5, marginLeft: 4 }, +}); diff --git a/apps/mobile/src/lib/feature-config.test.ts b/apps/mobile/src/lib/feature-config.test.ts new file mode 100644 index 000000000..c8d0ff4cc --- /dev/null +++ b/apps/mobile/src/lib/feature-config.test.ts @@ -0,0 +1,23 @@ +import { DEFAULT_MODEL, DEFAULT_PRO_MODEL } from '@debatelab/core/contract/model-catalog'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('./supabase', () => ({ supabase: {} })); + +import { featureConfigsWith } from './feature-config'; + +describe('featureConfigsWith', () => { + it('refuses a Pro model without Pro access', () => { + expect(() => featureConfigsWith({}, false, 'generate', { model: DEFAULT_PRO_MODEL })).toThrow(); + expect(() => featureConfigsWith({}, false, 'generate', { model: 'auto-pro' })).toThrow(); + }); + + it('changes one tool and keeps what the student saved for the others', () => { + const saved = { attack: { provider: 'openrouter', model: 'google/gemini-3.8-flash', temperature: 0.4, reasoningEffort: 'high' } }; + const configs = featureConfigsWith(saved, false, 'generate', { model: 'deepseek/deepseek-v4-flash-0731' }); + expect(configs.generate).toEqual({ provider: 'openrouter', model: 'deepseek/deepseek-v4-flash-0731', temperature: 0.7, reasoningEffort: 'low' }); + expect(configs.attack).toEqual(saved.attack); + // The website reads an empty feature_configs as never saved, so every tool gets its defaults. + expect(configs.ask.model).toBe(DEFAULT_MODEL); + expect(Object.keys(configs).sort()).toEqual(['ask', 'attack', 'brainstorm', 'factoids', 'generate', 'sparring']); + }); +}); diff --git a/apps/mobile/src/lib/feature-config.ts b/apps/mobile/src/lib/feature-config.ts index ad3bbb2b5..9fbf8720d 100644 --- a/apps/mobile/src/lib/feature-config.ts +++ b/apps/mobile/src/lib/feature-config.ts @@ -1,5 +1,6 @@ import { getDefaultFeatureConfigs, type FeatureTab } from '@debatelab/core/ai/feature-configs'; import { canAccessProModels } from '@debatelab/core/ai/pro-access'; +import { PROVIDER_ID, type ProviderConfig } from '@debatelab/core/ai/providers/types'; import { AUTO_FAST, AUTO_PRO, @@ -16,8 +17,8 @@ import { supabase } from './supabase'; /** * A tool's model and its settings, picked exactly as the website's settings * store (src/lib/stores/settings-store.ts) picks them from - * user_settings.feature_configs. The app only reads them: the model picker - * and the migration that rewrites old configs stay on the website. + * user_settings.feature_configs. The model picker writes them back through the + * settings provider; the migration that rewrites old configs stays on the website. */ export interface FeatureConfig { model: string; @@ -73,22 +74,56 @@ function normalizeConfig(value: unknown, fallback: FeatureConfig, allowProModels }; } -export async function fetchFeatureConfig(userId: string, feature: FeatureTab): Promise { - const { data, error } = await supabase - .from('user_settings') - .select('feature_configs, subscription_tier, grace_period_ends_at') - .eq('user_id', userId) - .maybeSingle(); - if (error || !data) return defaultConfig(feature); - // Null when it cannot be told, as on the website, which then keeps its defaults. - const allowProModels = await canAccessProModels(data, async () => { +/** Whether the student may pick Pro models, from their user_settings row; null when it cannot be told. */ +export function canUseProModels(userId: string, settings: { subscription_tier?: unknown; grace_period_ends_at?: unknown }): Promise { + return canAccessProModels(settings, async () => { const { data: rows, error: billingError } = await supabase .from('subscriptions') .select('status, current_period_end') .eq('user_id', userId); return billingError ? null : rows ?? []; }); - if (allowProModels === null) return defaultConfig(feature); +} + +/** A tool's settings and whether the student has Pro access (false when it cannot be told). */ +export async function fetchFeatureSettings(userId: string, feature: FeatureTab): Promise<{ config: FeatureConfig; pro: boolean }> { + const { data, error } = await supabase + .from('user_settings') + .select('feature_configs, subscription_tier, grace_period_ends_at') + .eq('user_id', userId) + .maybeSingle(); + if (error || !data) return { config: defaultConfig(feature), pro: false }; + // Null when it cannot be told, as on the website, which then keeps its defaults. + const allowProModels = await canUseProModels(userId, data); + if (allowProModels === null) return { config: defaultConfig(feature), pro: false }; const fallback = defaultConfig(feature, allowProModels ? DEFAULT_PRO_MODEL : DEFAULT_MODEL); - return normalizeConfig(asRecord(data.feature_configs)?.[feature], fallback, allowProModels); + return { config: normalizeConfig(asRecord(data.feature_configs)?.[feature], fallback, allowProModels), pro: allowProModels }; +} + +export async function fetchFeatureConfig(userId: string, feature: FeatureTab): Promise { + return (await fetchFeatureSettings(userId, feature)).config; +} + +export type FeatureConfigChange = Partial>; + +/** Whether a model or Auto choice is for Pro students only, as the website's ModelSelector marks it. */ +export function isProOnlyModel(model: string): boolean { + return model === AUTO_PRO || Boolean(getModelCatalogEntry(model)?.isProOnly); +} + +/** + * The whole feature_configs object the website would save after this change, + * in its canonical shape, so the website keeps it as it is: every tool's saved + * config normalised as the website's normalizeFeatureConfigs does (its + * defaults where none was saved), with the change applied to one tool. + */ +export function featureConfigsWith(saved: unknown, pro: boolean, feature: FeatureTab, change: FeatureConfigChange): Record { + if (change.model !== undefined && !pro && isProOnlyModel(change.model)) throw new Error('Pro models need Pro access'); + const record = asRecord(saved); + const fallbackModel = pro ? DEFAULT_PRO_MODEL : DEFAULT_MODEL; + const features = Object.keys(getDefaultFeatureConfigs()) as FeatureTab[]; + return Object.fromEntries(features.map((name) => { + const config = normalizeConfig(record?.[name], defaultConfig(name, fallbackModel), pro); + return [name, { provider: PROVIDER_ID, ...config, ...(name === feature ? change : {}) }]; + })) as Record; } diff --git a/apps/mobile/src/lib/settings.tsx b/apps/mobile/src/lib/settings.tsx index eb91586b3..004919685 100644 --- a/apps/mobile/src/lib/settings.tsx +++ b/apps/mobile/src/lib/settings.tsx @@ -1,11 +1,17 @@ -import { getDefaultFeatureConfigs } from '@debatelab/core/ai/feature-configs'; -import { canAccessProModels } from '@debatelab/core/ai/pro-access'; +import { getDefaultFeatureConfigs, type FeatureTab } from '@debatelab/core/ai/feature-configs'; import { DEFAULT_MODEL, DEFAULT_PRO_MODEL } from '@debatelab/core/contract/model-catalog'; import { isAppLanguage, type AppLanguage } from '@debatelab/core/languages'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { useAuth } from './auth'; +import { + canUseProModels, + featureConfigsWith, + fetchFeatureConfig, + type FeatureConfig, + type FeatureConfigChange, +} from './feature-config'; import i18n, { deviceLanguage } from './i18n'; import { createSerialQueue } from './serial-queue'; import { supabase } from './supabase'; @@ -27,6 +33,10 @@ type SettingsContextValue = Settings & { setLanguage: (language: AppLanguage) => Promise; setOutputLanguage: (language: AppLanguage) => Promise; setTheme: (theme: ThemePreference) => Promise; + /** Saves one tool's model or reasoning to feature_configs, in order with every other settings write. */ + setFeatureConfig: (feature: FeatureTab, change: FeatureConfigChange) => Promise; + /** A tool's saved config, read after every settings write made before it, so a tool runs with the model just picked. */ + readFeatureConfig: (userId: string, feature: FeatureTab) => Promise; }; // The columns each setting is stored in, in the website's user_settings row. @@ -41,6 +51,8 @@ const SettingsContext = createContext({ setLanguage: async () => {}, setOutputLanguage: async () => {}, setTheme: async () => {}, + setFeatureConfig: async () => {}, + readFeatureConfig: fetchFeatureConfig, }); function isThemePreference(value: unknown): value is ThemePreference { @@ -76,13 +88,7 @@ async function defaultModelFor(userId: string): Promise { .eq('user_id', userId) .maybeSingle(); if (error || !data) return null; - const pro = await canAccessProModels(data, async () => { - const { data: rows, error: billingError } = await supabase - .from('subscriptions') - .select('status, current_period_end') - .eq('user_id', userId); - return billingError ? null : rows ?? []; - }); + const pro = await canUseProModels(userId, data); if (pro === null) return null; return pro ? DEFAULT_PRO_MODEL : DEFAULT_MODEL; } @@ -119,6 +125,32 @@ async function writeSetting(userId: string, key: SettingKey, value: string, conf if (error) throw new Error(error.message); } +/** + * Writes one tool's change into the row's feature_configs, as the website's + * setFeatureConfig saves the whole object: every tool's config read fresh and + * normalised, the website's defaults filled in where none was saved (so an + * empty feature_configs is filled here too), and the change applied. A Pro + * model is refused without Pro access, and nothing is written when Pro access + * can't be read. + */ +async function writeFeatureConfig(userId: string, feature: FeatureTab, change: FeatureConfigChange) { + const { data, error } = await supabase + .from('user_settings') + .select('feature_configs, subscription_tier, grace_period_ends_at') + .eq('user_id', userId) + .maybeSingle(); + if (error) throw new Error(error.message); + if (!data) throw new Error('No settings row for this account'); + const pro = await canUseProModels(userId, data); + if (pro === null) throw new Error('Could not read Pro access'); + const featureConfigs = featureConfigsWith(data.feature_configs, pro, feature, change); + const { error: writeError } = await supabase + .from('user_settings') + .update({ feature_configs: featureConfigs, updated_at: new Date().toISOString() }) + .eq('user_id', userId); + if (writeError) throw new Error(writeError.message); +} + /** * Interface language, AI output language and theme from the account's * user_settings row, shared with the website. The last known values are kept @@ -237,13 +269,33 @@ export function SettingsProvider({ children }: { children: ReactNode }) { } }, [apply, writes]); + // The picker shows its own optimistic value and rolls it back on failure (model-picker.tsx). + const setFeatureConfig = useCallback(async (feature: FeatureTab, change: FeatureConfigChange) => { + const forUser = owner.current; + if (!forUser) throw new Error('Sign in to choose a model'); + const gen = generation.current; + await writes(async () => { + if (generation.current !== gen) return; + await writeFeatureConfig(forUser, feature, change); + if (generation.current !== gen) return; + configsSaved.current = true; + }); + }, [writes]); + + const readFeatureConfig = useCallback( + (forUser: string, feature: FeatureTab) => writes(() => fetchFeatureConfig(forUser, feature)), + [writes], + ); + const value = useMemo(() => ({ ...settings, ready, setLanguage: (language) => save('language', language), setOutputLanguage: (language) => save('outputLanguage', language), setTheme: (theme) => save('theme', theme), - }), [settings, ready, save]); + setFeatureConfig, + readFeatureConfig, + }), [settings, ready, save, setFeatureConfig, readFeatureConfig]); return {children}; } From 99f8fb7834246e86240a243baef3084a8ad00e00 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 13:18:20 +0000 Subject: [PATCH 05/12] Bound a tool's wait for earlier settings writes A tool reads its model behind pending settings writes so it runs with the model just picked. A write that hangs would have held Generate, Attack, Brainstorm, Factoids and Ask back with it; after five seconds the read now goes ahead on its own. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/lib/settings.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/lib/settings.tsx b/apps/mobile/src/lib/settings.tsx index 004919685..b81846991 100644 --- a/apps/mobile/src/lib/settings.tsx +++ b/apps/mobile/src/lib/settings.tsx @@ -73,6 +73,9 @@ function parseSettings(value: unknown): Settings | null { const cacheKey = (userId: string) => `debatelab.settings.${userId}`; +/** How long a tool waits for earlier settings writes before reading its model anyway. */ +const READ_WAIT_MS = 5000; + function isEmptyObject(value: unknown): boolean { return !!value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0; } @@ -282,8 +285,22 @@ export function SettingsProvider({ children }: { children: ReactNode }) { }); }, [writes]); + // Queued behind earlier writes; a write that hangs delays a tool by at most READ_WAIT_MS, then it reads anyway. const readFeatureConfig = useCallback( - (forUser: string, feature: FeatureTab) => writes(() => fetchFeatureConfig(forUser, feature)), + (forUser: string, feature: FeatureTab) => + new Promise((resolve, reject) => { + const fallback = setTimeout(() => void fetchFeatureConfig(forUser, feature).then(resolve, reject), READ_WAIT_MS); + writes(() => fetchFeatureConfig(forUser, feature)).then( + (config) => { + clearTimeout(fallback); + resolve(config); + }, + (cause: unknown) => { + clearTimeout(fallback); + reject(cause); + }, + ); + }), [writes], ); From 6d062f2b919443f2e7867ce5002b9f1787db5968 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 13:42:52 +0000 Subject: [PATCH 06/12] Shoot History, announcements, You and the model picker The screenshot script gains a SCREENS filter to run only some groups, and groups for History (Current Debate, chats, the scope menu, another debate and the unsorted view from made-up rows), the announcement banner from two made-up announcements with a local dismissal, You down to its data rows with Download my data (read-only, iphone-light-en) and the Delete all my data sheet, never confirmed, and the Generate model picker with its menu open, never chosen. The Delete all my data field's email placeholder is masked too. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/scripts/screenshots.ts | 331 ++++++++++++++++++++++++----- 1 file changed, 282 insertions(+), 49 deletions(-) diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index d4c27ca49..1ae395593 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. + * SCREENS=history,announcements limits it to those groups of screens (see SCREEN_GROUPS). * * 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 @@ -52,6 +53,17 @@ * none for one-off work, which shows the starter map) and answers writes * locally. The one-off shot turns one-off mode on in memory, as the switcher * does, and never taps a debate row. + * + * History reads the account's real saved work for the Current Debate and + * another debate, and the unsorted view answers its reads (rows with no debate) + * with made-up work, one item suggesting the Current Debate. Add to debate is + * shown, never pressed. The announcement banner comes from two made-up + * announcements answered inside the page; its dismissal is answered locally. + * You is shot down to its data rows; Download my data reads the account's real + * export once (iphone-light-en) and writes only its top-level keys next to the + * screenshots. The Delete all my data sheet is shot empty and never confirmed. + * The model picker in Generate is shot with its menu open; no model is chosen, + * so feature_configs is never written. */ import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page } from '@playwright/test'; import { createReadStream, existsSync, statSync } from 'node:fs'; @@ -68,6 +80,11 @@ const OUT = join(__dirname, '../../../.verify/mobile-shots'); const PORT = Number(process.env.PORT); const DEMO_CODE = process.env.STAGING_DEMO_CODE?.trim(); const ONLY = process.env.ONLY?.trim() ?? ''; +const SCREEN_GROUPS = ['sign-in', 'offline', 'debate', 'library', 'tabs', 'empty', 'ask', 'results', 'tools', 'timer', 'crossfire', 'flowchart', 'oneoff', 'history', 'announcements', 'account', 'model'] as const; +const SCREENS = (process.env.SCREENS ?? '').split(',').map((group) => group.trim()).filter(Boolean); +const unknownGroups = SCREENS.filter((group) => !(SCREEN_GROUPS as readonly string[]).includes(group)); +if (unknownGroups.length > 0) throw new Error(`Unknown SCREENS: ${unknownGroups.join(', ')}`); +const wants = (group: (typeof SCREEN_GROUPS)[number]) => SCREENS.length === 0 || SCREENS.includes(group); const ORIGIN = `http://127.0.0.1:${PORT}`; const STAGING_HOST = new URL(SUPABASE_PROJECTS.staging.url).host; const SESSION_KEY = `sb-${STAGING_HOST.split('.')[0]}-auth-token`; @@ -121,7 +138,8 @@ function watchErrors(page: Page, problems: string[], label: string) { async function shoot(page: Page, name: string) { // Let fonts and the colour scheme settle after hydration. await page.evaluate(() => document.fonts.ready); - await page.screenshot({ path: join(OUT, `${name}.png`), mask: [page.getByTestId('account-email')] }); + // The Delete all my data field shows the email as its placeholder, as on the website. + await page.screenshot({ path: join(OUT, `${name}.png`), mask: [page.getByTestId('account-email'), page.getByTestId('delete-data-confirmation')] }); } /** Answers user_settings reads with the run's language and the System theme (see the header). */ @@ -911,6 +929,181 @@ async function shootEmptyDebate(page: Page, prefix: string) { await shoot(page, `${prefix}-debate-empty-oneoff`); } +const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*', 'access-control-allow-methods': 'GET, POST, PATCH, OPTIONS' }; +const iso = (offsetMs: number) => new Date(Date.now() + offsetMs).toISOString(); + +/** Made-up work outside every debate, answered to the unsorted view's reads; the case suggests the Current Debate. */ +async function fakeUnsorted(page: Page, motion: string) { + const rows: Record = { + cases: [{ id: '00000000-0000-4000-8000-0000000c0001', title: motion, created_at: iso(-2 * 3600_000), motion, case_side: 'pro' }], + brainstorms: [{ id: '00000000-0000-4000-8000-0000000b0001', title: 'School uniforms should be optional', created_at: iso(-26 * 3600_000), motion: 'School uniforms should be optional' }], + chats: [{ id: '00000000-0000-4000-8000-0000000a0001', title: 'How long should a rebuttal be?', created_at: iso(-50 * 3600_000), messages: [{ count: 4 }] }], + sources: [{ id: '00000000-0000-4000-8000-0000000d0001', title: 'City air quality report 2025', created_at: iso(-80 * 3600_000) }], + }; + await page.route((url) => url.href.startsWith(REST) && url.search.includes('debate_id=is.null'), (route) => { + if (route.request().method() !== 'GET') return route.abort(); + const table = new URL(route.request().url()).pathname.split('/').pop() ?? ''; + return route.fulfill({ status: 200, headers: CORS, json: rows[table] ?? [] }); + }); +} + +/** History from the Debate tab's See all: the Current Debate, its chats, the scope menu, another debate and the unsorted view. */ +async function shootHistory(context: BrowserContext, prefix: string, language: string, problems: string[]) { + const page = await context.newPage(); + watchErrors(page, problems, `${prefix} history`); + await pinSettings(page, language); + await page.goto(`${ORIGIN}/`); + await page.getByTestId('current-debate-motion').waitFor({ timeout: 30_000 }); + const motion = (await page.getByTestId('current-debate-motion').textContent())?.trim() ?? ''; + await fakeUnsorted(page, motion); + // The Debate tab stays in the page under History; let its recent work finish loading first. + await settle(page); + await page.getByRole('button', { name: /^(See all|Összes|Ver todo)$/ }).click(); + await page.getByTestId('history-scope').waitFor({ timeout: 30_000 }); + await settle(page); + await shoot(page, `${prefix}-history`); + await page.getByTestId('history-tab-chats').click(); + await shoot(page, `${prefix}-history-chats`); + + await page.getByTestId('history-scope').click(); + const menu = page.getByTestId('history-scope-menu'); + await menu.waitFor(); + await shoot(page, `${prefix}-history-scope-menu`); + const items = menu.getByRole('menuitem'); + // The last item is one-off and unsorted work; the others are debates, the current one named with its motion. + const others = (await items.count()) - 1; + let other = -1; + for (let index = 0; index < others; index += 1) { + if (!(await items.nth(index).innerText()).includes(motion)) { + other = index; + break; + } + } + if (other >= 0) { + await items.nth(other).click(); + await settle(page); + await shoot(page, `${prefix}-history-other`); + await page.getByTestId('history-scope').click(); + await menu.waitFor(); + } else { + console.log(`${prefix}: the account has one debate, so History shows no other debate`); + } + await items.last().click(); + await page.getByTestId('unsorted-target').first().waitFor({ timeout: 30_000 }); + await settle(page); + await shoot(page, `${prefix}-history-unsorted`); + await page.close(); +} + +const ANNOUNCEMENT_TEXT: Record = { + en: [ + { title: 'Practice rounds this Saturday', body: 'Sign up with your coach by Friday for a mixed-school practice round.' }, + { title: 'Your saved work, in one place', body: 'Every case, chat and brainstorm now opens from History.' }, + ], + hu: [ + { title: 'Gyakorló fordulók szombaton', body: 'Péntekig jelentkezz az edződnél egy vegyes iskolai gyakorló fordulóra.' }, + { title: 'Mentett munkáid egy helyen', body: 'Minden eseted, beszélgetésed és ötletelésed az Előzményekből nyílik meg.' }, + ], +}; + +/** Answers the announcements function with two made-up announcements; a dismissal is answered locally. */ +async function fakeAnnouncements(page: Page, language: string) { + const announcements = (ANNOUNCEMENT_TEXT[language] ?? ANNOUNCEMENT_TEXT.en).map((text, index) => ({ + id: `00000000-0000-4000-8000-0000000e000${index + 1}`, + title: text.title, + body: text.body, + starts_at: iso(-3600_000), + expires_at: index === 0 ? iso(7 * 86_400_000) : null, + created_at: iso(-3600_000), + })); + const dismissed = new Set(); + await page.route(`https://${STAGING_HOST}/functions/v1/announcements*`, (route) => { + const request = route.request(); + if (request.method() === 'POST') { + const id = (request.postDataJSON() as { announcementId?: string } | null)?.announcementId; + if (id) dismissed.add(id); + return route.fulfill({ status: 200, headers: CORS, json: { success: true } }); + } + if (request.method() === 'GET') { + return route.fulfill({ status: 200, headers: CORS, json: { announcements: announcements.filter((item) => !dismissed.has(item.id)) } }); + } + return route.fulfill({ status: 204, headers: CORS }); + }); +} + +async function shootAnnouncements(context: BrowserContext, prefix: string, language: string, problems: string[]) { + const page = await context.newPage(); + watchErrors(page, problems, `${prefix} announcements`); + await pinSettings(page, language); + await fakeAnnouncements(page, language); + await page.goto(`${ORIGIN}/`); + const banner = page.getByTestId('announcement-banner'); + await banner.waitFor({ timeout: 30_000 }); + await settle(page); + await shoot(page, `${prefix}-announcement`); + const second = (ANNOUNCEMENT_TEXT[language] ?? ANNOUNCEMENT_TEXT.en)[1].title; + await page.getByTestId('announcement-dismiss').click(); + await banner.getByText(second).waitFor({ timeout: 15_000 }); + await shoot(page, `${prefix}-announcement-next`); + await page.close(); +} + +/** You down to its data rows, Download my data (once, read-only) and the Delete all my data sheet, never confirmed. */ +async function shootAccount(context: BrowserContext, prefix: string, language: string, problems: string[]) { + const page = await context.newPage(); + watchErrors(page, problems, `${prefix} account`); + await pinSettings(page, language); + await page.goto(`${ORIGIN}/you`); + await page.getByTestId('subscription-tier').waitFor({ timeout: 30_000 }); + await settle(page); + await page.getByTestId('you-delete-data').scrollIntoViewIfNeeded(); + await shoot(page, `${prefix}-you-account`); + + if (prefix === 'iphone-light-en') { + await page.getByTestId('you-download-data').click(); + const exported = await page.waitForFunction( + () => (window as unknown as { __debatelabExport?: { json?: string; fileName?: string } }).__debatelabExport?.json ? (window as unknown as { __debatelabExport: { json: string; fileName: string } }).__debatelabExport : null, + undefined, + { timeout: 60_000 }, + ).then((handle) => handle.jsonValue() as Promise<{ json: string; fileName: string }>); + const data = JSON.parse(exported.json) as Record; + const shape = Object.fromEntries(Object.entries(data).map(([key, value]) => [key, Array.isArray(value) ? `array(${value.length})` : typeof value])); + await writeFile(join(OUT, `${prefix}-download-data-shape.json`), JSON.stringify({ fileName: exported.fileName, keys: shape }, null, 2)); + console.log(`${prefix}: Download my data gave ${exported.fileName} with keys ${Object.keys(shape).join(', ')}`); + } + + await page.getByTestId('you-delete-data').click(); + await page.getByTestId('delete-data-confirmation').waitFor({ timeout: 15_000 }); + await page.waitForTimeout(700); + await shoot(page, `${prefix}-delete-data`); + await page.close(); +} + +/** The model picker in Generate, and its menu; nothing is chosen. */ +async function shootModelPicker(context: BrowserContext, prefix: string, language: string, problems: string[]) { + const page = await context.newPage(); + watchErrors(page, problems, `${prefix} model picker`); + await pinSettings(page, language); + await page.goto(`${ORIGIN}/generate?from=prepare`); + await page.getByTestId('generate-motion').waitFor({ timeout: 30_000 }); + await page.locator('[data-testid="model-picker-generate"]:not([aria-disabled="true"])').waitFor({ timeout: 30_000 }); + await page.getByTestId('model-picker-generate').scrollIntoViewIfNeeded(); + await shoot(page, `${prefix}-generate-model`); + await page.getByTestId('model-picker-generate').click(); + await page.getByRole('menu').waitFor(); + await shoot(page, `${prefix}-model-menu`); + await page.close(); +} + +/** Shoots whatever page of the group is open when it fails, and names the group in the error. */ +function failedIn(context: BrowserContext, prefix: string, group: string) { + return async (error: unknown) => { + const page = context.pages().at(-1); + if (page) await shoot(page, `${prefix}-failed-${group}`).catch(() => undefined); + throw new Error(`${group}: ${error instanceof Error ? error.message : String(error)}`); + }; +} + async function signIn(browser: Browser, problems: string[]): Promise { const context = await browser.newContext({ ...DEVICES.iphone, locale: LOCALES.en }); const page = await context.newPage(); @@ -992,44 +1185,51 @@ async function main() { let shots = 0; try { const session = DEMO_CODE ? await signIn(browser, problems) : null; - if (session) await checkOfflineColdStart(browser, session, problems); - else console.log('STAGING_DEMO_CODE is not set: signed-in screens skipped'); + if (!session) console.log('STAGING_DEMO_CODE is not set: signed-in screens skipped'); + else if (wants('offline')) await checkOfflineColdStart(browser, session, problems); for (const [device, options] of Object.entries(DEVICES)) { for (const scheme of SCHEMES) { for (const [language, locale] of Object.entries(LOCALES)) { const prefix = `${device}-${scheme}-${language}`; if (!prefix.startsWith(ONLY)) continue; - const signedOut = await browser.newContext({ ...options, colorScheme: scheme, locale }); - const page = await signedOut.newPage(); - watchErrors(page, problems, prefix); - await page.goto(`${ORIGIN}/sign-in`); - await page.getByRole('tablist').waitFor(); - await shoot(page, `${prefix}-sign-in`); - await page.getByRole('button').first().click(); - await page.getByRole('alert').waitFor(); - await shoot(page, `${prefix}-sign-in-error`); - await page.getByRole('tab').nth(1).click(); - await shoot(page, `${prefix}-sign-in-demo`); - shots += 3; - await signedOut.close(); + if (wants('sign-in')) { + const signedOut = await browser.newContext({ ...options, colorScheme: scheme, locale }); + const page = await signedOut.newPage(); + watchErrors(page, problems, prefix); + await page.goto(`${ORIGIN}/sign-in`); + await page.getByRole('tablist').waitFor(); + await shoot(page, `${prefix}-sign-in`); + await page.getByRole('button').first().click(); + await page.getByRole('alert').waitFor(); + await shoot(page, `${prefix}-sign-in-error`); + await page.getByRole('tab').nth(1).click(); + await shoot(page, `${prefix}-sign-in-demo`); + shots += 3; + await signedOut.close(); + } if (!session) continue; const signedIn = await browser.newContext({ ...options, colorScheme: scheme, locale, storageState: session }); const tabPage = await signedIn.newPage(); watchErrors(tabPage, problems, `${prefix} signed in`); await pinSettings(tabPage, language); - await shootDebateScreens(tabPage, prefix).catch(async (error: unknown) => { - await shoot(tabPage, `${prefix}-failed`); - throw error; - }); - shots += 6; - await shootLibraryScreens(tabPage, prefix).catch(async (error: unknown) => { - await shoot(tabPage, `${prefix}-failed`); - throw error; - }); - shots += 5; + if (wants('debate')) { + await shootDebateScreens(tabPage, prefix).catch(async (error: unknown) => { + await shoot(tabPage, `${prefix}-failed`); + throw error; + }); + shots += 6; + } + if (wants('library')) { + await shootLibraryScreens(tabPage, prefix).catch(async (error: unknown) => { + await shoot(tabPage, `${prefix}-failed`); + throw error; + }); + shots += 5; + } for (const [name, route] of Object.entries(SIGNED_IN_ROUTES)) { + if (!wants('tabs') && !(name === 'you' && wants('account'))) continue; await tabPage.goto(`${ORIGIN}${route}`); await tabPage.getByRole('tablist').first().waitFor(); if (name === 'you') await tabPage.getByTestId('subscription-tier').waitFor({ timeout: 30_000 }); @@ -1037,31 +1237,64 @@ async function main() { await shoot(tabPage, `${prefix}-${name}`); shots += 1; } - const emptyPage = await signedIn.newPage(); - watchErrors(emptyPage, problems, `${prefix} empty`); - await pinSettings(emptyPage, language); - await shootEmptyDebate(emptyPage, prefix); - shots += 2; - await shootAskScreens(signedIn, prefix, language, problems); - shots += 3; - await shootResultScreens(signedIn, prefix, language, problems); - shots += 9; - for (const tool of ['brainstorm', 'factoids'] as const) { - await shootTool(signedIn, prefix, language, problems, tool); + if (wants('empty')) { + const emptyPage = await signedIn.newPage(); + watchErrors(emptyPage, problems, `${prefix} empty`); + await pinSettings(emptyPage, language); + await shootEmptyDebate(emptyPage, prefix); + shots += 2; + } + if (wants('ask')) { + await shootAskScreens(signedIn, prefix, language, problems); + shots += 3; + } + if (wants('results')) { + await shootResultScreens(signedIn, prefix, language, problems); + shots += 9; + } + if (wants('tools')) { + for (const tool of ['brainstorm', 'factoids'] as const) { + await shootTool(signedIn, prefix, language, problems, tool); + shots += 6; + } + } + if (wants('timer')) { + await shootTimerScreens(signedIn, prefix, language, problems); + shots += 3; + } + if (wants('crossfire')) { + await shootCrossfireScreens(signedIn, prefix, language, problems); + shots += 3; + } + if (wants('flowchart')) { + await shootFlowchart(signedIn, prefix, language, problems); + shots += 10; + } + if (wants('oneoff')) { + const oneOffPage = await signedIn.newPage(); + watchErrors(oneOffPage, problems, `${prefix} one-off tools`); + await pinSettings(oneOffPage, language); + await shootOneOffTools(oneOffPage, prefix); + await oneOffPage.close(); + shots += 2; + } + if (wants('history')) { + await shootHistory(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'history')); shots += 6; } - await shootTimerScreens(signedIn, prefix, language, problems); - shots += 3; - await shootCrossfireScreens(signedIn, prefix, language, problems); - shots += 3; - await shootFlowchart(signedIn, prefix, language, problems); - shots += 10; - const oneOffPage = await signedIn.newPage(); - watchErrors(oneOffPage, problems, `${prefix} one-off tools`); - await pinSettings(oneOffPage, language); - await shootOneOffTools(oneOffPage, prefix); - await oneOffPage.close(); - shots += 2; + if (wants('announcements')) { + await shootAnnouncements(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'announcements')); + shots += 2; + } + if (wants('account')) { + await shootAccount(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'account')); + shots += 2; + } + if (wants('model')) { + await shootModelPicker(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'model')); + shots += 2; + } + await tabPage.close(); await signedIn.close(); } } From fcbfceb473d192ede48714796e1d522223aabb3c Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 13:48:17 +0000 Subject: [PATCH 07/12] Name History's Added to debate notice the same when nothing is left unsorted Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/history.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/app/history.tsx b/apps/mobile/src/app/history.tsx index f7c5e8f8b..12017e850 100644 --- a/apps/mobile/src/app/history.tsx +++ b/apps/mobile/src/app/history.tsx @@ -393,7 +393,7 @@ function UnsortedList({ {t('debate.unsortedEmptyDesc')} {notice ? ( - + {notice} ) : null} From 0e391e2a5e1b7d8e48b39c23a3d9f2a14c9668a9 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 14:02:03 +0000 Subject: [PATCH 08/12] Open and delete Sparring rounds from History History's Sparring tab now works as the website's HistoryPage does: a row opens the round's scorecard at sparring/[id] with History as its back label, and swiping deletes it after the website's confirmation through deleteSparringRound, which filters on the user. Deleting the finished round the Sparring screen still holds forgets it there too. The empty tab starts a round in the app. Unsorted rounds keep Add to debate through attachToDebate, which passes the student's side and checks the signed-in account; the screenshot script now shoots the Sparring tab. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 2 +- apps/mobile/scripts/screenshots.ts | 4 ++- apps/mobile/src/app/history.tsx | 57 +++++++++++++++++++++--------- 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 40e4ed62b..6565e4fd2 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -28,7 +28,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp - Crossfire is the website's typed mode: `crossfire.ts` sends `crossfire` and its signal requests as `src/lib/ai/client.ts` does (Ask's model settings, 75 s and 5 s deadlines) and saves a finished round to `crossfires` in its debate, as CrossfirePage's `recordResult` call; the reducer and schemas are `packages/core/src/crossfire`. The unfinished round is a session slot, so leaving the screen keeps it. Voice rounds need the development build, so the voice switch shows disabled. The website cannot reopen a saved round, so neither can the app. - 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`). -- History mirrors `src/pages/HistoryPage.tsx` and `UnsortedWork.tsx`: `history.ts` reads each table for one debate, or every kind plus Library sources outside all debates, and adds unsorted work to a debate through the website's `attach_to_debate` function. Chats reopen in Ask (`/ask?chat=`). Sparring rounds are listed read-only until Sparring is in the app. +- History mirrors `src/pages/HistoryPage.tsx` and `UnsortedWork.tsx`: `history.ts` reads each table for one debate, or every kind plus Library sources outside all debates, and adds unsorted work to a debate through the website's `attach_to_debate` function. Chats reopen in Ask (`/ask?chat=`). A Sparring round opens `sparring/[id]` and swipes to delete as a case does (`deleteSparringRound`); an unsorted round is added to a debate with the side the student spoke, through `attachToDebate`, which also checks the signed-in account. - Announcements mirror the website's banner (`AppLayout.tsx`, `AnnouncementBanner.tsx`) at the top of the Debate tab: `announcements.ts` lists and dismisses through the `announcements` Edge Function, and `use-announcements.ts` keeps the website's startsAt/expiresAt filter (shared in `packages/core/src/announcements.ts`), its one-minute reload guard, and reloads on focus, foreground and account change. Dismissals are stored on the server, so they are shared with the website. - You mirrors the website's Settings for what the app can do: Download my data (`exportAccountData` in `account.ts`, the `account` function's export written as `debatelab-data-.json` and shared through `shareJson`), Delete all my data (`delete-data.tsx`, the email-typed confirmation and `clear_current_user_data`; afterwards session memory is forgotten, one-off mode turns off and the debates reload, as the website clears its stores), and Terms and Privacy, opened in the in-app browser from `app.json`'s `extra.websiteUrl` (`website.ts`). Upgrade, Manage subscription and promo codes wait for in-app purchase. - 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. diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index f672e32ce..420409049 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -1111,6 +1111,8 @@ async function shootHistory(context: BrowserContext, prefix: string, language: s await shoot(page, `${prefix}-history`); await page.getByTestId('history-tab-chats').click(); await shoot(page, `${prefix}-history-chats`); + await page.getByTestId('history-tab-sparring').click(); + await shoot(page, `${prefix}-history-sparring`); await page.getByTestId('history-scope').click(); const menu = page.getByTestId('history-scope-menu'); @@ -1431,7 +1433,7 @@ async function main() { } if (wants('history')) { await shootHistory(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'history')); - shots += 6; + shots += 7; } if (wants('announcements')) { await shootAnnouncements(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'announcements')); diff --git a/apps/mobile/src/app/history.tsx b/apps/mobile/src/app/history.tsx index 12017e850..abd2c9105 100644 --- a/apps/mobile/src/app/history.tsx +++ b/apps/mobile/src/app/history.tsx @@ -32,8 +32,33 @@ import { import { keepIfSame } from '@/lib/keep-if-same'; import { suggestDebate } from '@/lib/motion'; import { deleteResult } from '@/lib/results'; +import { deleteSparringRound, sparringDraft } from '@/lib/sparring'; import { useTheme } from '@/theme/use-theme'; +/** What the History lists delete, with the website's confirmation and failure copy. */ +const DELETIONS: Record<'case' | 'sparring', { tab: HistoryTab; title: string; desc: string; failed: string; label: string; run: (userId: string, id: string) => Promise }> = { + case: { + tab: 'cases', + title: 'historyPage.deleteCaseTitle', + desc: 'historyPage.deleteCaseDesc', + failed: 'historyPage.deleteFailed', + label: 'historyPage.deleteCaseLabel', + run: (userId, id) => deleteResult(userId, 'case', id), + }, + sparring: { + tab: 'sparring', + title: 'historyPage.deleteSparringTitle', + desc: 'historyPage.deleteSparringDesc', + failed: 'historyPage.sparringDeleteFailed', + label: 'historyPage.deleteSparringAria', + run: async (userId, id) => { + await deleteSparringRound(userId, id); + // The finished round on the Sparring screen is this one; another round, finished or not, stays. + if (sparringDraft.get()?.id === id) sparringDraft.set(null); + }, + }, +}; + /** The History scope for work that belongs to no debate, as the website's OUTSIDE_DEBATES. */ const OUTSIDE = 'outside'; @@ -47,21 +72,19 @@ const EMPTY: Record => { const forUser = owner.current; - if (!forUser || item.kind !== 'case') return false; + const deletion = item.kind === 'case' || item.kind === 'sparring' ? DELETIONS[item.kind] : null; + if (!forUser || !deletion) return false; const confirmed = await confirmDestructive({ - title: t('historyPage.deleteCaseTitle'), - message: t('historyPage.deleteCaseDesc'), + title: t(deletion.title), + message: t(deletion.desc), confirm: t('common.delete'), cancel: t('common.cancel'), }); if (!confirmed || owner.current !== forUser) return false; try { - await deleteResult(forUser, 'case', item.id); + await deletion.run(forUser, item.id); } catch (cause) { - console.warn('Could not delete the case:', cause instanceof Error ? cause.message : cause); - if (live.current && owner.current === forUser) showNotice(t('historyPage.deleteFailed')); + console.warn(`Could not delete the ${item.kind}:`, cause instanceof Error ? cause.message : cause); + if (live.current && owner.current === forUser) showNotice(t(deletion.failed)); return false; } if (!live.current || owner.current !== forUser) return true; // Any load still running read the list before this delete. loads.current += 1; + const list = deletion.tab; setLoaded((previous) => previous?.kind === 'debate' - ? { ...previous, lists: { ...previous.lists, cases: previous.lists.cases.filter((entry) => entry.id !== item.id) } } + ? { ...previous, lists: { ...previous.lists, [list]: previous.lists[list].filter((entry) => entry.id !== item.id) } } : previous); return true; }; @@ -282,8 +308,7 @@ function TabList({ tab, items, onDelete }: { tab: HistoryTab; items: HistoryItem item={item} first={index === 0} onPress={href ? () => router.push(href) : undefined} - // The website deletes cases and Sparring rounds here; rounds wait for Sparring in the app. - onDelete={item.kind === 'case' ? () => onDelete(item) : undefined} + onDelete={item.kind === 'case' || item.kind === 'sparring' ? () => onDelete(item) : undefined} /> ); })} @@ -306,7 +331,7 @@ function itemMeta(t: ReturnType['t'], item: HistoryItem | return [t('mobile.debate.itemMeta', { kind, when }), ...parts].join(' · '); } -/** One saved result. Tapping opens it where the app can; a case swipes left to delete, with the website's confirmation. */ +/** One saved result. Tapping opens it where the app can; a case or a Sparring round swipes left to delete, with the website's confirmation. */ function HistoryRow({ item, first, onPress, onDelete }: { item: HistoryItem; first: boolean; onPress?: () => void; onDelete?: () => Promise }) { const { t } = useTranslation(); const { colors } = useTheme(); @@ -355,7 +380,7 @@ function HistoryRow({ item, first, onPress, onDelete }: { item: HistoryItem; fir renderRightActions={() => ( void remove()} style={[styles.deleteAction, { backgroundColor: colors.destructive }]}> From efe5580c2a9fbbccacb762588676a75505d88cd2 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 21:46:37 +0000 Subject: [PATCH 09/12] Send Delete all my data with the confirmed account's token clearAllData checked that the session was still the confirmed account, but supabase-js read the session again when it sent the RPC. An account signed in between the two would have had its data deleted. The request now carries the access token from the session that passed the check. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/lib/account.test.ts | 34 +++++++++++++++++++++++++ apps/mobile/src/lib/account.ts | 13 ++++++---- apps/mobile/src/lib/owner-check.test.ts | 5 ---- 3 files changed, 42 insertions(+), 10 deletions(-) create mode 100644 apps/mobile/src/lib/account.test.ts diff --git a/apps/mobile/src/lib/account.test.ts b/apps/mobile/src/lib/account.test.ts new file mode 100644 index 000000000..3d5b146d5 --- /dev/null +++ b/apps/mobile/src/lib/account.test.ts @@ -0,0 +1,34 @@ +import { createClient } from '@supabase/supabase-js'; +import { describe, expect, it, vi } from 'vitest'; + +// A real supabase-js client, so the test sees the Authorization header it actually sends. +const requests = vi.hoisted(() => [] as Request[]); +vi.mock('./supabase', () => ({ + supabase: createClient('http://127.0.0.1:54321', 'anon-key', { + auth: { persistSession: false, autoRefreshToken: false }, + global: { + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push(new Request(input, init)); + return new Response(null, { status: 204 }); + }, + }, + }), +})); + +import { clearAllData } from './account'; +import { supabase } from './supabase'; + +const sessionFor = (id: string) => ({ data: { session: { user: { id }, access_token: `token-${id}` } }, error: null }); + +describe('clearAllData', () => { + it('deletes with the confirmed account\'s token even if another account signs in before the request is sent', async () => { + // The first read is the owner check; every later read (supabase-js attaching auth) sees account B. + vi.spyOn(supabase.auth, 'getSession') + .mockResolvedValueOnce(sessionFor('user-a') as never) + .mockResolvedValue(sessionFor('user-b') as never); + await clearAllData('user-a'); + expect(requests).toHaveLength(1); + expect(new URL(requests[0].url).pathname).toBe('/rest/v1/rpc/clear_current_user_data'); + expect(requests[0].headers.get('Authorization')).toBe('Bearer token-user-a'); + }); +}); diff --git a/apps/mobile/src/lib/account.ts b/apps/mobile/src/lib/account.ts index 0d78f76df..0e98e63bc 100644 --- a/apps/mobile/src/lib/account.ts +++ b/apps/mobile/src/lib/account.ts @@ -37,13 +37,16 @@ export async function exportAccountData(now: Date = new Date()): Promise<{ fileN /** * Deletes the account's saved work, sources, debates and API keys through the - * website's clear_current_user_data function, which acts for whoever holds the - * session. The session must still be the account the student confirmed for. - * Settings and the subscription stay. + * website's clear_current_user_data function, which acts for whoever the + * request's token belongs to. The session must still be the account the + * student confirmed for, and the request carries that session's token: left to + * supabase-js, it would read the session again when sending, and an account + * signed in meanwhile would lose its data. Settings and the subscription stay. */ export async function clearAllData(userId: string): Promise { const { data } = await supabase.auth.getSession(); - if (data.session?.user.id !== userId) throw new Error('The signed-in account changed'); - const { error } = await supabase.rpc('clear_current_user_data'); + const session = data.session; + if (!session || session.user.id !== userId) throw new Error('The signed-in account changed'); + const { error } = await supabase.rpc('clear_current_user_data').setHeader('Authorization', `Bearer ${session.access_token}`); if (error) throw new Error(error.message); } diff --git a/apps/mobile/src/lib/owner-check.test.ts b/apps/mobile/src/lib/owner-check.test.ts index eec9b5e50..dd5b8439a 100644 --- a/apps/mobile/src/lib/owner-check.test.ts +++ b/apps/mobile/src/lib/owner-check.test.ts @@ -27,9 +27,4 @@ describe('account-wide writes after an account switch', () => { await expect(clearAllData('user-a')).rejects.toThrow(); expect(supabase.rpc).not.toHaveBeenCalled(); }); - - it('deletes all data for the account that confirmed', async () => { - await clearAllData('user-b'); - expect(supabase.rpc).toHaveBeenCalledWith('clear_current_user_data'); - }); }); From 1c147e7d11a126da1d137b42091460af007021d9 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 21:48:38 +0000 Subject: [PATCH 10/12] Run tools with the model still being saved after the wait runs out A tool waits up to 5 seconds for earlier settings writes, then reads its config anyway. That read returned the row's previous model while the new pick was still saving. The settings provider now keeps each tool's pending model and reasoning and applies them over the fallback read (leaving out a Pro model the student can't use). Ask also drops its cached config when a save fails, so it never keeps a model that wasn't saved. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/components/model-picker.tsx | 3 + .../src/lib/feature-config-read.test.ts | 41 ++++++++++++ apps/mobile/src/lib/feature-config-read.ts | 43 ++++++++++++ apps/mobile/src/lib/settings.tsx | 67 ++++++++++++------- 4 files changed, 129 insertions(+), 25 deletions(-) create mode 100644 apps/mobile/src/lib/feature-config-read.test.ts create mode 100644 apps/mobile/src/lib/feature-config-read.ts diff --git a/apps/mobile/src/components/model-picker.tsx b/apps/mobile/src/components/model-picker.tsx index b5b0cc90b..9560d6d45 100644 --- a/apps/mobile/src/components/model-picker.tsx +++ b/apps/mobile/src/components/model-picker.tsx @@ -36,6 +36,8 @@ type Field = keyof Choice; * through the settings provider's queue. A change shows at once; a failed * save rolls back only its own field, and only while it is that field's * latest change. Nothing lands after an account change or after leaving. + * `onChange` runs when the student picks and again when a save fails, so a + * caller that kept a config read meanwhile (Ask) drops a model that was not saved. */ function useModelChoice(feature: FeatureTab, onChange?: () => void) { const { session } = useAuth(); @@ -98,6 +100,7 @@ function useModelChoice(feature: FeatureTab, onChange?: () => void) { console.warn('Could not save the model:', cause instanceof Error ? cause.message : cause); if (!live.current || owner.current !== forUser) return; setFailed(true); + onChange?.(); if (versions.current[field] !== version || !confirmed.current) return; const previous = confirmed.current[field]; setState((current) => (current && current.owner === forUser ? { ...current, choice: { ...current.choice, [field]: previous } } : current)); diff --git a/apps/mobile/src/lib/feature-config-read.test.ts b/apps/mobile/src/lib/feature-config-read.test.ts new file mode 100644 index 000000000..532fc5740 --- /dev/null +++ b/apps/mobile/src/lib/feature-config-read.test.ts @@ -0,0 +1,41 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./supabase', () => ({ supabase: {} })); + +import type { FeatureConfig } from './feature-config'; +import { readFeatureConfigAfterWrites } from './feature-config-read'; +import { createSerialQueue } from './serial-queue'; + +const saved: FeatureConfig = { model: 'google/gemini-3.8-flash', temperature: 0.7, reasoningEffort: 'low' }; + +describe('readFeatureConfigAfterWrites', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('runs with the model still being saved when the save outlasts the wait', async () => { + const writes = createSerialQueue(); + // The save of the new model hangs, so the row still holds the old one. + void writes(() => new Promise(() => {})); + const config = readFeatureConfigAfterWrites({ + writes, + read: async () => ({ config: saved, pro: false }), + pending: () => ({ model: 'deepseek/deepseek-v4-flash-0731', reasoningEffort: 'medium' }), + waitMs: 5000, + }); + await vi.advanceTimersByTimeAsync(5000); + await expect(config).resolves.toEqual({ ...saved, model: 'deepseek/deepseek-v4-flash-0731', reasoningEffort: 'medium' }); + }); + + it('keeps the saved model when the pending one needs Pro access the student lacks', async () => { + const writes = createSerialQueue(); + void writes(() => new Promise(() => {})); + const config = readFeatureConfigAfterWrites({ + writes, + read: async () => ({ config: saved, pro: false }), + pending: () => ({ model: 'auto-pro' }), + waitMs: 5000, + }); + await vi.advanceTimersByTimeAsync(5000); + await expect(config).resolves.toEqual(saved); + }); +}); diff --git a/apps/mobile/src/lib/feature-config-read.ts b/apps/mobile/src/lib/feature-config-read.ts new file mode 100644 index 000000000..d0419dfa5 --- /dev/null +++ b/apps/mobile/src/lib/feature-config-read.ts @@ -0,0 +1,43 @@ +import { isProOnlyModel, type FeatureConfig, type FeatureConfigChange } from './feature-config'; +import type { SerialQueue } from './serial-queue'; + +type FeatureSettings = { config: FeatureConfig; pro: boolean }; + +/** + * A tool's config, read in the settings write queue after every write made + * before it, so the tool runs with the model just picked. A write that hangs + * delays the tool by at most `waitMs`. The tool then reads the saved config and + * applies the choice still being saved on top, because the row may still hold + * the model it replaces. That includes a choice made after the tool started: + * it is the latest one either way. A Pro model is left out without Pro access, + * since its save will fail and roll back. + */ +export function readFeatureConfigAfterWrites(options: { + writes: SerialQueue; + read: () => Promise; + pending: () => FeatureConfigChange | null; + waitMs: number; +}): Promise { + const { writes, read, pending, waitMs } = options; + const withPending = ({ config, pro }: FeatureSettings): FeatureConfig => { + const { model, reasoningEffort } = pending() ?? {}; + return { + ...config, + ...(model !== undefined && (pro || !isProOnlyModel(model)) ? { model } : {}), + ...(reasoningEffort !== undefined ? { reasoningEffort } : {}), + }; + }; + return new Promise((resolve, reject) => { + const fallback = setTimeout(() => void read().then((settings) => resolve(withPending(settings)), reject), waitMs); + writes(read).then( + ({ config }) => { + clearTimeout(fallback); + resolve(config); + }, + (cause: unknown) => { + clearTimeout(fallback); + reject(cause); + }, + ); + }); +} diff --git a/apps/mobile/src/lib/settings.tsx b/apps/mobile/src/lib/settings.tsx index b81846991..d10cb2af1 100644 --- a/apps/mobile/src/lib/settings.tsx +++ b/apps/mobile/src/lib/settings.tsx @@ -9,9 +9,11 @@ import { canUseProModels, featureConfigsWith, fetchFeatureConfig, + fetchFeatureSettings, type FeatureConfig, type FeatureConfigChange, } from './feature-config'; +import { readFeatureConfigAfterWrites } from './feature-config-read'; import i18n, { deviceLanguage } from './i18n'; import { createSerialQueue } from './serial-queue'; import { supabase } from './supabase'; @@ -35,7 +37,7 @@ type SettingsContextValue = Settings & { setTheme: (theme: ThemePreference) => Promise; /** Saves one tool's model or reasoning to feature_configs, in order with every other settings write. */ setFeatureConfig: (feature: FeatureTab, change: FeatureConfigChange) => Promise; - /** A tool's saved config, read after every settings write made before it, so a tool runs with the model just picked. */ + /** A tool's config, read after every settings write made before it, so a tool runs with the model just picked. */ readFeatureConfig: (userId: string, feature: FeatureTab) => Promise; }; @@ -73,7 +75,7 @@ function parseSettings(value: unknown): Settings | null { const cacheKey = (userId: string) => `debatelab.settings.${userId}`; -/** How long a tool waits for earlier settings writes before reading its model anyway. */ +/** How long a tool waits for earlier settings writes before reading its model anyway, with the choice still being saved on top. */ const READ_WAIT_MS = 5000; function isEmptyObject(value: unknown): boolean { @@ -180,6 +182,11 @@ export function SettingsProvider({ children }: { children: ReactNode }) { const versions = useRef>({ language: 0, outputLanguage: 0, theme: 0 }); // Whether the row's feature_configs is filled in; null until a load or save tells. const configsSaved = useRef(null); + // Per tool and field, the model or reasoning still being saved, so a tool + // that stops waiting for a slow save still runs with it. Each entry is + // dropped when its save settles, unless a later save of that field replaced it. + const pendingConfigs = useRef(new Map()); + const pendingSeq = useRef(0); // Saves write one at a time in the order they were made, so the last choice // is the last write even when an earlier save's Pro-access check is slower. const [writes] = useState(createSerialQueue); @@ -202,6 +209,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) { owner.current = userId; const gen = ++generation.current; configsSaved.current = null; + pendingConfigs.current.clear(); if (!userId) { apply({ ...SIGNED_OUT, language: deviceLanguage() }, null, gen); confirmed.current = { ...latest.current }; @@ -277,32 +285,41 @@ export function SettingsProvider({ children }: { children: ReactNode }) { const forUser = owner.current; if (!forUser) throw new Error('Sign in to choose a model'); const gen = generation.current; - await writes(async () => { - if (generation.current !== gen) return; - await writeFeatureConfig(forUser, feature, change); - if (generation.current !== gen) return; - configsSaved.current = true; + const seq = ++pendingSeq.current; + const fields = (Object.keys(change) as (keyof FeatureConfigChange)[]).filter((field) => change[field] !== undefined); + const keys = fields.map((field) => { + const key = `${feature}.${field}`; + pendingConfigs.current.set(key, { feature, change: { [field]: change[field] }, seq }); + return key; }); + try { + await writes(async () => { + if (generation.current !== gen) return; + await writeFeatureConfig(forUser, feature, change); + if (generation.current !== gen) return; + configsSaved.current = true; + }); + } finally { + if (generation.current === gen) { + for (const key of keys) if (pendingConfigs.current.get(key)?.seq === seq) pendingConfigs.current.delete(key); + } + } }, [writes]); - // Queued behind earlier writes; a write that hangs delays a tool by at most READ_WAIT_MS, then it reads anyway. - const readFeatureConfig = useCallback( - (forUser: string, feature: FeatureTab) => - new Promise((resolve, reject) => { - const fallback = setTimeout(() => void fetchFeatureConfig(forUser, feature).then(resolve, reject), READ_WAIT_MS); - writes(() => fetchFeatureConfig(forUser, feature)).then( - (config) => { - clearTimeout(fallback); - resolve(config); - }, - (cause: unknown) => { - clearTimeout(fallback); - reject(cause); - }, - ); - }), - [writes], - ); + const readFeatureConfig = useCallback((forUser: string, feature: FeatureTab) => { + const gen = generation.current; + return readFeatureConfigAfterWrites({ + writes, + read: () => fetchFeatureSettings(forUser, feature), + // Only this account's choices, and none from before an owner change. + pending: () => { + if (generation.current !== gen || owner.current !== forUser) return null; + const entries = [...pendingConfigs.current.values()].filter((entry) => entry.feature === feature); + return entries.length ? Object.assign({}, ...entries.map((entry) => entry.change)) as FeatureConfigChange : null; + }, + waitMs: READ_WAIT_MS, + }); + }, [writes]); const value = useMemo(() => ({ ...settings, From f949f9631a1157a7a8bd329db7a784ff9aeb5220 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 21:49:13 +0000 Subject: [PATCH 11/12] Clear the deleted debates even when the reload after Delete all fails After Delete all my data the sheet only reloaded the debates. When that reload failed, the deleted debates and the Current Debate stayed on screen and new work still targeted them. The debate context now drops the account's debates and Current Debate first (and any load already running can't bring them back), then reloads. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/delete-data.tsx | 7 ++++--- apps/mobile/src/lib/debate-context.tsx | 11 +++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/app/delete-data.tsx b/apps/mobile/src/app/delete-data.tsx index 8c70dcbad..aeeaf9bd9 100644 --- a/apps/mobile/src/app/delete-data.tsx +++ b/apps/mobile/src/app/delete-data.tsx @@ -24,7 +24,7 @@ export default function DeleteDataSheet() { const router = useRouter(); const { colors } = useTheme(); const { session } = useAuth(); - const { refresh, setOneOff } = useDebates(); + const { refresh, forgetDebates } = useDebates(); const userId = session?.user.id ?? null; const email = session?.user.email; const [confirmation, setConfirmation] = useState(''); @@ -59,9 +59,10 @@ export default function DeleteDataSheet() { return; } if (owner.current !== forUser) return; - // As the website clears its stores: this session's results and drafts, one-off mode, and the debates, now gone. + // As the website clears its stores: this session's results and drafts, one-off mode, and the debates, now + // gone. They are cleared here first, so a reload that fails leaves nothing deleted on screen or targeted. forgetSessionMemory(); - setOneOff(false); + forgetDebates(forUser); void refresh(); showNotice(t('settings.deleteSuccess'), t('settings.deleteSuccessDesc')); if (live.current) router.back(); diff --git a/apps/mobile/src/lib/debate-context.tsx b/apps/mobile/src/lib/debate-context.tsx index 40b013f72..364b8c412 100644 --- a/apps/mobile/src/lib/debate-context.tsx +++ b/apps/mobile/src/lib/debate-context.tsx @@ -26,6 +26,8 @@ type DebateContextValue = { create: (input: { motion: string; settings: DebateSettings }) => Promise; switchTo: (id: string) => Promise; setOneOff: (oneOff: boolean) => void; + /** After Delete all my data: drops this account's debates and Current Debate here, whether or not a reload works. */ + forgetDebates: (userId: string) => void; }; const DebateContext = createContext(null); @@ -120,6 +122,15 @@ export function DebateProvider({ children }: { children: ReactNode }) { setOneOff(false); }, setOneOff, + forgetDebates: (forUser) => { + if (owner.current !== forUser) return; + // A load that started before the delete must not bring the deleted debates back. + writes.current += 1; + setDebates([]); + setCurrentId(null); + setOneOff(false); + setStatus('ready'); + }, }; }, [status, debates, currentId, oneOff, refresh]); From 8f55dcd9d2a92f9dd35a63a1b3ed8b9b04e5aa7e Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 22:08:16 +0000 Subject: [PATCH 12/12] Keep History readable offline and drop its copies on attach and Delete all History's debate lists and its work outside every debate read through cachedRead, so they answer from a saved copy offline, and the screen holds its copies with useCopyHolder. Every write that already drops a debate's counts and lists now drops that debate's History copy too, and work outside every debate drops the one-off copy. Adding work to a debate from History drops the item's own copy and the lists on both sides, and Delete all my data drops every saved copy before the debates reload. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 2 +- apps/mobile/src/app/history.tsx | 9 ++-- apps/mobile/src/lib/account.test.ts | 1 + apps/mobile/src/lib/account.ts | 3 ++ apps/mobile/src/lib/chats.ts | 4 +- apps/mobile/src/lib/debates.ts | 9 ++-- apps/mobile/src/lib/history.ts | 69 +++++++++++++++++++------ apps/mobile/src/lib/offline-cache.ts | 5 ++ apps/mobile/src/lib/owner-check.test.ts | 1 + 9 files changed, 77 insertions(+), 26 deletions(-) diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 3f67687d1..812a039fb 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -33,7 +33,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp - Announcements mirror the website's banner (`AppLayout.tsx`, `AnnouncementBanner.tsx`) at the top of the Debate tab: `announcements.ts` lists and dismisses through the `announcements` Edge Function, and `use-announcements.ts` keeps the website's startsAt/expiresAt filter (shared in `packages/core/src/announcements.ts`), its one-minute reload guard, and reloads on focus, foreground and account change. Dismissals are stored on the server, so they are shared with the website. - You mirrors the website's Settings for what the app can do: Download my data (`exportAccountData` in `account.ts`, the `account` function's export written as `debatelab-data-.json` and shared through `shareJson`), Delete all my data (`delete-data.tsx`, the email-typed confirmation and `clear_current_user_data`; afterwards session memory is forgotten, one-off mode turns off and the debates reload, as the website clears its stores), and Terms and Privacy, opened in the in-app browser from `app.config.ts`'s `extra.websiteUrl` (`website.ts`). Upgrade, Manage subscription and promo codes wait for in-app purchase. - 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. +- Offline reading: `offline-cache.ts` keeps a copy of every read in `debates.ts`, `sources.ts`, `results.ts`, `flowcharts.ts`, `chats.ts`, `sparring.ts` and `history.ts` (the debates list, counts and recent work, Library sources with their text, saved results, maps, chats, judged Sparring rounds and History's lists) 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. `debateListScopes` also names History's copy (`one-off` for work outside every debate); History's add-to-debate drops the item's copies on both sides, and Delete all my data drops every copy (`ALL_COPIES`). - `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. diff --git a/apps/mobile/src/app/history.tsx b/apps/mobile/src/app/history.tsx index abd2c9105..24ca7c1d4 100644 --- a/apps/mobile/src/app/history.tsx +++ b/apps/mobile/src/app/history.tsx @@ -33,6 +33,7 @@ import { keepIfSame } from '@/lib/keep-if-same'; import { suggestDebate } from '@/lib/motion'; import { deleteResult } from '@/lib/results'; import { deleteSparringRound, sparringDraft } from '@/lib/sparring'; +import { useCopyHolder } from '@/lib/use-copy-holder'; import { useTheme } from '@/theme/use-theme'; /** What the History lists delete, with the website's confirmation and failure copy. */ @@ -126,15 +127,17 @@ export default function HistoryScreen() { }; }, []); + const copies = useCopyHolder(); const load = useCallback(async (target: string) => { if (!userId) return; const forUser = userId; const request = ++loads.current; const isCurrent = () => live.current && loads.current === request && owner.current === forUser; + const mark = copies.mark(); try { const next: Loaded = target === OUTSIDE - ? { scope: OUTSIDE, kind: 'unsorted', entries: await fetchUnsortedWork(forUser) } - : { scope: target, kind: 'debate', lists: await fetchDebateHistory(forUser, target) }; + ? { scope: OUTSIDE, kind: 'unsorted', entries: await fetchUnsortedWork(forUser, mark) } + : { scope: target, kind: 'debate', lists: await fetchDebateHistory(forUser, target, mark) }; if (!isCurrent()) return; // Focus refreshes usually reread the same rows; keep the shown lists when they did not change. setLoaded((previous) => keepIfSame(previous, next)); @@ -144,7 +147,7 @@ export default function HistoryScreen() { console.warn('Could not load history:', cause instanceof Error ? cause.message : cause); setFailed(target); } - }, [userId]); + }, [userId, copies]); // Reload on focus, so a case deleted in its document or work saved on the website shows up. // The scope follows the Current Debate, so wait for the debates instead of loading one-off work first. diff --git a/apps/mobile/src/lib/account.test.ts b/apps/mobile/src/lib/account.test.ts index 3d5b146d5..e4d4ebb7c 100644 --- a/apps/mobile/src/lib/account.test.ts +++ b/apps/mobile/src/lib/account.test.ts @@ -14,6 +14,7 @@ vi.mock('./supabase', () => ({ }, }), })); +vi.mock('./offline-cache', () => ({ afterWrite: vi.fn(), ALL_COPIES: [] })); import { clearAllData } from './account'; import { supabase } from './supabase'; diff --git a/apps/mobile/src/lib/account.ts b/apps/mobile/src/lib/account.ts index 0e98e63bc..480d4e88a 100644 --- a/apps/mobile/src/lib/account.ts +++ b/apps/mobile/src/lib/account.ts @@ -1,5 +1,6 @@ import { getEdgeFunctionErrorMessage } from '@debatelab/core/supabase/edge-function-error'; +import { afterWrite, ALL_COPIES } from './offline-cache'; import { supabase } from './supabase'; /** The text a user types to confirm deletion. Must match deletionConfirmation in supabase/functions/account. */ @@ -49,4 +50,6 @@ export async function clearAllData(userId: string): Promise { if (!session || session.user.id !== userId) throw new Error('The signed-in account changed'); const { error } = await supabase.rpc('clear_current_user_data').setHeader('Authorization', `Bearer ${session.access_token}`); if (error) throw new Error(error.message); + // No saved copy may show deleted work offline, and the reload that follows must not read one back. + afterWrite(userId, [...ALL_COPIES]); } diff --git a/apps/mobile/src/lib/chats.ts b/apps/mobile/src/lib/chats.ts index cf131e39c..3e9b0a2a7 100644 --- a/apps/mobile/src/lib/chats.ts +++ b/apps/mobile/src/lib/chats.ts @@ -130,7 +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' }]); + // The conversation and the message count in its debate's list and in History changed. + afterWrite(userId, [{ group: 'chat', key: chatId }, { group: 'chats', key: debateId ?? 'one-off' }, { group: 'history', key: debateId ?? 'one-off' }]); return { chatId, debateId, persisted: messages.length }; } diff --git a/apps/mobile/src/lib/debates.ts b/apps/mobile/src/lib/debates.ts index 287019c0e..153a01120 100644 --- a/apps/mobile/src/lib/debates.ts +++ b/apps/mobile/src/lib/debates.ts @@ -279,7 +279,8 @@ const PREPARE_KINDS = new Set(PREPARE_TABLES.map((table) => KIND * 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) : []; + // Work outside every debate has no counts, but History lists it. + return debateId ? listScopes(debateId, change) : [{ group: 'history', key: 'one-off' }]; } /** @@ -296,8 +297,10 @@ export function deletedListScopes(deletedFrom: (string | null)[], change: 'sourc 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}` }); + // History lists a debate's work, but not its sources; a delete from an unknown debate may have been one-off, so every History copy goes. + const history: CopyScope = debateId ? { group: 'history', key: debateId } : { group: 'history' }; + if (change === 'source') return [...scopes, { group: 'debate-lists', prefix: 'source-counts:', about: debateId }, ...(debateId ? [] : [history])]; + scopes.push(history, { group: 'debate-lists', prefix: `recent:${of}` }); if (PREPARE_KINDS.has(change)) scopes.push({ group: 'debate-lists', prefix: `prepare:${of}` }); return scopes; } diff --git a/apps/mobile/src/lib/history.ts b/apps/mobile/src/lib/history.ts index baaab2115..48309c12a 100644 --- a/apps/mobile/src/lib/history.ts +++ b/apps/mobile/src/lib/history.ts @@ -1,13 +1,15 @@ import { SparringScorecardSchema, SparringSpeechSchema } from '@debatelab/core/ai/schemas/sparring'; -import type { DebateItem, DebateItemKind } from './debates'; +import { debateListScopes, type DebateItem, type DebateItemKind } from './debates'; +import { afterWrite, cachedRead, readAll, readFailed, type CopyMark, type CopyScope } from './offline-cache'; import { supabase } from './supabase'; /** * The website's History page (src/pages/HistoryPage.tsx) and its unsorted * view (src/components/features/debate/UnsortedWork.tsx), read from the same * tables. A debate shows six lists; work outside every debate is one list of - * every kind, plus Library sources, that can be added to a debate. + * every kind, plus Library sources, that can be added to a debate. Offline, + * both answer from their saved copies. */ export type HistoryTab = 'cases' | 'chats' | 'attacks' | 'brainstorms' | 'factoids' | 'sparring'; @@ -73,8 +75,8 @@ async function fetchRows(userId: string, kind: Excludemetadata->>side'] : []), ].join(','); const query = supabase.from(table).select(columns).eq('user_id', userId); - const { data, error } = await inScope(query, scope).order('created_at', { ascending: false }); - if (error) throw new Error(error.message); + const { data, error, status } = await inScope(query, scope).order('created_at', { ascending: false }); + if (error) throw readFailed(error, status); return ((data ?? []) as unknown as { id: string; title: string; created_at: string; motion?: string | null; case_side?: string | null }[]).map((row) => ({ id: row.id, kind, @@ -88,8 +90,8 @@ async function fetchRows(userId: string, kind: Exclude { const query = supabase.from('chats').select('id,title,created_at,messages(count)').eq('user_id', userId); - const { data, error } = await inScope(query, scope).order('created_at', { ascending: false }); - if (error) throw new Error(error.message); + const { data, error, status } = await inScope(query, scope).order('created_at', { ascending: false }); + if (error) throw readFailed(error, status); return ((data ?? []) as { id: string; title: string; created_at: string; messages: { count: number }[] }[]).map((row) => ({ id: row.id, kind: 'chat', @@ -119,8 +121,8 @@ async function fetchRounds(userId: string, scope: Scope): Promise .select('id,motion,user_side,format,turns,final_evaluation,status,updated_at') .eq('user_id', userId) .eq('status', 'complete'); - const { data, error } = await inScope(query, scope).order('updated_at', { ascending: false }).limit(MAX_ROUNDS); - if (error) throw new Error(error.message); + const { data, error, status } = await inScope(query, scope).order('updated_at', { ascending: false }).limit(MAX_ROUNDS); + if (error) throw readFailed(error, status); return ((data ?? []) as RoundRow[]).flatMap((row): HistoryItem[] => { const turns = Array.isArray(row.turns) && row.turns.length <= 12 ? row.turns : null; const scorecard = SparringScorecardSchema.safeParse(row.final_evaluation); @@ -141,10 +143,14 @@ async function fetchRounds(userId: string, scope: Scope): Promise }); } -/** The six lists of one debate. */ -export async function fetchDebateHistory(userId: string, debateId: string): Promise { +/** The six lists of one debate. Offline, the saved copy. */ +export function fetchDebateHistory(userId: string, debateId: string, mark?: CopyMark): Promise { + return cachedRead(userId, 'history', debateId, () => loadDebateHistory(userId, debateId), mark); +} + +async function loadDebateHistory(userId: string, debateId: string): Promise { const scope = { debateId }; - const [cases, chats, attacks, brainstorms, factoids, sparring] = await Promise.all([ + const [cases, chats, attacks, brainstorms, factoids, sparring] = await readAll([ fetchRows(userId, 'case', scope), fetchChatRows(userId, scope), fetchRows(userId, 'attack', scope), @@ -155,10 +161,14 @@ export async function fetchDebateHistory(userId: string, debateId: string): Prom return { cases, chats, attacks, brainstorms, factoids, sparring }; } -/** Work of every kind and Library sources that belong to no debate, newest first. */ -export async function fetchUnsortedWork(userId: string): Promise { +/** Work of every kind and Library sources that belong to no debate, newest first. Offline, the saved copy. */ +export function fetchUnsortedWork(userId: string, mark?: CopyMark): Promise { + return cachedRead(userId, 'history', 'one-off', () => loadUnsortedWork(userId), mark); +} + +async function loadUnsortedWork(userId: string): Promise { const scope = { unsorted: true } as const; - const lists = await Promise.all([ + const lists = await readAll([ fetchRows(userId, 'case', scope), fetchRows(userId, 'attack', scope), fetchRows(userId, 'brainstorm', scope), @@ -168,13 +178,13 @@ export async function fetchUnsortedWork(userId: string): Promise => { - const { data, error } = await supabase + const { data, error, status } = await supabase .from('sources') .select('id,title,created_at') .eq('user_id', userId) .is('debate_id', null) .order('created_at', { ascending: false }); - if (error) throw new Error(error.message); + if (error) throw readFailed(error, status); return ((data ?? []) as { id: string; title: string; created_at: string }[]).map((row) => ({ kind: 'source' as const, id: row.id, @@ -192,7 +202,9 @@ export async function fetchUnsortedWork(userId: string): Promise { const { data } = await supabase.auth.getSession(); @@ -205,4 +217,27 @@ export async function attachToDebate(userId: string, entry: UnsortedEntry, debat ...(entry.kind === 'sparring' && 'sparring' in entry && entry.sparring ? { p_side: entry.sparring.userSide } : {}), }); if (error) throw new Error(error.message); + afterWrite(userId, [...attachedCopies(entry.kind, entry.id, debateId), ...debateListScopes(debateId, entry.kind), ...debateListScopes(null, entry.kind)]); +} + +/** The item's own copy and the per-debate lists it left (one-off) and joined. */ +function attachedCopies(kind: UnsortedEntry['kind'], id: string, debateId: string): CopyScope[] { + switch (kind) { + case 'case': + case 'attack': + case 'brainstorm': + case 'factoids': + return [{ group: `result:${kind}`, key: id }]; + case 'chat': + return [{ group: 'chat', key: id }, { group: 'chats', key: 'one-off' }, { group: 'chats', key: debateId }]; + case 'flowchart': + return [{ group: 'flowchart', key: id }, { group: 'flowcharts', key: 'one-off' }, { group: 'flowcharts', key: debateId }]; + case 'source': + return [{ group: 'source', key: id }, { group: 'sources', key: debateId }]; + case 'sparring': + // The round lists are keyed `:`, as sparring.ts reads them. + return [{ group: 'sparring', key: id }, { group: 'sparring-rounds', prefix: 'one-off:' }, { group: 'sparring-rounds', prefix: `${debateId}:` }]; + case 'crossfire': + return []; + } } diff --git a/apps/mobile/src/lib/offline-cache.ts b/apps/mobile/src/lib/offline-cache.ts index 65cc4e539..58dd6dea9 100644 --- a/apps/mobile/src/lib/offline-cache.ts +++ b/apps/mobile/src/lib/offline-cache.ts @@ -39,10 +39,15 @@ const LIMITS = { /** Judged Sparring rounds per debate, and each saved round. */ 'sparring-rounds': 20, sparring: 40, + /** History's six lists of a debate, and the work outside every debate (`one-off`). */ + history: 20, } as const; export type CacheGroup = keyof typeof LIMITS; +/** Every copy of every group, for a write that changed all of the account's data. */ +export const ALL_COPIES: readonly CopyScope[] = (Object.keys(LIMITS) as CacheGroup[]).map((group) => ({ group })); + /** A copy larger than this is not kept; web storage holds about 5 MB in all. */ const MAX_ENTRY_CHARS = 512_000; diff --git a/apps/mobile/src/lib/owner-check.test.ts b/apps/mobile/src/lib/owner-check.test.ts index dd5b8439a..58e516123 100644 --- a/apps/mobile/src/lib/owner-check.test.ts +++ b/apps/mobile/src/lib/owner-check.test.ts @@ -6,6 +6,7 @@ const supabase = vi.hoisted(() => ({ })); vi.mock('./supabase', () => ({ supabase })); +vi.mock('./offline-cache', () => ({ afterWrite: vi.fn(), ALL_COPIES: [], cachedRead: vi.fn(), readAll: vi.fn(), readFailed: vi.fn(), pickFailure: vi.fn(), updateCopy: vi.fn() })); import { clearAllData } from './account'; import { attachToDebate } from './history';