diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 68c70b66e..659159dd3 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -18,19 +18,22 @@ 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/`, `factoids/` and `sparring/` (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`. Every Practice tool is in the app, so `(practice)/soon/[tool].tsx` is no longer reached from the Practice tab. +- `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/`, `factoids/` and `sparring/` (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`. Every Practice tool is in the app, so `(practice)/soon/[tool].tsx` is no longer reached from the Practice tab. - `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`), `native-capabilities.ts` (what the running binary has beyond Expo Go), 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)`). - 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=`). 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.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 and filing a judged Sparring round into its debate both drop the item's copies on both sides, and Delete all my data drops every copy (`ALL_COPIES`). - Push: `push.ts` asks for permission and files the Expo push token under the signed-in user with the `register_push_token` RPC; the You tab's Notifications switch turns it on and off, the root layout registers again on launch and when the language changes, and sign-out releases the registration with the account's own access token, after any registration still in flight. Every registration carries the ID of the local record, and `release_push_token` withdraws that ID and deletes only the rows it wrote, so a registration the app gave up on is refused if it reaches the database after the release. Once a release is sent for an ID, the record is marked released and the next registration gets a new ID, so a late release cannot delete it. The local record is cleared only once a release succeeds; otherwise the next change on the phone, from any account, releases it first. The server side is in [supabase/README.md](../../supabase/README.md#push-notifications). `push.web.ts` registers nothing and only flips the switch for the screenshot script. - No build can get a token until `app.config.ts` has `extra.eas.projectId`, from `eas init` on build day (see [docs/development.md](../../docs/development.md#build-day)). Until then the switch shows as unavailable, on Expo Go too. Expo Go on Android has no remote push since SDK 53, and Expo recommends a development build for remote push on iOS as well. - Prebuild applies the `expo-notifications` config plugin automatically once the package is installed, even though `app.config.ts` does not list it, and the plugin sets the `aps-environment` entitlement. `DEBATELAB_BUILD=dev` removes it again (`withoutPushEntitlement`), because the borrowed Signulous profile has no push, so a development build cannot get a token even with a project ID: turning the switch on there fails. Real push needs a provisioning profile with the Push Notifications capability and an APNs key uploaded to EAS, both from Adam's own Apple Developer account; only then can `withoutPushEntitlement` go. diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 171e0cb6d..2e2acb2c4 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -92,6 +92,9 @@ export default ({ projectRoot }: ConfigContext): ExpoConfig => { }, ], ], + extra: { + websiteUrl: 'https://debate.pi-stack.org', + }, experiments: { typedRoutes: true, reactCompiler: true, diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index 67b1f1680..484eb871a 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -10,7 +10,8 @@ * ONLY=iphone-light-en limits a run to the combinations whose name starts with it. * SCREENS=you,timer limits the signed-in screens to those groups: debate, * library, prepare, practice, you, empty, ask, results, brainstorm, factoids, - * timer, crossfire, sparring, flowchart, one-off. Sign-in screens are always shot. + * timer, crossfire, sparring, flowchart, one-off, history, announcements, + * account, model. Sign-in screens are always shot. * ONLY=offline runs only the offline reading check (see shootOfflineScreens). * * Signed-in screens need a staging session. With STAGING_DEMO_CODE set, the @@ -62,6 +63,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'; @@ -140,7 +152,8 @@ function watchErrors(page: Page, problems: string[], label: string, offline = () 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). */ @@ -1071,6 +1084,183 @@ 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-tab-sparring').click(); + await shoot(page, `${prefix}-history-sparring`); + + 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(); @@ -1355,10 +1545,10 @@ async function main() { let shots = 0; try { const session = DEMO_CODE ? await signIn(browser, problems) : null; - if (session && ONLY === 'offline') { + if (!session) console.log('STAGING_DEMO_CODE is not set: signed-in screens skipped'); + else if (ONLY === 'offline') { for (const [index, run] of OFFLINE_RUNS.entries()) shots += await shootOfflineScreens(browser, session, problems, run, index === 0); - } else if (session) await checkOfflineColdStart(browser, session, problems); - else console.log('STAGING_DEMO_CODE is not set: signed-in screens skipped'); + } else await checkOfflineColdStart(browser, session, problems); for (const [device, options] of Object.entries(DEVICES)) { for (const scheme of SCHEMES) { @@ -1460,6 +1650,22 @@ async function main() { await oneOffPage.close(); shots += 2; } + if (want('history')) { + await shootHistory(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'history')); + shots += 7; + } + if (want('announcements')) { + await shootAnnouncements(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'announcements')); + shots += 2; + } + if (want('account')) { + await shootAccount(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'account')); + shots += 2; + } + if (want('model')) { + await shootModelPicker(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'model')); + shots += 2; + } await signedIn.close(); } } diff --git a/apps/mobile/src/app/(tabs)/(debate)/index.tsx b/apps/mobile/src/app/(tabs)/(debate)/index.tsx index cebb274d8..1276ceb39 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 { useCopyHolder } from '@/lib/use-copy-holder'; import { mainActionGradient, radius } from '@/theme/colors'; import { useTheme } from '@/theme/use-theme'; @@ -34,6 +36,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); @@ -41,7 +46,8 @@ export default function DebateScreen() { useCallback(() => { if (focusedBefore.current) void refresh(); focusedBefore.current = true; - }, [refresh]), + refreshAnnouncements(); + }, [refresh, refreshAnnouncements]), ); const openSwitcher = () => router.push('/debates'); @@ -57,6 +63,7 @@ export default function DebateScreen() { if (status === 'error') { return ( + {t('mobile.debate.loadFailed')} @@ -70,6 +77,7 @@ export default function DebateScreen() { if (!current) { return ( + @@ -87,6 +95,7 @@ export default function DebateScreen() { return ( + @@ -202,9 +211,15 @@ function DebateDetails({ debateId }: { debateId: string }) { ))} - - {t('mobile.debate.pickUp')} - + + + {t('mobile.debate.pickUp')} + + router.push({ pathname: '/history', params: { from: 'debate' } })} + /> + {failed ? ( @@ -333,7 +348,10 @@ 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 }, 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 892e5e1e5..c06218e05 100644 --- a/apps/mobile/src/app/(tabs)/(you)/you.tsx +++ b/apps/mobile/src/app/(tabs)/(you)/you.tsx @@ -1,6 +1,7 @@ import { APP_LANGUAGES, type AppLanguage } from '@debatelab/core/languages'; import { useFocusEffect, useRouter } from 'expo-router'; -import { useCallback, useEffect, 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, AppState, Linking, Pressable, StyleSheet, View } from 'react-native'; @@ -8,17 +9,22 @@ import { TextButton } from '@/components/buttons'; import { Card, IconTile } from '@/components/card'; import { SwitchRow } from '@/components/form-rows'; 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 { disablePush, enablePush, readPushState, type PushState } from '@/lib/push'; import { createPushSwitch, type PushSwitchView } from '@/lib/push-switch'; 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 = { @@ -34,7 +40,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(); @@ -43,6 +49,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) => { @@ -57,6 +91,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')} @@ -117,6 +170,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')} @@ -133,11 +225,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 2cc70e34f..1bdca3eb4 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -90,11 +90,14 @@ 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 346c48baf..01bb261ac 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'; @@ -51,13 +52,13 @@ 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(); 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); @@ -90,7 +91,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!; }; @@ -326,6 +327,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; @@ -427,6 +438,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); @@ -116,7 +116,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; @@ -233,6 +233,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" /> + { + 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. They are cleared here first, so a reload that fails leaves nothing deleted on screen or targeted. + forgetSessionMemory(); + forgetDebates(forUser); + 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/app/factoids/index.tsx b/apps/mobile/src/app/factoids/index.tsx index a4f918a90..b2be095d8 100644 --- a/apps/mobile/src/app/factoids/index.tsx +++ b/apps/mobile/src/app/factoids/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 { SegmentedControl } from '@/components/segmented-control'; import { Text } from '@/components/text'; import { ToolError, ToolProgress, ToolTitle, WorkingIn } from '@/components/tool-form'; @@ -17,7 +18,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 { keepOpenResult } from '@/lib/open-results'; import { arrivalNotices, autosaveSources, findFactoids, keepSourceAssessments, recordResult } from '@/lib/results'; import { sessionSlot } from '@/lib/session-memory'; @@ -46,7 +46,7 @@ export default function FactoidsScreen() { const { session } = useAuth(); const userId = session?.user.id ?? null; const { activeDebateId, refresh } = useDebates(); - const { outputLanguage } = useSettings(); + const { outputLanguage, readFeatureConfig } = useSettings(); const { colors } = useTheme(); const [form, setFormState] = useState(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/app/history.tsx b/apps/mobile/src/app/history.tsx new file mode 100644 index 000000000..24ca7c1d4 --- /dev/null +++ b/apps/mobile/src/app/history.tsx @@ -0,0 +1,563 @@ +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 { 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. */ +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'; + +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: { title: 'historyPage.emptySparring', desc: 'historyPage.emptySparringDesc', action: 'historyPage.startSparring', href: { pathname: '/sparring', params: { from: 'history' } } }, +}; + +/** + * Where a History row opens, or null when the app cannot open it. Chats load + * back into Ask, as the website's Resume does, and a Sparring round opens its + * scorecard, as the website's Load does. The website cannot reopen a + * Crossfire round, so neither can the app. + */ +function historyHref(item: HistoryItem): Href | null { + switch (item.kind) { + case 'chat': + return { pathname: '/ask', params: { chat: item.id } }; + 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 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, 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)); + setFailed(null); + } catch (cause) { + if (!isCurrent()) return; + console.warn('Could not load history:', cause instanceof Error ? cause.message : cause); + setFailed(target); + } + }, [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. + 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 ?? ''}`; + + // The website's History deletes cases and Sparring rounds, each after its own confirmation. + const remove = async (item: HistoryItem): Promise => { + const forUser = owner.current; + const deletion = item.kind === 'case' || item.kind === 'sparring' ? DELETIONS[item.kind] : null; + if (!forUser || !deletion) return false; + const confirmed = await confirmDestructive({ + 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 deletion.run(forUser, item.id); + } catch (cause) { + 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, [list]: previous.lists[list].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} + onDelete={item.kind === 'case' || item.kind === 'sparring' ? () => 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 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(); + 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/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/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/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/icon-paths.ts b/apps/mobile/src/components/icon-paths.ts index 675976ed2..9ad11d6df 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: '', cloudOff: '', diff --git a/apps/mobile/src/components/model-picker.tsx b/apps/mobile/src/components/model-picker.tsx new file mode 100644 index 000000000..9560d6d45 --- /dev/null +++ b/apps/mobile/src/components/model-picker.tsx @@ -0,0 +1,208 @@ +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. + * `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(); + 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); + 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)); + }); + }; + + 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/components/work-row.tsx b/apps/mobile/src/components/work-row.tsx index c7c13b1c7..2feeb3191 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, factoids and Sparring diff --git a/apps/mobile/src/lib/account.test.ts b/apps/mobile/src/lib/account.test.ts new file mode 100644 index 000000000..e4d4ebb7c --- /dev/null +++ b/apps/mobile/src/lib/account.test.ts @@ -0,0 +1,35 @@ +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 }); + }, + }, + }), +})); +vi.mock('./offline-cache', () => ({ afterWrite: vi.fn(), ALL_COPIES: [] })); + +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 c1d582933..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. */ @@ -7,18 +8,48 @@ 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 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(); + 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); + // 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/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/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/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/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]); 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/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/feature-config-read.test.ts b/apps/mobile/src/lib/feature-config-read.test.ts new file mode 100644 index 000000000..7acc3744e --- /dev/null +++ b/apps/mobile/src/lib/feature-config-read.test.ts @@ -0,0 +1,73 @@ +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); + }); + + it('reads the saved row again when the save finishes during the fallback read', async () => { + const writes = createSerialQueue(); + let releaseSave = () => {}; + void writes(() => new Promise((resolve) => { + releaseSave = resolve; + })); + const updated: FeatureConfig = { ...saved, model: 'deepseek/deepseek-v4-flash-0731' }; + const reads: ((settings: { config: FeatureConfig; pro: boolean }) => void)[] = []; + let pendingChoice: { model: string } | null = { model: updated.model }; + const config = readFeatureConfigAfterWrites({ + writes, + read: () => new Promise((resolve) => { + reads.push(resolve); + }), + pending: () => pendingChoice, + waitMs: 5000, + }); + + await vi.advanceTimersByTimeAsync(5000); + expect(reads).toHaveLength(1); + releaseSave(); + pendingChoice = null; + await vi.advanceTimersByTimeAsync(0); + expect(reads).toHaveLength(2); + // The fallback copied the row before the save landed, then the choice was cleared. + reads[0]?.({ config: saved, pro: false }); + await vi.advanceTimersByTimeAsync(0); + expect(reads).toHaveLength(3); + reads[2]?.({ config: updated, pro: true }); + await expect(config).resolves.toEqual(updated); + }); +}); 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..b85dbc45c --- /dev/null +++ b/apps/mobile/src/lib/feature-config-read.ts @@ -0,0 +1,67 @@ +import { isProOnlyModel, type FeatureConfig, type FeatureConfigChange } from './feature-config'; +import type { SerialQueue } from './serial-queue'; + +type FeatureSettings = { config: FeatureConfig; pro: boolean }; + +function sameChoice(left: FeatureConfigChange | null, right: FeatureConfigChange | null): boolean { + return (left?.model ?? null) === (right?.model ?? null) && (left?.reasoningEffort ?? null) === (right?.reasoningEffort ?? null); +} + +function withChoice({ config, pro }: FeatureSettings, choice: FeatureConfigChange | null): FeatureConfig { + const { model, reasoningEffort } = choice ?? {}; + return { + ...config, + ...(model !== undefined && (pro || !isProOnlyModel(model)) ? { model } : {}), + ...(reasoningEffort !== undefined ? { reasoningEffort } : {}), + }; +} + +/** + * 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. + * + * The fallback read copies the row and then waits on the entitlement. If the + * save finishes in that gap it clears the choice, and the copied row is the + * one the save replaced. That result is dropped and the row is read again, + * so a hung write still in the queue cannot keep the tool on the old model. + */ +export function readFeatureConfigAfterWrites(options: { + writes: SerialQueue; + read: () => Promise; + pending: () => FeatureConfigChange | null; + waitMs: number; +}): Promise { + const { writes, read, pending, waitMs } = options; + return new Promise((resolve, reject) => { + let settled = false; + const finish = (config: FeatureConfig) => { + if (settled) return; + settled = true; + clearTimeout(fallback); + resolve(config); + }; + const fail = (cause: unknown) => { + if (settled) return; + settled = true; + clearTimeout(fallback); + reject(cause); + }; + const fallback = setTimeout(() => { + const choiceAtStart = pending(); + void read().then((settings) => { + const choice = pending(); + if (choice === null && !sameChoice(choice, choiceAtStart)) { + void read().then((fresh) => finish(withChoice(fresh, pending())), fail); + return; + } + finish(withChoice(settings, choice)); + }, fail); + }, waitMs); + writes(read).then(({ config }) => finish(config), fail); + }); +} 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 5f0832a07..62110abc4 100644 --- a/apps/mobile/src/lib/feature-config.ts +++ b/apps/mobile/src/lib/feature-config.ts @@ -1,4 +1,5 @@ import { getDefaultFeatureConfigs, type FeatureTab } from '@debatelab/core/ai/feature-configs'; +import { PROVIDER_ID, type ProviderConfig } from '@debatelab/core/ai/providers/types'; import { readEntitlement } from '@debatelab/core/supabase/entitlement'; import { AUTO_FAST, @@ -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,16 +74,50 @@ function normalizeConfig(value: unknown, fallback: FeatureConfig, allowProModels }; } -export async function fetchFeatureConfig(userId: string, feature: FeatureTab): Promise { +/** Whether the student may pick Pro models, from effective_entitlement; null when the plan cannot be read. */ +export async function canUseProModels(userId: string): Promise { + return (await readEntitlement(supabase, userId))?.canAccessProModels ?? null; +} + +/** 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') .eq('user_id', userId) .maybeSingle(); - if (error || !data) return defaultConfig(feature); - // Unknown when the plan cannot be read, as on the website, which then keeps its defaults. - const allowProModels = (await readEntitlement(supabase, userId))?.canAccessProModels; - if (allowProModels === undefined) return defaultConfig(feature); + if (error || !data) return { config: defaultConfig(feature), pro: false }; + // Null when the plan cannot be read, as on the website, which then keeps its defaults. + const allowProModels = await canUseProModels(userId); + 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/history.ts b/apps/mobile/src/lib/history.ts new file mode 100644 index 000000000..48309c12a --- /dev/null +++ b/apps/mobile/src/lib/history.ts @@ -0,0 +1,243 @@ +import { SparringScorecardSchema, SparringSpeechSchema } from '@debatelab/core/ai/schemas/sparring'; + +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. Offline, + * both answer from their saved copies. + */ + +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, 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, + 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, 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', + 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, 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); + 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. 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 readAll([ + 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. 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 readAll([ + 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, 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 readFailed(error, status); + 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. Afterwards the saved + * copies that showed the item outside every debate, or the debate without it, + * are dropped. + */ +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); + 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/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/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 new file mode 100644 index 000000000..58e516123 --- /dev/null +++ b/apps/mobile/src/lib/owner-check.test.ts @@ -0,0 +1,31 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const supabase = vi.hoisted(() => ({ + auth: { getSession: vi.fn() }, + rpc: vi.fn(), +})); + +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'; + +// 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(); + }); +}); 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/settings.tsx b/apps/mobile/src/lib/settings.tsx index 93ec9de3d..332735592 100644 --- a/apps/mobile/src/lib/settings.tsx +++ b/apps/mobile/src/lib/settings.tsx @@ -1,4 +1,4 @@ -import { getDefaultFeatureConfigs } from '@debatelab/core/ai/feature-configs'; +import { getDefaultFeatureConfigs, type FeatureTab } from '@debatelab/core/ai/feature-configs'; import { readEntitlement } from '@debatelab/core/supabase/entitlement'; import { DEFAULT_MODEL, DEFAULT_PRO_MODEL } from '@debatelab/core/contract/model-catalog'; import { isAppLanguage, type AppLanguage } from '@debatelab/core/languages'; @@ -6,6 +6,15 @@ 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, + 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'; @@ -27,6 +36,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 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 +54,8 @@ const SettingsContext = createContext({ setLanguage: async () => {}, setOutputLanguage: async () => {}, setTheme: async () => {}, + setFeatureConfig: async () => {}, + readFeatureConfig: fetchFeatureConfig, }); function isThemePreference(value: unknown): value is ThemePreference { @@ -61,6 +76,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, with the choice still being saved on top. */ +const READ_WAIT_MS = 5000; + function isEmptyObject(value: unknown): boolean { return !!value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0; } @@ -107,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') + .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); + 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 @@ -133,6 +177,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); @@ -155,6 +204,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 }; @@ -225,13 +275,56 @@ 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; + 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]); + + 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, 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}; } diff --git a/apps/mobile/src/lib/sparring-attach.test.ts b/apps/mobile/src/lib/sparring-attach.test.ts new file mode 100644 index 000000000..d4285cffd --- /dev/null +++ b/apps/mobile/src/lib/sparring-attach.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const rpc = vi.hoisted(() => vi.fn()); +const afterWrite = vi.hoisted(() => vi.fn()); + +vi.mock('./supabase', () => ({ supabase: { rpc } })); +vi.mock('./offline-cache', () => ({ afterWrite, cachedRead: vi.fn(), readFailed: vi.fn() })); +vi.mock('expo-crypto', () => ({ randomUUID: () => '00000000-0000-4000-8000-000000000001' })); +vi.mock('expo/fetch', () => ({ fetch: vi.fn() })); + +import { debateListScopes } from './debates'; +import { attachSparring } from './sparring'; + +describe('attachSparring', () => { + beforeEach(() => { + rpc.mockReset().mockResolvedValue({ error: null }); + afterWrite.mockReset(); + }); + + it('drops the one-off History copy when a judged round is filed in its debate', async () => { + await attachSparring('user-1', 'round-1', 'debate-1', 'pro'); + expect(rpc).toHaveBeenCalledWith('attach_to_debate', { + p_kind: 'sparring', + p_id: 'round-1', + p_debate_id: 'debate-1', + p_side: 'pro', + }); + const scopes = afterWrite.mock.calls[0]?.[1] as { group: string; key?: string }[]; + expect(scopes).toEqual(expect.arrayContaining(debateListScopes(null, 'sparring'))); + expect(scopes).toEqual(expect.arrayContaining([{ group: 'history', key: 'one-off' }])); + }); +}); diff --git a/apps/mobile/src/lib/sparring.ts b/apps/mobile/src/lib/sparring.ts index f40fe61d8..c7d23f67d 100644 --- a/apps/mobile/src/lib/sparring.ts +++ b/apps/mobile/src/lib/sparring.ts @@ -98,8 +98,15 @@ export function noteJudgedRound(userId: string, id: string, debateId: string | n export async function attachSparring(userId: string, id: string, debateId: string, side: SparringSide): Promise { const { error } = await supabase.rpc('attach_to_debate', { p_kind: 'sparring', p_id: id, p_debate_id: debateId, p_side: side }); if (error) throw new Error(error.message); - // The round may have been outside every debate until now. - afterWrite(userId, [{ group: 'sparring', key: id }, ...roundListScopes(debateId), ...roundListScopes(null), ...debateListScopes(debateId, 'sparring')]); + // The server saved the judged round outside every debate, so History's + // one-off copy can still list it until this drops that copy too. + afterWrite(userId, [ + { group: 'sparring', key: id }, + ...roundListScopes(debateId), + ...roundListScopes(null), + ...debateListScopes(debateId, 'sparring'), + ...debateListScopes(null, 'sparring'), + ]); } /** A saved, judged round; null when there is none or it does not check out. Offline, the saved copy. */ 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/apps/mobile/src/lib/website.ts b/apps/mobile/src/lib/website.ts new file mode 100644 index 000000000..63aa80c9f --- /dev/null +++ b/apps/mobile/src/lib/website.ts @@ -0,0 +1,12 @@ +import Constants from 'expo-constants'; + +/** + * The public website, from app.config.ts'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/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), diff --git a/src/locales/en.json b/src/locales/en.json index 24eaa7d69..b41d37fbc 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2423,7 +2423,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" @@ -2447,6 +2448,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", + "deleteDataDesc": "Saved work, sources and debates. Your account and settings stay.", "notifications": { "title": "Notifications", "description": "Get a notification on this phone when a sparring round is scored.", @@ -2642,6 +2645,9 @@ "discardDesc": "Your unsaved map will be replaced by the starter map.", "startNew": "Start new" }, + "history": { + "loadFailed": "Could not load your history." + }, "permissions": { "speechRecognition": "DebateLab turns your voice into text when you dictate a question, a Crossfire answer or a Sparring speech. Apple processes the audio to recognize it.", "microphone": "DebateLab uses the microphone while you dictate a question, a Crossfire answer or a Sparring speech." diff --git a/src/locales/es.json b/src/locales/es.json index c794b844b..52b98c2ba 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2423,7 +2423,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" @@ -2447,6 +2448,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", + "deleteDataDesc": "Trabajo guardado, fuentes y debates. Tu cuenta y tus ajustes se mantienen.", "notifications": { "title": "Notificaciones", "description": "Recibe una notificación en este teléfono cuando se puntúe una ronda de sparring.", @@ -2642,6 +2645,9 @@ "discardDesc": "Tu mapa sin guardar se sustituirá por el mapa inicial.", "startNew": "Empezar nuevo" }, + "history": { + "loadFailed": "No se pudo cargar tu historial." + }, "permissions": { "speechRecognition": "DebateLab convierte tu voz en texto cuando dictas una pregunta, una respuesta de Interrogatorio o un discurso de Sparring. Apple procesa el audio para reconocerlo.", "microphone": "DebateLab usa el micrófono mientras dictas una pregunta, una respuesta de Interrogatorio o un discurso de Sparring." diff --git a/src/locales/hu.json b/src/locales/hu.json index 71de44e2c..0801e286a 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2423,7 +2423,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" @@ -2447,6 +2448,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", + "deleteDataDesc": "Mentett munkák, források és viták. A fiókod és a beállításaid megmaradnak.", "notifications": { "title": "Értesítések", "description": "Értesítést kapsz erre a telefonra, amikor elkészül egy sparring forduló értékelése.", @@ -2642,6 +2645,9 @@ "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." + }, "permissions": { "speechRecognition": "A DebateLab szöveggé alakítja a hangodat, amikor kérdést, keresztkérdezési választ vagy vitapárbaj-beszédet diktálsz. A hangot az Apple dolgozza fel a felismeréshez.", "microphone": "A DebateLab a mikrofont használja, amíg kérdést, keresztkérdezési választ vagy vitapárbaj-beszédet diktálsz."