diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 8a86d595a..24bfe383e 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -18,10 +18,14 @@ The iOS app DebateOwl (`@debatelab/mobile`; the bundle id is still `com.adam.deb ## 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`, `delete-data.tsx`, `add-link.tsx`, `new-note.tsx`, `source/[id].tsx`, `limit.tsx` (the website's limit-reached and model-restricted dialogs), `save-case.tsx` (Save case with a title) and `context-review.tsx` (the debate context Review); 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/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`, `add-file.tsx` (Import file), `source/[id].tsx`, `edit-debate.tsx` (Edit settings), `debate-check.tsx` (the five-day check), `limit.tsx` (the website's limit-reached and model-restricted dialogs), `save-case.tsx` (Save case with a title) and `context-review.tsx` (the debate context Review); 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. +- `sources.ts` mirrors the session store's source actions (`addSource` with Undo's restore, `updateSource`, `removeSource`, `toggleSource` and `setFolderActive` as `setSourcesActive`, the folder actions and `clearLibrary`) and `src/lib/source-import.ts`. Switch changes run in one serial queue, so they reach the server in the order the student made them; a screen shows a change at once and puts it back when the write fails (`source-actions.ts`, with the website's messages). The Library always shows the Current Debate, also in one-off mode, as on the website. Its folder list, search and eight sort orders are the website's, in `packages/core/src/library.ts`. Scan pages is not in the app yet. +- File import runs the website's reader (`packages/core/src/source-file-import.ts`, with its 8 MB and 120,000-character limits) inside an Expo DOM component, `components/source-file-reader.tsx`, because pdf.js and mammoth need a browser. `pick-source-file.ts` opens the Files picker, `source-file.tsx` checks size (the file's own when the picker gives none) and type before reading anything, then serves the picked copy in 768 KB slices that the reader pulls one at a time (`picked-file.ts` reads them natively as base64; the web preview slices the browser File); a read always ends, with a two-minute timeout for a reader that never loads, and `add-file.tsx` shows the draft. The DOM component imports pdf.js and mammoth statically, because Expo cannot export a DOM component that splits into chunks; its webview loads only while a file is read. It uses `@expo/dom-webview`, which Expo Go has. Never move the parsing into `import-source`: that would be an Edge Function deploy. +- `toast.tsx` is the app's toast (the website's short messages, with Undo); a form sheet is drawn above it, so a sheet says its outcome inline and the toast shows once the sheet closes. `haptics.ts` is the one place for haptics (a tap for switches and chips, a tick for segmented controls and pickers, success when a result arrives or something is saved, a warning when a request fails, a tap when the turn changes); `haptics.web.ts` records them for the screenshot script. +- Debates: `new-debate.tsx` and `edit-debate.tsx` share `components/debate-form.tsx`, the website's DebateForm plus the judge (`settings.judge`, which the website's prompts read but its form does not show). The switcher deletes any debate but the current one (the database cascades its sources and work). `use-inactive-debate-check.ts` opens `debate-check.tsx`, the website's five-day check; closing it counts as Continue. +- Rows of saved work and sources are `Link`s with `Link.Preview` and `Link.Menu`: a long press shows the document and its actions (`work-actions.ts`: Discuss, Share, and Delete where the website's History deletes). - 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 from `context-plan.ts`, which reads a debate's list of material when a form opens, and the text only of the items a request sends (kept in `context-texts.ts` under each row's version: a case's generatedAt, a chat's message count, a round's updated_at; source text is read again after each list read, and `updateResult` and `saveChat` forget what they change), and plans it with the website's planner (`packages/core/src/debate/context-plan.ts`), with the Review choices for one request (Crossfire keeps them for its round) and the pins in `debate_context_pins` (three at most); the line on each tool form, Crossfire, Sparring and Ask (`components/context-line.tsx`) and its Review sheet read the same plan, 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`'s `postAI`, which streams them as the website does (`stream: true` with a `resultKey`; the reader is `packages/core/src/ai/live.ts`, shared with the website's `src/lib/ai/live.ts`) and reads a dropped stream's answer back from `ai_run_results` (`packages/core/src/ai/run-recovery.ts`). The four tool screens show the draft with `components/live-run.tsx` (the website's `LiveToolRun`; the sections come from `packages/core/src/ai/live-sections.ts`). `results.ts` also 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. @@ -59,4 +63,4 @@ ONLY=offline PORT=18080 STAGING_DEMO_CODE=... npx tsx apps/mobile/scripts/screen Screenshots go to `.verify/mobile-shots/`. For signed-in screens, see the header of `scripts/screenshots.ts`. To load the app on a phone, see [docs/development.md](../../docs/development.md#mobile-app). -knip ignores `expo-updates`: its Expo plugin expects the package whenever the app config does not disable updates, and EAS Update is not set up yet. It also ignores `expo-speech-recognition` and `expo-widgets`, which the app reaches by native module name, not by import. +knip ignores `expo-updates`: its Expo plugin expects the package whenever the app config does not disable updates, and EAS Update is not set up yet. It also ignores `expo-speech-recognition` and `expo-widgets`, which the app reaches by native module name, not by import, and `@expo/dom-webview`, which Expo's DOM components load by name. diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 370b293eb..49c78b0e8 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -12,6 +12,7 @@ "@debatelab/core": "*", "@expo-google-fonts/fredoka": "^0.4.1", "@expo-google-fonts/nunito": "^0.4.2", + "@expo/dom-webview": "~57.0.1", "@formatjs/intl-listformat": "^8.3.15", "@formatjs/intl-pluralrules": "^6.3.15", "@react-native-async-storage/async-storage": "2.2.0", @@ -44,6 +45,8 @@ "expo-web-browser": "~57.0.3", "expo-widgets": "~57.0.24", "i18next": "^23.16.8", + "mammoth": "^1.12.0", + "pdfjs-dist": "^6.3.289", "react": "19.2.3", "react-dom": "19.2.3", "react-i18next": "^15.4.1", diff --git a/apps/mobile/scripts/fixtures/congestion-charge-results.pdf b/apps/mobile/scripts/fixtures/congestion-charge-results.pdf new file mode 100644 index 000000000..a54a5dd01 Binary files /dev/null and b/apps/mobile/scripts/fixtures/congestion-charge-results.pdf differ diff --git a/apps/mobile/scripts/fixtures/rebuttal-notes.docx b/apps/mobile/scripts/fixtures/rebuttal-notes.docx new file mode 100644 index 000000000..9dcf654cd Binary files /dev/null and b/apps/mobile/scripts/fixtures/rebuttal-notes.docx differ diff --git a/apps/mobile/scripts/fixtures/whiteboard-photo.png b/apps/mobile/scripts/fixtures/whiteboard-photo.png new file mode 100644 index 000000000..94381b429 Binary files /dev/null and b/apps/mobile/scripts/fixtures/whiteboard-photo.png differ diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index 874cb7821..f089e1ebe 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -11,7 +11,8 @@ * ONLY=iphone-light-en limits a run to the combinations whose name starts with it; * separate several with commas (ONLY=iphone-light-en,ipad-dark-hu). * SCREENS=you,timer limits the signed-in screens to those groups: debate, - * library, prepare, practice, you, empty, ask, results, brainstorm, factoids, case-edits, context, limits, + * debate-check, library, prepare, practice, you, empty, ask, results, brainstorm, + * factoids, case-edits, context, limits, * 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). @@ -40,8 +41,18 @@ * Ask screenshots never call a model and never save a chat: the page answers * ai-stream from a fake stream inside the page (expo/fetch uses the page's * fetch), answers the ai function with a fixed answer with citations, and - * answers chat and message writes locally. The Library screens read the - * account's real sources and write nothing. + * answers chat and message writes locally. + * + * The Library screens read made-up sources (librarySources: four folders, a + * source switched off, quality rankings) and answer every source and folder + * write locally. File import picks the made-up PDF, DOCX and PNG in + * scripts/fixtures through the page's file chooser, so the website's reader + * (pdf.js and mammoth, the code the iOS DOM component runs) reads them for + * real; the run checks the extracted text. Edit is shown, never saved. + * + * Every debates read is answered as active just now, so the five-day check + * stays out of the shots; its own shot answers it as nine days idle and + * never presses a button. Edit settings is shown, never saved. * * Generate and Attack screenshots never call a model and never save: the * page answers the ai function with a fixed case or attack, holds it open @@ -163,7 +174,7 @@ function watchErrors(page: Page, problems: string[], label: string, offline = () page.on('response', (response) => { if (response.status() === 404 && response.url().includes('/rest/v1/app_store_subscriptions')) appStoreMisses += 1; }); - page.on('pageerror', (error) => problems.push(`${label}: ${error.message}`)); + page.on('pageerror', (error) => { if (process.env.SHOT_DEBUG) console.error('PAGEERROR', label, error.stack); problems.push(`${label}: ${error.message}`); }); page.on('console', (message) => { if (message.type() !== 'error') return; if (offline() && message.text().startsWith('Failed to load resource')) return; @@ -208,6 +219,7 @@ async function shoot(page: Page, name: string) { /** Answers user_settings reads with the run's language and the System theme (see the header). */ async function pinSettings(page: Page, language: string) { + await pinDebateActivity(page, 0); await page.route(`${REST}/user_settings*`, async (route) => { if (route.request().method() !== 'GET') return route.continue(); const response = await route.fetch(); @@ -225,6 +237,20 @@ async function pinSettings(page: Page, language: string) { }); } +/** + * Answers the debates list with every debate last active `ageMs` ago, so the + * five-day check opens only in its own shot. Nothing is written. + */ +async function pinDebateActivity(page: Page, ageMs: number) { + await page.route(`${REST}/debates?*`, async (route) => { + if (route.request().method() !== 'GET') return route.continue(); + const response = await route.fetch(); + const body: unknown = await response.json(); + const json = Array.isArray(body) ? body.map((row) => ({ ...(row as Record), last_activity_at: iso(-ageMs) })) : body; + await route.fulfill({ response, json }); + }); +} + /** Waits until the screen's data has arrived, so no spinner is caught mid-load. */ async function settle(page: Page) { await page.waitForLoadState('networkidle'); @@ -264,6 +290,62 @@ async function shootDebateScreens(page: Page, prefix: string) { await page.getByTestId('new-debate-sheet').waitFor(); await page.waitForTimeout(700); await shoot(page, `${prefix}-new-debate`); + await shootSheetEnd(page, 'new-debate-sheet', `${prefix}-new-debate-end`); + + // Edit settings is shown, never saved: saving would write the account's debate. + await page.goto(`${ORIGIN}/`); + await page.getByTestId('edit-debate').click({ timeout: 30_000 }); + await page.getByTestId('edit-debate-sheet').waitFor(); + await page.waitForTimeout(700); + await shoot(page, `${prefix}-edit-debate`); + await shootSheetEnd(page, 'edit-debate-sheet', `${prefix}-edit-debate-end`); +} + +/** + * Shows the end of a long sheet. The web preview draws sheets as pages that + * grow with their content, so it scrolls what scrolls and, where nothing + * does, makes the window tall enough for the whole sheet for one shot. + */ +async function shootSheetEnd(page: Page, testId: string, name: string) { + await scrollToEnd(page, testId); + const viewport = page.viewportSize(); + const height = await page.getByTestId(testId).evaluate((element) => element.scrollHeight); + if (viewport && height > viewport.height) await page.setViewportSize({ width: viewport.width, height: Math.ceil(height) + 40 }); + await page.waitForTimeout(300); + await shoot(page, name); + if (viewport) await page.setViewportSize(viewport); +} + +/** Scrolls a sheet's own scroll view to its end; the web preview draws sheets as pages. */ +async function scrollToEnd(page: Page, testId: string) { + // The sheet's own scroll view, or the page or a wrapper around it, whichever scrolls at this size. + await page.getByTestId(testId).evaluate((root) => { + const candidates = [document.scrollingElement, root, ...Array.from(root.querySelectorAll('*'))]; + for (let element = root.parentElement; element; element = element.parentElement) candidates.push(element); + for (const element of candidates) { + if (element && element.scrollHeight > element.clientHeight + 1) element.scrollTo({ top: element.scrollHeight }); + } + }); + await page.waitForTimeout(300); +} + +/** + * The five-day check, from a debates list answered as nine days idle. Its + * buttons are shown, never pressed: Continue would write the debate's activity. + */ +async function shootDebateCheck(context: BrowserContext, prefix: string, language: string, problems: string[]) { + const page = await context.newPage(); + watchErrors(page, problems, `${prefix} debate check`); + await pinSettings(page, language); + // Registered last, so it answers before pinSettings' recent activity. + await pinDebateActivity(page, 9 * 86_400_000); + // Closing the sheet would count as Continue, so the page is closed without it ever being dismissed. + await page.route(`${REST}/debates?*`, (route) => (route.request().method() === 'PATCH' ? route.fulfill({ status: 204, headers: CORS }) : route.fallback())); + await page.goto(`${ORIGIN}/`); + await page.getByTestId('debate-check-sheet').waitFor({ timeout: 30_000 }); + await page.waitForTimeout(700); + await shoot(page, `${prefix}-debate-check`); + await page.close(); } async function turnOnOneOff(page: Page) { @@ -992,12 +1074,117 @@ async function shootFlowchart(context: BrowserContext, prefix: string, language: await oneOff.close(); } -async function shootLibraryScreens(page: Page, prefix: string) { +const FIXTURES = join(__dirname, 'fixtures'); + +/** Made-up Library rows: four folders, one source switched off, and the website's quality rankings on three. */ +function librarySources(debateId: string) { + const semantic = (importance: number, strength: number, useCount: number, caseUseCount: number, lastUsedInCase = false) => ({ + relevance: 0.8, importance, strength, useCount, caseUseCount, lastUsedInCase, evaluatedAt: iso(-86_400_000), + }); + const row = (id: number, fields: Record) => ({ + id: `00000000-0000-4000-8000-0000005${String(id).padStart(7, '0')}`, + debate_id: debateId, + is_active: true, + metadata: null, + created_at: iso(-id * 7 * 3600_000), + ...fields, + }); + return [ + row(1, { title: 'City air quality report 2025', type: 'url', origin: 'https://example.org/air-quality-2025', folder: 'Evidence', content: 'Nitrogen dioxide fell by a third on streets closed to through traffic, measured over twelve months at forty sites.', metadata: { url: 'https://example.org/air-quality-2025', qualityScore: 0.9, semantic: semantic(3, 3, 4, 2, true) } }), + row(2, { title: 'Congestion charge results', type: 'file', origin: 'congestion-charge-2024.pdf', folder: 'Evidence', content: 'Traffic in the charging zone fell by 18 percent in the first twelve months. Bus journey times improved by 9 percent.', metadata: { semantic: semantic(2, 2, 1, 1) } }), + row(3, { title: 'Nurse shift-work exemptions', type: 'text', origin: null, folder: 'Rebuttals', content: 'Every real ban exempts shift workers, carers and blue badge holders. Night buses follow demand.' }), + row(4, { title: 'Opposition talking points', type: 'text', origin: null, folder: 'General', is_active: false, content: 'Their likely lines: freedom of movement, trade in the centre, deliveries.' }), + row(5, { title: 'Retail footfall after car bans', type: 'url', origin: 'https://example.com/footfall-study', folder: 'Auto-saved', content: 'Footfall recovered to its earlier level within eight months in five of six cities.', metadata: { url: 'https://example.com/footfall-study', semantic: semantic(1, 2, 1, 0) } }), + ]; +} + +/** + * Answers the Library's reads with librarySources and its writes locally, so + * the shots show folders, a switched-off source and quality badges without + * touching the account's real Library (see the header). + */ +async function fakeLibrary(page: Page) { + let rows: Record[] | null = null; + const folders = ['General', 'Evidence', 'Rebuttals', 'Auto-saved']; + await page.route((url) => url.href.startsWith(`${REST}/sources`) || url.href.startsWith(`${REST}/source_folders`), async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const object = (request.headers()['accept'] ?? '').includes('vnd.pgrst.object'); + const debate = url.searchParams.get('debate_id')?.replace(/^eq\./, ''); + if (url.pathname.endsWith('/source_folders')) { + if (request.method() === 'POST') { + const body = request.postDataJSON() as { name?: string } | { name?: string }[]; + for (const row of Array.isArray(body) ? body : [body]) if (row.name && !folders.includes(row.name)) folders.push(row.name); + } + return route.fulfill({ status: request.method() === 'GET' ? 200 : 201, headers: CORS, json: request.method() === 'GET' ? folders.map((name) => ({ name })) : [] }); + } + if (debate && !rows) rows = librarySources(debate); + if (request.method() === 'GET') { + const id = url.searchParams.get('id')?.replace(/^eq\./, ''); + const list = (rows ?? []).filter((row) => !id || row.id === id); + return route.fulfill({ status: 200, headers: CORS, json: object ? list[0] ?? null : list }); + } + if (request.method() === 'POST') { + const body = request.postDataJSON() as Record; + const saved = { id: `00000000-0000-4000-8000-0000006${String(Date.now() % 1e7).padStart(7, '0')}`, created_at: iso(0), ...body }; + rows = [saved, ...(rows ?? [])]; + return route.fulfill({ status: 201, headers: CORS, json: object ? saved : [saved] }); + } + if (request.method() === 'DELETE') { + const id = url.searchParams.get('id')?.replace(/^eq\./, ''); + const removed = (rows ?? []).filter((row) => row.id === id); + rows = (rows ?? []).filter((row) => row.id !== id); + return route.fulfill({ status: 200, headers: CORS, json: removed.map((row) => ({ debate_id: row.debate_id })) }); + } + return route.fulfill({ status: 204, headers: CORS }); + }); +} + +/** Picks a fixture through the Library's Import a file, as the Files picker would hand it over. */ +async function importFixture(page: Page, file: string) { + await page.goto(`${ORIGIN}/library`); + await page.getByTestId('library-add').click({ timeout: 30_000 }); + const chooser = page.waitForEvent('filechooser'); + await page.getByRole('menuitem').nth(2).click(); + await (await chooser).setFiles(join(FIXTURES, file)); + await page.getByTestId('add-file-sheet').waitFor({ timeout: 30_000 }); +} + +async function shootLibraryScreens(page: Page, prefix: string, problems: string[]) { + await fakeLibrary(page); await page.goto(`${ORIGIN}/library`); await page.getByTestId('library-add').waitFor({ timeout: 30_000 }); + await page.getByTestId('source-row').first().waitFor({ timeout: 30_000 }); await settle(page); await shoot(page, `${prefix}-library`); + // The switch sits beside the row's link: tapping it turns the source off and on and never opens it. + const toggle = page.getByTestId('source-active').first(); + for (const expected of [false, true]) { + await toggle.click(); + await page.waitForFunction((on) => (document.querySelector('[data-testid="source-active"] [role="switch"]') as HTMLInputElement | null)?.checked === on, expected); + if (!new URL(page.url()).pathname.endsWith('/library')) throw new Error('Tapping a source switch opened the source'); + } + + await page.getByTestId('library-more').click(); + await page.getByTestId('library-more-menu').waitFor(); + await shoot(page, `${prefix}-library-more`); + await page.getByTestId('library-more').click(); + + await page.getByTestId('folder-chip').filter({ hasText: 'Evidence' }).click(); + await page.getByTestId('folder-bar').waitFor(); + await shoot(page, `${prefix}-library-folder`); + + // New folder asks for a name (window.prompt in the web preview) and stays chosen once it is saved. + page.once('dialog', (dialog) => void dialog.accept('Field notes')); + await page.getByTestId('library-more').click(); + await page.getByTestId('library-more-menu').getByRole('menuitem').nth(-2).click(); + await page.getByTestId('folder-bar').filter({ hasText: 'Field notes' }).waitFor({ timeout: 15_000 }); + await settle(page); + await page.waitForTimeout(500); + if ((await page.getByTestId('folder-bar').filter({ hasText: 'Field notes' }).count()) === 0) throw new Error('The new folder did not stay chosen'); + await shoot(page, `${prefix}-library-new-folder`); + await page.getByTestId('library-add').click(); await page.getByRole('menu').waitFor(); await shoot(page, `${prefix}-library-menu`); @@ -1011,11 +1198,45 @@ async function shootLibraryScreens(page: Page, prefix: string) { await page.waitForTimeout(700); await shoot(page, `${prefix}-new-note`); + // The real reader: pdf.js and mammoth read the fixtures in the page, as in the iOS DOM component. + await importFixture(page, 'congestion-charge-results.pdf'); + await page.getByText('Bus journey times improved by 9 percent', { exact: false }).first().waitFor({ timeout: 60_000 }); + await page.waitForTimeout(500); + await shoot(page, `${prefix}-add-file`); + await page.getByRole('button', { name: /^(Save imported source|Importált forrás mentése|Guardar fuente importada)$/ }).click(); + await page.getByTestId('toast').waitFor({ timeout: 30_000 }); + await page.waitForTimeout(400); + await shoot(page, `${prefix}-library-saved`); + + await importFixture(page, 'rebuttal-notes.docx'); + await page.getByText('Night buses follow demand', { exact: false }).first().waitFor({ timeout: 60_000 }); + await page.waitForTimeout(500); + await shoot(page, `${prefix}-add-file-docx`); + + await importFixture(page, 'whiteboard-photo.png'); + await page.getByRole('alert').first().waitFor({ timeout: 30_000 }); + await page.waitForTimeout(500); + await shoot(page, `${prefix}-add-file-error`); + await page.goto(`${ORIGIN}/library`); await page.getByTestId('source-row').first().click({ timeout: 30_000 }); await page.getByTestId('source-sheet').getByRole('heading').waitFor({ timeout: 30_000 }); await page.waitForTimeout(700); await shoot(page, `${prefix}-source`); + await page.getByTestId('source-sheet').getByRole('button', { name: /^(Edit|Szerkesztés|Editar)$/ }).click(); + await page.waitForTimeout(400); + await shoot(page, `${prefix}-source-edit`); + + // Delete acts at once and offers Undo; the write is answered locally. + await page.goto(`${ORIGIN}/library`); + await page.getByTestId('source-row').nth(1).click({ timeout: 30_000 }); + await page.getByTestId('source-delete').click({ timeout: 30_000 }); + await page.getByTestId('toast-action').waitFor({ timeout: 30_000 }); + await page.getByTestId('source-sheet').waitFor({ state: 'detached', timeout: 10_000 }); + await page.waitForTimeout(400); + await shoot(page, `${prefix}-library-undo`); + const haptics = await page.evaluate(() => (window as unknown as { __debatelabHaptics?: string[] }).__debatelabHaptics ?? []); + if (!haptics.includes('success')) problems.push(`${prefix}: deleting a source did not tap success`); } async function shootAskScreens(context: BrowserContext, prefix: string, language: string, problems: string[]) { @@ -1892,10 +2113,12 @@ async function main() { await mkdir(OUT, { recursive: true }); const server = await serve(); - const browser = await chromium.launch(); + // Launched in the try, so a missing browser still closes the server. + let browser: Browser | null = null; const problems: string[] = []; let shots = 0; try { + browser = await chromium.launch(); const session = DEMO_CODE ? await signIn(browser, problems) : null; if (!session) console.log('STAGING_DEMO_CODE is not set: signed-in screens skipped'); else if (offlineOnly) { @@ -1931,14 +2154,18 @@ async function main() { await shoot(tabPage, `${prefix}-failed`); throw error; }); - shots += 6; + shots += 10; + } + if (want('debate-check')) { + await shootDebateCheck(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'debate-check')); + shots += 1; } if (want('library')) { - await shootLibraryScreens(tabPage, prefix).catch(async (error: unknown) => { + await shootLibraryScreens(tabPage, prefix, problems).catch(async (error: unknown) => { await shoot(tabPage, `${prefix}-failed`); throw error; }); - shots += 5; + shots += 14; } for (const [name, route] of Object.entries(SIGNED_IN_ROUTES)) { if (!want(name)) continue; @@ -2035,7 +2262,7 @@ async function main() { } } } finally { - await browser.close(); + await browser?.close(); server.close(); } diff --git a/apps/mobile/src/app/(tabs)/(debate)/index.tsx b/apps/mobile/src/app/(tabs)/(debate)/index.tsx index 5200728e8..9d29cf38a 100644 --- a/apps/mobile/src/app/(tabs)/(debate)/index.tsx +++ b/apps/mobile/src/app/(tabs)/(debate)/index.tsx @@ -1,4 +1,4 @@ -import { useFocusEffect, useRouter } from 'expo-router'; +import { Link, useFocusEffect, useRouter } from 'expo-router'; import { useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; @@ -14,17 +14,20 @@ import { Screen } from '@/components/screen'; import { SegmentedControl } from '@/components/segmented-control'; import { SOURCE_ICONS, sourceMeta } from '@/components/source-display'; import { Text } from '@/components/text'; -import { WorkRow, workHref } from '@/components/work-row'; +import { WorkRow } from '@/components/work-row'; import { useAuth } from '@/lib/auth'; import { useDebates } from '@/lib/debate-context'; import { fetchDebateCounts, fetchRecentWork, + ROLE_LABEL_KEYS, + type Debate, type DebateCounts, type DebateItem, type DebateSide, } from '@/lib/debates'; import { HOME_COLUMNS_WIDTH, useLayoutClass } from '@/lib/layout'; +import { useSourceActions } from '@/lib/source-actions'; import { fetchSources, type Source } from '@/lib/sources'; import { useAnnouncements } from '@/lib/use-announcements'; import { useCopyHolder } from '@/lib/use-copy-holder'; @@ -111,6 +114,7 @@ export default function DebateScreen() { {current.motion} + ); @@ -124,11 +128,55 @@ export default function DebateScreen() { {current.motion} + ); } +/** + * The website's settings card under the motion (format and instructions), with + * the role and judge the app's form adds, and Edit settings. + */ +function DebateSettingsCard({ debate }: { debate: Debate }) { + const { t } = useTranslation(); + const router = useRouter(); + const { colors } = useTheme(); + const { format, role, judge } = debate.settings; + const facts = [ + format ? `${t('debate.format')}: ${t(`debate.formats.${format}`, { defaultValue: format })}` : null, + role ? `${t('debate.role')}: ${role in ROLE_LABEL_KEYS ? t(ROLE_LABEL_KEYS[role as keyof typeof ROLE_LABEL_KEYS]) : role}` : null, + judge ? `${t('mobile.debate.judge')}: ${judge}` : null, + ].filter(Boolean); + return ( + + + {facts.length > 0 ? ( + + {facts.join(' · ')} + + ) : null} + {debate.instructions ? ( + + {debate.instructions} + + ) : null} + + router.push('/edit-debate')} + testID="edit-debate" + hitSlop={6} + style={({ pressed }) => [styles.editButton, { backgroundColor: colors.tile }, pressed && styles.pressed]}> + + + {t('debate.editSettings')} + + + + ); +} + function SwitcherPill({ onPress }: { onPress: () => void }) { const { t } = useTranslation(); const { colors } = useTheme(); @@ -274,7 +322,7 @@ function DebateDetails({ debateId, dashboard = false }: { debateId: string; dash ) : ( {items.map((item, index) => ( - router.navigate(workHref(item, 'debate'))} /> + void load(() => true)} /> ))} )} @@ -303,7 +351,7 @@ function DebateDetails({ debateId, dashboard = false }: { debateId: string; dash ) : ( {sources.map((source, index) => ( - router.push({ pathname: '/source/[id]', params: { id: source.id } })} /> + ))} ); @@ -330,8 +378,29 @@ function DebateDetails({ debateId, dashboard = false }: { debateId: string; dash ); } -/** A source in the dashboard's Recent sources; it opens in the source sheet. */ -function RecentSource({ source, first, onPress }: { source: Source; first: boolean; onPress: () => void }) { +/** A source in the dashboard's Recent sources; it opens in the source sheet, with a preview and Copy and Share on a long press. */ +function RecentSource({ source, first }: { source: Source; first: boolean }) { + const { t } = useTranslation(); + const actions = useSourceActions(); + return ( + + + + + + + void actions.copy(source)}> + {t('common.copy')} + + actions.share(source)}> + {t('mobile.library.share')} + + + + ); +} + +function RecentSourceRow({ source, first, onPress }: { source: Source; first: boolean; onPress?: () => void }) { const { t } = useTranslation(); const { colors } = useTheme(); return ( @@ -339,17 +408,21 @@ function RecentSource({ source, first, onPress }: { source: Source; first: boole accessibilityRole="button" onPress={onPress} testID="recent-source" - style={({ pressed }) => [styles.sourceRow, !first && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> - - - - {source.title || t('libraryPage.untitledSource')} - - - {sourceMeta(t, source)} - - - + style={!first ? { borderTopWidth: 1, borderTopColor: colors.line } : undefined}> + {({ pressed }) => ( + + + + + {source.title || t('libraryPage.untitledSource')} + + + {sourceMeta(t, source)} + + + + + )} ); } @@ -436,7 +509,13 @@ const styles = StyleSheet.create({ emptyText: { gap: 8 }, emptyTitle: { fontSize: 28, lineHeight: 34, letterSpacing: -0.6 }, emptyOwl: { alignSelf: 'flex-start', marginTop: 8, marginBottom: -6 }, - motion: { fontSize: 27, lineHeight: 32, letterSpacing: -0.6, marginTop: 12, marginBottom: 15 }, + motion: { fontSize: 27, lineHeight: 32, letterSpacing: -0.6, marginTop: 12, marginBottom: 10 }, + settings: { gap: 9, borderTopWidth: 1, borderBottomWidth: 1, paddingVertical: 11, marginBottom: 15 }, + settingsText: { gap: 3 }, + settingsFacts: { fontSize: 14, lineHeight: 19 }, + settingsInstructions: { fontSize: 14.5, lineHeight: 20 }, + editButton: { alignSelf: 'flex-start', flexDirection: 'row', alignItems: 'center', gap: 5, borderRadius: radius.pill, paddingHorizontal: 11, minHeight: 32 }, + editLabel: { fontSize: 14 }, stats: { flexDirection: 'row', gap: 24, marginTop: 13, marginBottom: 15, marginHorizontal: 2 }, statValue: { fontSize: 21, lineHeight: 26, letterSpacing: -0.2 }, statLabel: { fontSize: 15 }, diff --git a/apps/mobile/src/app/(tabs)/(library)/library.tsx b/apps/mobile/src/app/(tabs)/(library)/library.tsx index f92f66ea8..59088805b 100644 --- a/apps/mobile/src/app/(tabs)/(library)/library.tsx +++ b/apps/mobile/src/app/(tabs)/(library)/library.tsx @@ -1,102 +1,161 @@ +import { GENERAL_FOLDER, matchesLibrarySearch, normalizeFolder, sortLibrarySources, type LibrarySort } from '@debatelab/core/library'; import { useFocusEffect, useRouter } from 'expo-router'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, Pressable, ScrollView, StyleSheet, View, useWindowDimensions } from 'react-native'; -import ReanimatedSwipeable, { type SwipeableMethods } from 'react-native-gesture-handler/ReanimatedSwipeable'; +import { ActivityIndicator, ScrollView, StyleSheet, View, useWindowDimensions } from 'react-native'; import { TextButton } from '@/components/buttons'; -import { Card, IconTile } from '@/components/card'; +import { Card } from '@/components/card'; import { Chip } from '@/components/chip'; -import { Icon } from '@/components/icon'; -import { LibraryHeader, type AddSourceKind } from '@/components/library-header'; +import { EmptyState } from '@/components/empty-state'; +import { LibraryHeader } from '@/components/library-header'; +import type { AddSourceKind, SourceTypeFilter } from '@/components/library-options'; +import { FolderBar, GroundingBanner } from '@/components/library-parts'; import { OfflineNotice } from '@/components/offline-notice'; import { OneOffNotice } from '@/components/one-off-notice'; import { Screen } from '@/components/screen'; import { SearchField } from '@/components/search-field'; -import { SOURCE_ICONS, sourceMeta } from '@/components/source-display'; +import { folderLabel } from '@/components/source-display'; +import { SourceRow } from '@/components/source-row'; import { SourceView } from '@/components/source-view'; import { Text } from '@/components/text'; +import { useToast } from '@/components/toast'; import { useAuth } from '@/lib/auth'; -import { confirmDestructive } from '@/lib/confirm'; +import { confirmDestructive, promptText } from '@/lib/confirm'; import { useDebates } from '@/lib/debate-context'; +import { haptics } from '@/lib/haptics'; import { LIBRARY_ROOMY_WIDTH, LIST_WIDTH, SIDEBAR_WIDTH, useLayoutClass } from '@/lib/layout'; import { openRowId } from '@/lib/open-pane'; -import { fetchSources, removeSource, subscribeSourcesChanged, type Source, type SourceType } from '@/lib/sources'; +import { pickSourceFile } from '@/lib/pick-source-file'; +import { useSourceActions } from '@/lib/source-actions'; +import { holdPickedFile } from '@/lib/source-file'; +import { clearLibrary, createFolder, deleteFolder, fetchSources, renameFolder, subscribeSourcesChanged, type Source } from '@/lib/sources'; import { useCopyHolder } from '@/lib/use-copy-holder'; +import { useLibraryFolders } from '@/lib/use-library-folders'; import { useTheme } from '@/theme/use-theme'; -import { EmptyState } from '@/components/empty-state'; - -type Filter = 'all' | SourceType; -// In the mock's order. Files are listed read-only: the app cannot add them yet. -const FILTERS: { value: Filter; label: string }[] = [ - { value: 'all', label: 'mobile.library.filters.all' }, - { value: 'file', label: 'mobile.library.filters.files' }, - { value: 'url', label: 'mobile.library.filters.links' }, - { value: 'text', label: 'mobile.library.filters.notes' }, -]; - -function matches(source: Source, query: string): boolean { - // The website searches titles, text and origins. - const needle = query.trim().toLocaleLowerCase(); - return !needle || [source.title, source.content, source.origin ?? ''].some((value) => value.toLocaleLowerCase().includes(needle)); -} +const ALL = '\u0000all'; /** - * The Current Debate's sources, as in mock 04. It shows the Current Debate even in one-off mode, like the website. + * The Current Debate's sources, as in mock 04, with the website's Library + * around them: folders, the Active switch, sorting, file import and Clear all. + * It shows the Current Debate even in one-off mode, like the website. * On iPad the list and the open source sit side by side (iPad mock A). */ export default function LibraryScreen() { - const { t } = useTranslation(); - const router = useRouter(); - const { status, current } = useDebates(); - const debateId = current?.id ?? null; + const { current } = useDebates(); const layout = useLayoutClass(); - - const onAdd = (kind: AddSourceKind) => router.push(kind === 'link' ? '/add-link' : '/new-note'); - - if (layout !== 'phone') return ; - - return ( - - - {/* New sources still go to the Current Debate in one-off mode, as on the website, so this only says the mode is on. */} - - {status === 'loading' ? : null} - {status !== 'loading' && !debateId ? ( - - - {t('debate.libraryNeedsDebate')} - - router.push('/debates')} /> - - ) : null} - {debateId ? : null} - - ); + const debateId = current?.id ?? null; + // A new debate starts with a fresh list, folder and search. + return ; } -/** The iPad Library: the list on the left, the source it opened on the right, in place of the source sheet. */ -function LibrarySplit({ wide, onAdd }: { wide: boolean; onAdd: (kind: AddSourceKind) => void }) { +function LibraryView({ debateId, layout }: { debateId: string | null; layout: ReturnType }) { const { t } = useTranslation(); const router = useRouter(); const { colors } = useTheme(); + const toast = useToast(); const { width } = useWindowDimensions(); const { session } = useAuth(); - const { status, current } = useDebates(); + const { status } = useDebates(); const userId = session?.user.id ?? null; - const debateId = current?.id ?? null; - // The selection belongs to the account and debate it was made in; switching either clears it. - const [selected, setSelected] = useState<{ userId: string; debateId: string; id: string } | null>(null); - const shown = selected && selected.userId === userId && selected.debateId === debateId ? selected.id : null; - const clearSelected = useCallback((id: string) => { - setSelected((previous) => (previous?.id === id ? null : previous)); - }, []); - const available = width - (wide ? SIDEBAR_WIDTH : 0); + const library = useLibrary(debateId); + // On iPad the selection belongs to the account it was made in; signing out clears it. + const [selected, setSelected] = useState<{ userId: string; id: string } | null>(null); + const shown = layout !== 'phone' && selected && selected.userId === userId ? selected.id : null; + + // A refresh that no longer has the open source closes the detail pane. Searching does not: `sources` is the full list. + useEffect(() => { + if (!shown || library.sources === null) return; + if (openRowId(shown, library.sources) === null) setSelected(null); + }, [shown, library.sources]); + + const onAdd = async (kind: AddSourceKind) => { + if (kind === 'link') return router.push('/add-link'); + if (kind === 'note') return router.push('/new-note'); + const asset = await pickSourceFile().catch((cause: unknown) => { + console.warn('Could not open the file picker:', cause instanceof Error ? cause.message : cause); + return null; + }); + if (asset) router.push({ pathname: '/add-file', params: { file: holdPickedFile(asset) } }); + }; + + const newFolder = async () => { + if (!userId) return; + const name = (await promptText({ title: t('libraryPage.newFolder'), message: '', defaultValue: '', confirm: t('libraryPage.createFolder'), cancel: t('common.cancel') }))?.trim(); + if (!name) return; + try { + const folder = await createFolder(userId, name); + library.setFolder(folder); + toast({ tone: 'success', title: t('libraryPage.newFolder'), message: folderLabel(t, folder) }); + } catch { + toast({ tone: 'error', title: t('libraryPage.saveFailed') }); + } + }; + + const clearAll = async () => { + if (!userId || !debateId) return; + const confirmed = await confirmDestructive({ title: t('libraryPage.clearAll'), message: t('libraryPage.clearConfirm'), confirm: t('libraryPage.clearAll'), cancel: t('common.cancel') }); + if (!confirmed) return; + try { + await clearLibrary(userId, debateId); + setSelected(null); + library.setSources([]); + toast({ tone: 'success', title: t('common.deleted'), message: t('libraryPage.clearSuccess') }); + } catch { + toast({ tone: 'error', title: t('common.error'), message: t('libraryPage.clearFailed') }); + } + }; + + const header = ( + void onAdd(kind)} + disabled={!debateId} + sort={library.sort} + onSort={(sort) => { + haptics.select(); + library.setSort(sort); + }} + filter={library.filter} + onFilter={(filter) => { + haptics.select(); + library.setFilter(filter); + }} + onNewFolder={() => void newFolder()} + onClearAll={() => void clearAll()} + canClear={(library.sources?.length ?? 0) > 0} + /> + ); + const noDebate = status !== 'loading' && !debateId ? ( + + + {t('debate.libraryNeedsDebate')} + + router.push('/debates')} /> + + ) : null; + const body = debateId ? ( + setSelected({ userId, id: source.id })} /> + ) : null; + + if (layout === 'phone') { + return ( + + {header} + {/* New sources still go to the Current Debate in one-off mode, as on the website, so this only says the mode is on. */} + + {status === 'loading' ? : null} + {noDebate} + {body} + + ); + } + + const available = width - (layout === 'wide' ? SIDEBAR_WIDTH : 0); // With nothing open the list takes the whole width, as the website's Library does; an open // source splits it, and the list keeps 40% (at least its usual width) so titles stay readable. const listWidth = Math.max(available >= LIBRARY_ROOMY_WIDTH ? LIST_WIDTH.libraryRoomy : LIST_WIDTH.library, Math.round(available * 0.4)); - return ( - + {header} {status === 'loading' ? : null} - {status !== 'loading' && !debateId ? ( - - - {t('debate.libraryNeedsDebate')} - - router.push('/debates')} /> - - ) : null} - {debateId && userId ? ( - setSelected({ userId, debateId, id: source.id })} - onRemoved={clearSelected} - /> - ) : null} + {noDebate} + {body} {shown && userId ? ( - + setSelected(null)} /> ) : null} ); } -function SourceList({ - debateId, - selectedId, - onOpen, - onRemoved, -}: { - debateId: string; - /** The source open beside the list on iPad. */ - selectedId?: string | null; - /** Opens a source beside the list instead of in the source sheet. */ - onOpen?: (source: Source) => void; - onRemoved?: (id: string) => void; -}) { - const { t } = useTranslation(); - const router = useRouter(); +type Library = ReturnType; + +/** One debate's sources and folders, with the website's search, folder filter and sort, kept while the tab is open. */ +function useLibrary(debateId: string | null) { const { session } = useAuth(); - const { colors } = useTheme(); + const userId = session?.user.id ?? null; const [sources, setSources] = useState(null); const [failed, setFailed] = useState(false); - const [deleteFailed, setDeleteFailed] = useState(false); const [query, setQuery] = useState(''); - const [filter, setFilter] = useState('all'); + const [filter, setFilter] = useState('all'); + const [sort, setSort] = useState('smart'); + const [folder, setFolder] = useState(ALL); + const { folders } = useLibraryFolders(debateId); const loads = useRef(0); const copies = useCopyHolder(); - const userId = session?.user.id ?? null; const load = useCallback(async () => { - if (!userId) return; + if (!userId || !debateId) return; const load = ++loads.current; try { const next = await fetchSources(userId, debateId, copies.mark()); @@ -185,38 +219,39 @@ function SourceList({ ); useEffect(() => subscribeSourcesChanged(() => void load()), [load]); - // A refresh that no longer has the open source closes the detail pane. Searching does not: `sources` is the full list. + // A folder that no longer exists here (renamed or deleted elsewhere, or only another debate's) + // shows all folders again. Checked only when the folder list reloads: a folder just created or + // renamed here is chosen before its reload lands, and the reload then lists it. + const chosen = useRef(folder); + chosen.current = folder; useEffect(() => { - if (!selectedId || sources === null) return; - if (openRowId(selectedId, sources) === null) onRemoved?.(selectedId); - }, [sources, selectedId, onRemoved]); + if (chosen.current !== ALL && !folders.includes(chosen.current)) setFolder(ALL); + }, [folders]); + const inFolder = useMemo( + () => (sources ?? []).filter((source) => folder === ALL || normalizeFolder(source.folder) === folder), + [sources, folder], + ); const visible = useMemo( - () => (sources ?? []).filter((source) => (filter === 'all' || source.type === filter) && matches(source, query)), - [sources, filter, query], + () => sortLibrarySources(inFolder.filter((source) => (filter === 'all' || source.type === filter) && matchesLibrarySearch(source, query)), sort), + [inFolder, filter, query, sort], ); - const remove = async (source: Source) => { - if (!userId) return false; - const confirmed = await confirmDestructive({ - title: t('mobile.library.deleteSourceTitle'), - message: t('mobile.library.deleteSourceDesc'), - confirm: t('common.delete'), - cancel: t('common.cancel'), - }); - if (!confirmed) return false; - setDeleteFailed(false); - try { - await removeSource(userId, source.id); - setSources((previous) => previous?.filter((item) => item.id !== source.id) ?? previous); - onRemoved?.(source.id); - return true; - } catch (cause) { - console.warn('Could not delete the source:', cause instanceof Error ? cause.message : cause); - setDeleteFailed(true); - return false; - } - }; + /** Shows a change at once; `undo` puts the rows back when the write fails. */ + const patch = useCallback((ids: string[], change: Partial) => { + setSources((previous) => previous?.map((source) => (ids.includes(source.id) ? { ...source, ...change } : source)) ?? previous); + }, []); + + return { userId, debateId: debateId!, sources, setSources, failed, load, query, setQuery, filter, setFilter, sort, setSort, folder, setFolder, folders, inFolder, visible, patch }; +} + +function LibraryBody({ library, selectedId, onSelect }: { library: Library; selectedId: string | null; onSelect?: (source: Source) => void }) { + const { t } = useTranslation(); + const router = useRouter(); + const toast = useToast(); + const actions = useSourceActions(); + const activeWrites = useRef({ seq: 0, latest: new Map(), confirmed: new Map() }); + const { sources, failed, folders, folder, inFolder, visible, patch, userId, debateId } = library; if (failed && !sources) { return ( @@ -224,31 +259,108 @@ function SourceList({ {t('mobile.library.loadFailed')} - void load()} /> + void library.load()} /> ); } if (!sources) return ; + const active = sources.filter((source) => source.isActive).length; + const chipFolders = Array.from(new Set([...folders, ...sources.map((source) => source.folder)])).sort(); + + // The newest change to each source decides what shows, as the website's switch queue does: a + // failure that a newer tap already replaced changes nothing, and otherwise the row goes back to + // the last value the server accepted, not to what showed when it was tapped. + const toggle = async (targets: Source[], isActive: boolean, inFolderName?: string) => { + const writes = activeWrites.current; + const seq = ++writes.seq; + const ids = targets.map((source) => source.id); + for (const source of targets) { + if (!writes.confirmed.has(source.id)) writes.confirmed.set(source.id, source.isActive); + writes.latest.set(source.id, seq); + } + patch(ids, { isActive }); + const saved = await actions.setActive(targets, isActive, inFolderName); + const back = new Map(); + for (const id of ids) { + if (saved) writes.confirmed.set(id, isActive); + if (writes.latest.get(id) !== seq) continue; + writes.latest.delete(id); + if (!saved) back.set(id, writes.confirmed.get(id) ?? !isActive); + writes.confirmed.delete(id); + } + if (back.size > 0) library.setSources((previous) => previous?.map((source) => (back.has(source.id) ? { ...source, isActive: back.get(source.id)! } : source)) ?? previous); + }; + + const remove = async (source: Source) => { + const removed = await actions.remove(source); + if (removed) library.setSources((previous) => previous?.filter((item) => item.id !== source.id) ?? previous); + return removed; + }; + + const rename = async () => { + if (!userId || folder === ALL || folder === GENERAL_FOLDER) return; + const next = (await promptText({ title: t('libraryPage.renameFolder'), message: t('mobile.library.renamePrompt'), defaultValue: folder, confirm: t('common.save'), cancel: t('common.cancel') }))?.trim(); + if (!next || next === folder) return; + // Merging into an existing folder can't be split again, so it asks first. + if (chipFolders.includes(next)) { + const merge = await confirmDestructive({ title: t('libraryPage.mergeFolderTitle', { name: folderLabel(t, next) }), message: t('libraryPage.folderMergeConfirm'), confirm: t('libraryPage.mergeFolders'), cancel: t('common.cancel') }); + if (!merge) return; + } + try { + await renameFolder(userId, debateId, folder, next); + library.setFolder(next); + toast({ tone: 'success', title: t('libraryPage.folderRenamed'), message: t('libraryPage.folderRenamedDesc') }); + } catch { + toast({ tone: 'error', title: chipFolders.includes(next) ? t('libraryPage.mergeFailed') : t('libraryPage.folderUpdateFailed') }); + } + }; + + const removeFolder = async () => { + if (!userId || folder === ALL || folder === GENERAL_FOLDER) return; + const confirmed = await confirmDestructive({ title: t('libraryPage.deleteFolderTitle'), message: t('libraryPage.deleteFolderDesc'), confirm: t('common.delete'), cancel: t('common.cancel') }); + if (!confirmed) return; + try { + await deleteFolder(userId, debateId, folder); + library.setFolder(GENERAL_FOLDER); + toast({ tone: 'success', title: t('libraryPage.folderDeleted'), message: t('libraryPage.folderDeletedDesc') }); + } catch { + toast({ tone: 'error', title: t('libraryPage.folderUpdateFailed') }); + } + }; + return ( <> - - - {FILTERS.map((option) => ( + + {t('libraryPage.summary', { + sources: t('libraryPage.sourceCount', { count: sources.length }), + active: t('libraryPage.activeCount', { count: active }), + folders: t('libraryPage.folderCount', { count: chipFolders.length }), + })} + + {sources.length > 0 ? 0} /> : null} + + + {[ALL, ...chipFolders].map((option) => ( setFilter(option.value)} + testID="folder-chip" + onPress={() => library.setFolder(option)} /> ))} - - {deleteFailed ? ( - - {t('mobile.library.deleteFailed')} - + {folder !== ALL ? ( + void toggle(inFolder, isActive, folder)} + onRename={() => void rename()} + onDelete={() => void removeFolder()} + /> ) : null} {sources.length === 0 ? ( @@ -261,10 +373,25 @@ function SourceList({ (onOpen ? onOpen(source) : router.push({ pathname: '/source/[id]', params: { id: source.id } }))} - onDelete={() => remove(source)} + selectInstead={Boolean(onSelect)} + showFolder={folder === ALL} + compact={Boolean(selectedId)} + actions={{ + onOpen: () => (onSelect ? onSelect(source) : router.push({ pathname: '/source/[id]', params: { id: source.id } })), + onToggle: (isActive) => void toggle([source], isActive), + onDelete: () => remove(source), + onCopy: () => void actions.copy(source), + onShare: () => actions.share(source), + onMove: (target) => { + patch([source.id], { folder: target }); + void actions.move(source, target).then((moved) => { + if (!moved) patch([source.id], { folder: source.folder }); + }); + }, + }} /> ))} @@ -273,85 +400,13 @@ function SourceList({ ); } -/** A source row; swipe left to delete, with a confirmation, as on iOS. */ -function SourceRow({ - source, - first, - selected = false, - onOpen, - onDelete, -}: { - source: Source; - first: boolean; - selected?: boolean; - onOpen: () => void; - onDelete: () => Promise; -}) { - const { t } = useTranslation(); - const { colors } = useTheme(); - const swipeable = useRef(null); - - const remove = async () => { - const removed = await onDelete(); - if (!removed) swipeable.current?.close(); - }; - - return ( - ( - void remove()} - style={[styles.deleteAction, { backgroundColor: colors.destructive }]}> - - - {t('common.delete')} - - - )}> - { - if (event.nativeEvent.actionName === 'delete') void remove(); - }} - onPress={onOpen} - testID="source-row" - style={({ pressed }) => [styles.row, { backgroundColor: selected ? colors.tile : colors.card }, pressed && styles.pressed]}> - - - - {source.title || t('libraryPage.untitledSource')} - - - {sourceMeta(t, source)} - - - - - ); -} - const styles = StyleSheet.create({ loading: { marginTop: 32 }, body: { fontSize: 15, lineHeight: 21 }, + summary: { fontSize: 14.5, marginHorizontal: 2, marginTop: -6 }, chipRow: { marginHorizontal: -20, flexGrow: 0 }, chips: { 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 }, - rowMeta: { fontSize: 14 }, - pressed: { opacity: 0.7 }, - deleteAction: { width: 92, alignItems: 'center', justifyContent: 'center', gap: 3 }, - deleteLabel: { color: '#ffffff', fontSize: 13 }, split: { flex: 1, flexDirection: 'row' }, listPane: { flexGrow: 0, flexShrink: 0, borderRightWidth: StyleSheet.hairlineWidth }, listPaneFull: { flex: 1 }, diff --git a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx index 3d0d8b6ce..b5f18de0b 100644 --- a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx +++ b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx @@ -126,7 +126,8 @@ export default function PracticeScreen() { key={round.id} item={{ id: round.id, kind: 'sparring', title: round.motion, createdAt: round.updatedAt }} first={index === 0} - onPress={() => router.push({ pathname: '/sparring/[id]', params: { id: round.id, from: 'practice' } })} + from="practice" + onDeleted={() => void load()} /> ))} diff --git a/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx b/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx index 0bb7d8b92..3ceece427 100644 --- a/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx +++ b/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx @@ -11,7 +11,7 @@ import { PREPARE_TOOLS, prepareToolHref, type PrepareTool } from '@/components/p import { Screen } from '@/components/screen'; import { ScreenTitle } from '@/components/screen-title'; import { Text } from '@/components/text'; -import { WorkRow, workHref } from '@/components/work-row'; +import { WorkRow } from '@/components/work-row'; import { useAuth } from '@/lib/auth'; import { useDebates } from '@/lib/debate-context'; import { fetchPrepareWork, type DebateItem } from '@/lib/debates'; @@ -120,7 +120,7 @@ export default function PrepareScreen() { ) : ( {items.map((item, index) => ( - router.push(workHref(item, 'prepare'))} /> + void load()} /> ))} )} diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx index 685425f4e..d6c75f96e 100644 --- a/apps/mobile/src/app/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -1,5 +1,7 @@ import AppTabs from '@/components/app-tabs'; +import { useInactiveDebateCheck } from '@/lib/use-inactive-debate-check'; export default function TabsLayout() { + useInactiveDebateCheck(); return ; } diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 724620593..70454b13a 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -9,6 +9,7 @@ import { useEffect } from 'react'; import { StyleSheet } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { ToastHost, ToastProvider } from '@/components/toast'; import { AuthProvider, useAuth } from '@/lib/auth'; import { DebateProvider } from '@/lib/debate-context'; import { watchNetwork } from '@/lib/network-status'; @@ -24,7 +25,9 @@ export default function RootLayout() { - + + + @@ -89,10 +92,13 @@ function Root() { + + + {/* The website's limit and model dialogs; short, so the sheet stops at half height. */} @@ -126,6 +132,7 @@ function Root() { + ); diff --git a/apps/mobile/src/app/add-file.tsx b/apps/mobile/src/app/add-file.tsx new file mode 100644 index 000000000..b6b8177d7 --- /dev/null +++ b/apps/mobile/src/app/add-file.tsx @@ -0,0 +1,252 @@ +import type { SourceFileImportResult, SourceFileNotice } from '@debatelab/core/source-file-import'; +import { GENERAL_FOLDER } from '@debatelab/core/library'; +import type { TFunction } from 'i18next'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { MainActionButton, SecondaryButton, TextButton } from '@/components/buttons'; +import { Card } from '@/components/card'; +import { Field } from '@/components/field'; +import { FolderSelect } from '@/components/folder-select'; +import { Icon } from '@/components/icon'; +import { Owl } from '@/components/owl'; +import { SheetScrollView } from '@/components/sheet'; +import { Text } from '@/components/text'; +import { useToast } from '@/components/toast'; +import { useAuth } from '@/lib/auth'; +import { useDebates } from '@/lib/debate-context'; +import { haptics } from '@/lib/haptics'; +import { pickSourceFile } from '@/lib/pick-source-file'; +import { pickedFile, useSourceFileReader, type SourceFileOutcome } from '@/lib/source-file'; +import { addSource, DuplicateSourceError, fetchSources } from '@/lib/sources'; +import { useLibraryFolders } from '@/lib/use-library-folders'; +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +type Failure = Extract; + +/** The website's English messages, translated: the error codes from the shared reader name them. */ +function failureText(t: TFunction, failure: Failure): { title: string; message: string } { + const title = failure.code === 'unsupported' ? t('libraryPage.unsupportedFile') : t('libraryPage.importFailed'); + switch (failure.code) { + case 'too-large': + return { title, message: t('mobile.library.file.tooLarge') }; + case 'unsupported': + return { title, message: t('mobile.library.file.unsupported') }; + case 'empty': + return { title, message: t('mobile.library.file.empty') }; + // A failed read, or a reader that never answered (`timeout`): the website's "Could not read this file." + default: + return { + title, + message: failure.fileType === 'pdf' + ? t('mobile.library.file.parsePdf') + : failure.fileType === 'docx' + ? t('mobile.library.file.parseDocx') + : t('libraryPage.fileReadFailed'), + }; + } +} + +function noticeText(t: TFunction, notice: SourceFileNotice): string { + switch (notice.code) { + case 'truncated': + return t('mobile.library.file.truncated'); + case 'short': + return t('mobile.library.file.short'); + case 'invalid-json': + return t('mobile.library.file.invalidJson'); + case 'pdf-pages': + return t('mobile.library.file.pdfPages', { count: notice.pages, pages: notice.pages, imported: notice.imported }); + // mammoth's own notes about the document, in English, as on the website. + default: + return notice.message; + } +} + +/** + * Import a file: the Library's plus menu picks it, and this sheet reads it + * with the website's reader into an editable draft, as the website's Import + * file does, then saves it to the Current Debate. + */ +export default function AddFileSheet() { + const { t, i18n } = useTranslation(); + const router = useRouter(); + const { colors } = useTheme(); + const toast = useToast(); + const { session } = useAuth(); + const { current } = useDebates(); + const { file: fileId } = useLocalSearchParams<{ file?: string }>(); + const { folders } = useLibraryFolders(current?.id ?? null); + const { reader, read } = useSourceFileReader(); + const [draft, setDraft] = useState(null); + const [title, setTitle] = useState(''); + const [content, setContent] = useState(''); + const [folder, setFolder] = useState(GENERAL_FOLDER); + const [reading, setReading] = useState(false); + const [saving, setSaving] = useState(false); + const [failure, setFailure] = useState<{ title: string; message: string } | null>(null); + const [saveError, setSaveError] = useState(''); + const started = useRef(false); + + const readAsset = async (asset: Parameters[0]) => { + setReading(true); + setFailure(null); + setDraft(null); + const outcome = await read(asset); + // A newer file replaced this read, or the sheet closed: that one reports instead. + if (!outcome.ok && outcome.code === 'cancelled') return; + setReading(false); + if (!outcome.ok) { + haptics.warning(); + setFailure(failureText(t, outcome)); + return; + } + setDraft(outcome.result); + setTitle(outcome.result.title); + setContent(outcome.result.content); + }; + + useEffect(() => { + if (started.current) return; + started.current = true; + const asset = pickedFile(fileId); + if (asset) void readAsset(asset); + // Reads the file it was opened with, once. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fileId]); + + const chooseAnother = async () => { + const asset = await pickSourceFile(); + if (asset) await readAsset(asset); + }; + + const ready = Boolean(draft && title.trim() && content.trim()); + + const save = async () => { + const userId = session?.user.id; + if (!draft || !ready || !userId || !current || saving) return; + setSaving(true); + setSaveError(''); + try { + // The website matches a file by its name within the debate. + const name = draft.origin.trim().toLowerCase(); + const saved = await fetchSources(userId, current.id); + if (saved.some((source) => source.origin?.trim().toLowerCase() === name)) throw new DuplicateSourceError(); + await addSource(userId, { title: title.trim(), content: content.trim(), type: 'file', origin: draft.origin, folder, debateId: current.id }); + toast({ tone: 'success', title: t('libraryPage.sourceSaved', { title: title.trim() }) }); + router.back(); + } catch (cause) { + haptics.warning(); + setSaveError(cause instanceof DuplicateSourceError + ? `${t('libraryPage.duplicateSource')}. ${t('libraryPage.duplicateSourceDesc')}` + : t('mobile.library.saveFailed')); + setSaving(false); + } + }; + + const fileKind = draft ? draft.fileType.toUpperCase() : ''; + const words = draft ? t('libraryPage.wordCount', { count: draft.wordCount, formatted: draft.wordCount.toLocaleString(i18n.language) }) : ''; + + return ( + + {reader} + + + {t('libraryPage.importFileLabel')} + + router.back()} disabled={saving} /> + + + {!current ? ( + + {t('debate.libraryNeedsDebate')} + + ) : reading ? ( + + + + {t('libraryPage.readingFile')} + + + ) : failure ? ( + + + + + {failure.title} + + + {failure.message} + + + void chooseAnother()} /> + + ) : !draft ? ( + + + void chooseAnother()} /> + + ) : ( + <> + + + + {[draft.origin, fileKind, words].filter(Boolean).join(' · ')} + + + {draft.notices.length > 0 ? ( + + {draft.notices.map((notice, index) => ( + + + {noticeText(t, notice)} + + ))} + + ) : null} + + + + {saveError ? ( + + {saveError} + + ) : null} + void save()} /> + + )} + + ); +} + +const styles = StyleSheet.create({ + content: { gap: 16 }, + 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 }, + reading: { alignItems: 'center', gap: 14, paddingVertical: 28 }, + failure: { alignItems: 'center', paddingVertical: 22 }, + failureText: { alignItems: 'center', gap: 4 }, + failureTitle: { fontSize: 17, lineHeight: 22, textAlign: 'center' }, + centered: { textAlign: 'center' }, + meta: { flexDirection: 'row', alignItems: 'center', gap: 8, marginHorizontal: 4 }, + metaText: { flex: 1, fontSize: 14, lineHeight: 19 }, + notices: { borderWidth: 2, borderRadius: radius.field, padding: 12, gap: 8 }, + notice: { flexDirection: 'row', gap: 8, alignItems: 'flex-start' }, + noticeText: { flex: 1, fontSize: 14, lineHeight: 19 }, + contentField: { minHeight: 220, maxHeight: 420, paddingTop: 13, paddingBottom: 13, textAlignVertical: 'top' }, +}); diff --git a/apps/mobile/src/app/add-link.tsx b/apps/mobile/src/app/add-link.tsx index b566fbfcd..dea10c093 100644 --- a/apps/mobile/src/app/add-link.tsx +++ b/apps/mobile/src/app/add-link.tsx @@ -7,12 +7,16 @@ import { StyleSheet, View } from 'react-native'; import { MainActionButton, PrimaryButton, TextButton } from '@/components/buttons'; import { Card } from '@/components/card'; import { Field } from '@/components/field'; +import { FolderSelect } from '@/components/folder-select'; import { SheetScrollView } from '@/components/sheet'; import { Text } from '@/components/text'; +import { useToast } from '@/components/toast'; import { useAuth } from '@/lib/auth'; import { useDebates } from '@/lib/debate-context'; +import { haptics } from '@/lib/haptics'; import { addSource, fetchSources, importSourceFromUrl, isDuplicateOrigin } from '@/lib/sources'; import { useCopyHolder } from '@/lib/use-copy-holder'; +import { useLibraryFolders } from '@/lib/use-library-folders'; import { useTheme } from '@/theme/use-theme'; /** @@ -32,6 +36,9 @@ export default function AddLinkSheet() { const [busy, setBusy] = useState<'import' | 'save' | null>(null); const [error, setError] = useState(''); const copies = useCopyHolder(); + const toast = useToast(); + const { folders } = useLibraryFolders(current?.id ?? null); + const [folder, setFolder] = useState('General'); const runImport = async () => { const trimmed = url.trim(); @@ -64,10 +71,12 @@ export default function AddLinkSheet() { setBusy(null); return; } - await addSource(userId, { title: title.trim(), content: draft.content.trim(), type: 'url', origin: draft.url, debateId: current.id }); + await addSource(userId, { title: title.trim(), content: draft.content.trim(), type: 'url', origin: draft.url, folder, debateId: current.id }); + toast({ tone: 'success', title: t('libraryPage.sourceSaved', { title: title.trim() }) }); router.back(); } catch (cause) { console.warn('Could not save the source:', cause instanceof Error ? cause.message : cause); + haptics.warning(); setError(t('mobile.library.saveFailed')); setBusy(null); } @@ -123,6 +132,7 @@ export default function AddLinkSheet() { ) : null} + void save()} /> )} diff --git a/apps/mobile/src/app/crossfire.tsx b/apps/mobile/src/app/crossfire.tsx index 2e86118aa..e4728e22d 100644 --- a/apps/mobile/src/app/crossfire.tsx +++ b/apps/mobile/src/app/crossfire.tsx @@ -30,6 +30,7 @@ import { TimerRing } from '@/components/timer-ring'; import { ToolTitle, WorkingIn } from '@/components/tool-form'; import { abortError, AIError, isAbortError, type ResponseExtras, untilAborted } from '@/lib/ai-request'; import { useAuth } from '@/lib/auth'; +import { haptics } from '@/lib/haptics'; import { backLabel } from '@/lib/back-label'; import { resetContextChoices } from '@/lib/context-plan'; import { buildDebateContext } from '@/lib/context-request'; @@ -243,6 +244,8 @@ export default function CrossfireScreen() { }); if (controller.signal.aborted) return; dispatch({ type: 'ai-turn-received', turn: nextTurn(snapshot.turns.length + 1, 'ai', reply.type, reply.text) }); + // The turn is back with the student. + haptics.turn(); afterResponse(forUser, debateId, extras); if (reply.signalToken) { const signalController = new AbortController(); diff --git a/apps/mobile/src/app/debate-check.tsx b/apps/mobile/src/app/debate-check.tsx new file mode 100644 index 000000000..c3c1828e2 --- /dev/null +++ b/apps/mobile/src/app/debate-check.tsx @@ -0,0 +1,90 @@ +import { useRouter } from 'expo-router'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { MainActionButton, SecondaryButton } from '@/components/buttons'; +import { Owl } from '@/components/owl'; +import { SheetScrollView } from '@/components/sheet'; +import { Text } from '@/components/text'; +import { useDebates } from '@/lib/debate-context'; +import { formatRelativeDate } from '@/lib/dates'; +import { haptics } from '@/lib/haptics'; +import { useTheme } from '@/theme/use-theme'; + +/** + * The website's InactiveDebateGate: "Still preparing this debate?" with + * Continue, Switch debate and Start new debate. Closing the sheet counts as + * Continue, as Escape does on the website, so it never traps the student. + */ +export default function DebateCheckSheet() { + const { t } = useTranslation(); + const router = useRouter(); + const { colors } = useTheme(); + const { current, continueCurrent } = useDebates(); + const [continuing, setContinuing] = useState(false); + const [error, setError] = useState(''); + const answered = useRef(false); + const latest = useRef(continueCurrent); + latest.current = continueCurrent; + + useEffect(() => () => { + if (!answered.current) latest.current().catch(() => {}); + }, []); + + const keep = async () => { + if (continuing) return; + setContinuing(true); + setError(''); + try { + await continueCurrent(); + answered.current = true; + haptics.success(); + router.back(); + } catch { + haptics.warning(); + setError(t('debate.continueFailed')); + setContinuing(false); + } + }; + const go = (href: '/debates' | '/new-debate') => { + answered.current = true; + router.replace(href); + }; + + return ( + + + + {t('debate.gateTitle')} + + {current ? ( + + + {current.motion} + + + {t('debate.gateDesc', { when: formatRelativeDate(current.lastActivityAt) })} + + + ) : null} + {error ? ( + + {error} + + ) : null} + void keep()} /> + go('/debates')} /> + go('/new-debate')} /> + + ); +} + +const styles = StyleSheet.create({ + content: { gap: 14 }, + owl: { alignSelf: 'center' }, + title: { fontSize: 23, lineHeight: 29, letterSpacing: -0.4, textAlign: 'center' }, + text: { gap: 6, alignItems: 'center' }, + motion: { fontSize: 17, lineHeight: 23, textAlign: 'center' }, + body: { fontSize: 15, lineHeight: 21, textAlign: 'center' }, +}); diff --git a/apps/mobile/src/app/debates.tsx b/apps/mobile/src/app/debates.tsx index 3bd11fc62..c1b357ad3 100644 --- a/apps/mobile/src/app/debates.tsx +++ b/apps/mobile/src/app/debates.tsx @@ -2,6 +2,7 @@ import { useRouter } from 'expo-router'; import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityIndicator, Pressable, StyleSheet, Switch, TextInput, View } from 'react-native'; +import ReanimatedSwipeable from 'react-native-gesture-handler/ReanimatedSwipeable'; import { PrimaryButton, TextButton } from '@/components/buttons'; import { Card } from '@/components/card'; @@ -10,9 +11,11 @@ import { Icon } from '@/components/icon'; import { SheetScrollView } from '@/components/sheet'; import { Text } from '@/components/text'; import { useAuth } from '@/lib/auth'; +import { confirmDestructive } from '@/lib/confirm'; import { useDebates } from '@/lib/debate-context'; import { fetchSourceCounts, type Debate } from '@/lib/debates'; import { formatRelativeDate, isToday } from '@/lib/dates'; +import { haptics } from '@/lib/haptics'; import { useCopyHolder } from '@/lib/use-copy-holder'; import { radius } from '@/theme/colors'; import { fontFamily } from '@/theme/fonts'; @@ -27,7 +30,8 @@ export default function DebatesSheet() { const { t } = useTranslation(); const router = useRouter(); const { colors } = useTheme(); - const { status, debates, current, oneOff, setOneOff, switchTo, refresh } = useDebates(); + const { status, debates, current, oneOff, setOneOff, switchTo, refresh, remove } = useDebates(); + const [deleting, setDeleting] = useState(null); const { session } = useAuth(); const userId = session?.user.id ?? null; const [query, setQuery] = useState(''); @@ -77,6 +81,29 @@ export default function DebatesSheet() { } }; + // The website deletes any debate but the current one, after its warning; its sources and saved work go too. + const removeDebate = async (debate: Debate) => { + if (deleting || switching) return; + const confirmed = await confirmDestructive({ + title: t('mobile.debate.deleteTitle'), + message: `${debate.motion}\n\n${t('debate.deleteWarning')}`, + confirm: t('common.delete'), + cancel: t('common.cancel'), + }); + if (!confirmed) return; + setDeleting(debate.id); + setError(''); + try { + await remove(debate.id); + haptics.success(); + } catch { + haptics.warning(); + setError(t('debate.deleteFailed')); + } finally { + setDeleting(null); + } + }; + return ( @@ -128,19 +155,18 @@ export default function DebatesSheet() { const when = isToday(debate.lastActivityAt) ? t('mobile.debates.activeToday') : formatRelativeDate(debate.lastActivityAt); - return ( + const row = ( { + if (event.nativeEvent.actionName === 'delete') void removeDebate(debate); + }} disabled={switching !== null} onPress={() => void pick(debate)} - style={({ pressed }) => [ - styles.row, - index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, - pressed && styles.pressed, - ]}> + style={({ pressed }) => [styles.row, { backgroundColor: colors.card }, pressed && styles.pressed]}> {debate.motion} @@ -151,13 +177,38 @@ export default function DebatesSheet() { : t('mobile.debates.meta', { sources: t('mobile.debates.sourceCount', { count: sources }), when })} - {switching === debate.id ? ( + {switching === debate.id || deleting === debate.id ? ( ) : isCurrent ? ( ) : null} ); + const divider = index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }; + // The current debate cannot be deleted, as on the website. + if (isCurrent) return {row}; + return ( + ( + void removeDebate(debate)} + style={[styles.deleteAction, { backgroundColor: colors.destructive }]}> + + + {t('common.delete')} + + + )}> + {row} + + ); })} ) : null} @@ -177,7 +228,10 @@ export default function DebatesSheet() { { + haptics.tap(); + setOneOff(value); + }} accessibilityLabel={t('mobile.debates.oneOff')} trackColor={{ false: colors.border, true: colors.primaryFill }} ios_backgroundColor={colors.border} @@ -213,7 +267,9 @@ const styles = StyleSheet.create({ searchInput: { flex: 1, fontSize: 16.5, fontFamily: fontFamily.regular, paddingVertical: 10 }, loading: { marginVertical: 24 }, body: { fontSize: 15, lineHeight: 21 }, - list: { padding: 0, gap: 0 }, + list: { padding: 0, gap: 0, overflow: 'hidden' }, + deleteAction: { width: 92, alignItems: 'center', justifyContent: 'center', gap: 3 }, + deleteLabel: { color: '#ffffff', fontSize: 13 }, row: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 13, paddingHorizontal: 16 }, rowText: { flex: 1, gap: 3 }, motion: { fontSize: 16.5, lineHeight: 22 }, diff --git a/apps/mobile/src/app/edit-debate.tsx b/apps/mobile/src/app/edit-debate.tsx new file mode 100644 index 000000000..2488cf4ef --- /dev/null +++ b/apps/mobile/src/app/edit-debate.tsx @@ -0,0 +1,51 @@ +import { useRouter } from 'expo-router'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { TextButton } from '@/components/buttons'; +import { DebateForm } from '@/components/debate-form'; +import { SheetScrollView } from '@/components/sheet'; +import { Text } from '@/components/text'; +import { useToast } from '@/components/toast'; +import { useDebates } from '@/lib/debate-context'; + +/** The website's Edit settings for the Current Debate: motion, format, role and instructions, plus the judge. */ +export default function EditDebateSheet() { + const { t } = useTranslation(); + const router = useRouter(); + const toast = useToast(); + const { current, update } = useDebates(); + + return ( + + + + {t('debate.editSettings')} + + router.back()} /> + + {current ? ( + { + await update(current.id, values); + toast({ tone: 'success', title: t('debate.settingsSaved') }); + router.back(); + }} + /> + ) : ( + + {t('debate.noCurrentDesc')} + + )} + + ); +} + +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 }, +}); diff --git a/apps/mobile/src/app/history.tsx b/apps/mobile/src/app/history.tsx index 71b7a1816..49da13873 100644 --- a/apps/mobile/src/app/history.tsx +++ b/apps/mobile/src/app/history.tsx @@ -1,4 +1,4 @@ -import { useFocusEffect, useLocalSearchParams, useRouter, type Href } from 'expo-router'; +import { Link, 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'; @@ -33,6 +33,7 @@ import { keepIfSame } from '@/lib/keep-if-same'; import { suggestDebate } from '@/lib/motion'; import { deleteResult } from '@/lib/results'; import { deleteSparringRound, sparringDraft } from '@/lib/sparring'; +import { hasDocumentText, useWorkActions } from '@/lib/work-actions'; import { useCopyHolder } from '@/lib/use-copy-holder'; import { useTheme } from '@/theme/use-theme'; import { EmptyState } from '@/components/empty-state'; @@ -334,6 +335,8 @@ function HistoryRow({ item, first, onPress, onDelete }: { item: HistoryItem; fir const { t } = useTranslation(); const { colors } = useTheme(); const swipeable = useRef(null); + const work = useWorkActions(); + const href = historyHref(item); const title = item.title || t(`debate.kinds.${item.kind}`); const remove = async () => { @@ -341,7 +344,7 @@ function HistoryRow({ item, first, onPress, onDelete }: { item: HistoryItem; fir if (!removed) swipeable.current?.close(); }; - const body = ( + const row = ( [styles.row, { backgroundColor: colors.card }, pressed && styles.pressed]}> - - - - {title} - - - {itemMeta(t, item)} - - - {onPress ? : null} + style={{ backgroundColor: colors.card }}> + {({ pressed }) => ( + + + + + {title} + + + {itemMeta(t, item)} + + + {onPress ? : null} + + )} ); + // Press and hold for a preview and the menu: Discuss and Share for a document, Delete where History deletes. + const body = href ? ( + { + event.preventDefault(); + onPress?.(); + }}> + {row} + + + {hasDocumentText(item) ? ( + void work.discuss(item)}> + {t('common.discuss')} + + ) : null} + {hasDocumentText(item) ? ( + void work.share(item)}> + {t('mobile.library.share')} + + ) : null} + {onDelete ? ( + void remove()}> + {t('common.delete')} + + ) : null} + + + ) : row; const divider = !first && { borderTopWidth: 1, borderTopColor: colors.line }; if (!onDelete) return {body}; diff --git a/apps/mobile/src/app/new-debate.tsx b/apps/mobile/src/app/new-debate.tsx index 9f77eecdd..d036183f2 100644 --- a/apps/mobile/src/app/new-debate.tsx +++ b/apps/mobile/src/app/new-debate.tsx @@ -1,45 +1,18 @@ import { useRouter } from 'expo-router'; -import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Pressable, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; -import { MainActionButton, TextButton } from '@/components/buttons'; -import { Card } from '@/components/card'; -import { Field } from '@/components/field'; -import { Icon } from '@/components/icon'; +import { TextButton } from '@/components/buttons'; +import { DebateForm } from '@/components/debate-form'; import { SheetScrollView } from '@/components/sheet'; import { Text } from '@/components/text'; import { useDebates } from '@/lib/debate-context'; -import { DEBATE_FORMATS, MAX_MOTION_LENGTH, type DebateFormat } from '@/lib/debates'; -import { useTheme } from '@/theme/use-theme'; -/** Start a debate: motion and format, like the website's Start new debate dialog. It becomes current. */ +/** Start a debate: the website's Start new debate dialog (motion, format, role, instructions) plus the judge. It becomes current. */ export default function NewDebateSheet() { const { t } = useTranslation(); const router = useRouter(); - const { colors } = useTheme(); const { create } = useDebates(); - const [motion, setMotion] = useState(''); - const [format, setFormat] = useState('custom'); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(''); - - const submit = async () => { - // The button stays disabled until there is a motion, as on the website. - if (!motion.trim()) return; - setSaving(true); - setError(''); - try { - // The website's form saves the role too; it has no role picker here yet, so it keeps the default. - await create({ motion, settings: { format, role: 'general' } }); - // Closes this sheet and the switcher under it, if it was opened from there. - router.dismissAll(); - } catch (cause) { - console.warn('Could not create the debate:', cause instanceof Error ? cause.message : cause); - setError(t('debate.saveFailed')); - setSaving(false); - } - }; return ( @@ -47,64 +20,18 @@ export default function NewDebateSheet() { {t('debate.startNew')} - router.back()} disabled={saving} /> + router.back()} /> {t('debate.startNewDesc')} - - - - - - {t('debate.format')} - - - {DEBATE_FORMATS.map((value, index) => { - const selected = value === format; - return ( - setFormat(value)} - style={({ pressed }) => [ - styles.option, - index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, - pressed && styles.pressed, - ]}> - - {t(`debate.formats.${value}`)} - - {selected ? : null} - - ); - })} - - - - {error ? ( - - {error} - - ) : null} - - void submit()} + { + await create(values); + // Closes this sheet and the switcher under it, if it was opened from there. + router.dismissAll(); + }} /> ); @@ -114,11 +41,4 @@ 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 }, - motion: { minHeight: 96, paddingTop: 13, paddingBottom: 13, textAlignVertical: 'top' }, - group: { gap: 7 }, - label: { fontSize: 14, marginLeft: 4 }, - list: { padding: 0, gap: 0 }, - option: { flexDirection: 'row', alignItems: 'center', minHeight: 48, paddingHorizontal: 16, gap: 12 }, - optionLabel: { flex: 1, fontSize: 16 }, - pressed: { opacity: 0.6 }, }); diff --git a/apps/mobile/src/app/new-note.tsx b/apps/mobile/src/app/new-note.tsx index 1b29035f8..fa110d123 100644 --- a/apps/mobile/src/app/new-note.tsx +++ b/apps/mobile/src/app/new-note.tsx @@ -5,11 +5,15 @@ import { StyleSheet, View } from 'react-native'; import { MainActionButton, TextButton } from '@/components/buttons'; import { Field } from '@/components/field'; +import { FolderSelect } from '@/components/folder-select'; import { SheetScrollView } from '@/components/sheet'; import { Text } from '@/components/text'; +import { useToast } from '@/components/toast'; import { useAuth } from '@/lib/auth'; import { useDebates } from '@/lib/debate-context'; +import { haptics } from '@/lib/haptics'; import { addSource } from '@/lib/sources'; +import { useLibraryFolders } from '@/lib/use-library-folders'; import { useTheme } from '@/theme/use-theme'; /** Write a note: a text source in the Current Debate, like the website's manual entry. */ @@ -23,6 +27,9 @@ export default function NewNoteSheet() { const [content, setContent] = useState(''); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); + const toast = useToast(); + const { folders } = useLibraryFolders(current?.id ?? null); + const [folder, setFolder] = useState('General'); const ready = Boolean(title.trim() && content.trim()); @@ -32,10 +39,12 @@ export default function NewNoteSheet() { setSaving(true); setError(''); try { - await addSource(userId, { title: title.trim(), content: content.trim(), type: 'text', debateId: current.id }); + await addSource(userId, { title: title.trim(), content: content.trim(), type: 'text', folder, debateId: current.id }); + toast({ tone: 'success', title: t('libraryPage.sourceSaved', { title: title.trim() }) }); router.back(); } catch (cause) { console.warn('Could not save the note:', cause instanceof Error ? cause.message : cause); + haptics.warning(); setError(t('mobile.library.saveFailed')); setSaving(false); } @@ -73,6 +82,7 @@ export default function NewNoteSheet() { editable={!saving} style={styles.noteBody} /> + {error ? ( {error} diff --git a/apps/mobile/src/app/source/[id].tsx b/apps/mobile/src/app/source/[id].tsx index c19522ba6..e69ae0234 100644 --- a/apps/mobile/src/app/source/[id].tsx +++ b/apps/mobile/src/app/source/[id].tsx @@ -10,7 +10,8 @@ export default function SourceSheet() { const { id } = useLocalSearchParams<{ id: string }>(); return ( - router.back()} /> + {/* Opened from a link with nothing under it, closing goes to the Library. */} + (router.canGoBack() ? router.back() : router.replace('/library'))} /> ); } diff --git a/apps/mobile/src/components/chip.tsx b/apps/mobile/src/components/chip.tsx index f8771b32e..01ca25210 100644 --- a/apps/mobile/src/components/chip.tsx +++ b/apps/mobile/src/components/chip.tsx @@ -5,6 +5,7 @@ import { Icon } from './icon'; import type { IconName } from './icon-paths'; import { Text } from './text'; +import { haptics } from '@/lib/haptics'; import { radius } from '@/theme/colors'; import { useTheme } from '@/theme/use-theme'; @@ -38,7 +39,12 @@ export function Chip({ accessibilityRole={accessibilityRole} accessibilityLabel={accessibilityLabel} accessibilityState={accessibilityRole === 'switch' || accessibilityRole === 'radio' ? { checked: Boolean(selected) } : { selected: Boolean(selected) }} - onPress={onPress} + onPress={() => { + // A switch chip taps; a filter, tab or radio chip ticks like a picker. + if (accessibilityRole === 'switch') haptics.tap(); + else if (!selected) haptics.select(); + onPress(); + }} testID={testID} style={({ pressed }) => [ styles.chip, diff --git a/apps/mobile/src/components/debate-form.tsx b/apps/mobile/src/components/debate-form.tsx new file mode 100644 index 000000000..8e952abc2 --- /dev/null +++ b/apps/mobile/src/components/debate-form.tsx @@ -0,0 +1,192 @@ +import { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, View } from 'react-native'; + +import { MainActionButton } from './buttons'; +import { Card } from './card'; +import { Field } from './field'; +import { FormGroup, SelectRow } from './form-rows'; +import { Icon } from './icon'; +import { Text } from './text'; + +import { + DEBATE_FORMATS, + DEBATE_ROLES, + MAX_INSTRUCTIONS_LENGTH, + MAX_JUDGE_LENGTH, + MAX_MOTION_LENGTH, + ROLE_LABEL_KEYS, + type Debate, + type DebateSettings, +} from '@/lib/debates'; +import { haptics } from '@/lib/haptics'; +import { useTheme } from '@/theme/use-theme'; + +export interface DebateFormValues { + motion: string; + settings: DebateSettings; + instructions: string; +} + +/** + * The website's DebateForm: motion, format, your role and instructions, with + * its limits and counters. The app adds the judge, which the website stores + * and its prompts read as "Judge expectations" but its form does not show. + * Settings the form does not show are kept. + */ +export function DebateForm({ + initial, + submitLabel, + onSubmit, +}: { + initial?: Partial>; + submitLabel: string; + onSubmit: (values: DebateFormValues) => Promise; +}) { + const { t, i18n } = useTranslation(); + const { colors } = useTheme(); + const [motion, setMotion] = useState(initial?.motion ?? ''); + const [format, setFormat] = useState(initial?.settings?.format ?? 'custom'); + const [role, setRole] = useState(initial?.settings?.role ?? 'general'); + const [judge, setJudge] = useState(initial?.settings?.judge ?? ''); + const [instructions, setInstructions] = useState(initial?.instructions ?? ''); + const [saving, setSaving] = useState(false); + // One tap is one save: a second tap before the button re-renders busy is ignored. + const savingRef = useRef(false); + const [error, setError] = useState(''); + const count = (value: string, max: number) => `${value.length.toLocaleString(i18n.language)}/${max.toLocaleString(i18n.language)}`; + + const submit = async () => { + // The button stays disabled until there is a motion, as on the website. + if (!motion.trim() || savingRef.current) return; + savingRef.current = true; + setSaving(true); + setError(''); + try { + const settings: DebateSettings = { ...initial?.settings, format, role }; + if (judge.trim()) settings.judge = judge.trim(); + else delete settings.judge; + await onSubmit({ motion: motion.trim(), settings, instructions: instructions.trim() }); + haptics.success(); + } catch (cause) { + console.warn('Could not save the debate:', cause instanceof Error ? cause.message : cause); + haptics.warning(); + setError(t('debate.saveFailed')); + } finally { + savingRef.current = false; + setSaving(false); + } + }; + + return ( + <> + + + + {count(motion, MAX_MOTION_LENGTH)} + + + + + + {t('debate.format')} + + + {DEBATE_FORMATS.map((value, index) => { + const selected = value === format; + return ( + { + haptics.select(); + setFormat(value); + }} + style={({ pressed }) => [styles.option, index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> + + {t(`debate.formats.${value}`)} + + {selected ? : null} + + ); + })} + + + + + ({ value: value as string, label: t(ROLE_LABEL_KEYS[value]) })), + // A role set elsewhere that this list does not know is kept. + ...((DEBATE_ROLES as readonly string[]).includes(role) ? [] : [{ value: role, label: role }]), + ]} + onChange={setRole} + /> + + + + + + + + {count(instructions, MAX_INSTRUCTIONS_LENGTH)} + + + + {error ? ( + + {error} + + ) : null} + + void submit()} /> + + ); +} + +const styles = StyleSheet.create({ + body: { fontSize: 15, lineHeight: 21 }, + motion: { minHeight: 96, paddingTop: 13, paddingBottom: 13, textAlignVertical: 'top' }, + instructions: { minHeight: 110, paddingTop: 13, paddingBottom: 13, textAlignVertical: 'top' }, + counter: { fontSize: 12.5, textAlign: 'right', marginRight: 4, marginTop: -2, fontVariant: ['tabular-nums'] }, + group: { gap: 7 }, + label: { fontSize: 14, marginLeft: 4 }, + list: { padding: 0, gap: 0 }, + option: { flexDirection: 'row', alignItems: 'center', minHeight: 48, paddingHorizontal: 16, gap: 12 }, + optionLabel: { flex: 1, fontSize: 16 }, + pressed: { opacity: 0.6 }, +}); diff --git a/apps/mobile/src/components/folder-select.tsx b/apps/mobile/src/components/folder-select.tsx new file mode 100644 index 000000000..a9e01d2d5 --- /dev/null +++ b/apps/mobile/src/components/folder-select.tsx @@ -0,0 +1,59 @@ +import { GENERAL_FOLDER } from '@debatelab/core/library'; +import { useTranslation } from 'react-i18next'; + +import { FormGroup, SelectRow } from './form-rows'; +import { folderLabel } from './source-display'; + +import { promptText } from '@/lib/confirm'; + +const NEW_FOLDER = '\u0000new'; + +/** + * The folder a source goes in, as the website's Folder select, with New folder + * at the end: the name typed is used at once and saved with the source. + */ +export function FolderSelect({ + folders, + value, + onChange, + disabled, +}: { + folders: string[]; + value: string; + onChange: (folder: string) => void; + disabled?: boolean; +}) { + const { t } = useTranslation(); + // General first, as the website lists it; a folder only this form knows is kept. + const names = Array.from(new Set([GENERAL_FOLDER, ...folders, value])).filter(Boolean); + const pick = async (choice: string) => { + if (choice !== NEW_FOLDER) { + onChange(choice); + return; + } + const name = await promptText({ + title: t('libraryPage.newFolder'), + message: '', + defaultValue: '', + confirm: t('libraryPage.createFolder'), + cancel: t('common.cancel'), + }); + if (name?.trim()) onChange(name.trim()); + }; + return ( + + ({ value: name, label: folderLabel(t, name) })), + { value: NEW_FOLDER, label: `${t('libraryPage.newFolder')}…` }, + ]} + onChange={(choice) => void pick(choice)} + /> + + ); +} diff --git a/apps/mobile/src/components/form-rows.tsx b/apps/mobile/src/components/form-rows.tsx index 75e4e542c..65ef5c524 100644 --- a/apps/mobile/src/components/form-rows.tsx +++ b/apps/mobile/src/components/form-rows.tsx @@ -6,6 +6,7 @@ import { ActionMenu } from './action-menu'; import { Icon } from './icon'; import { Text } from './text'; +import { haptics } from '@/lib/haptics'; import { radius } from '@/theme/colors'; import { useTheme } from '@/theme/use-theme'; @@ -83,7 +84,10 @@ export function SelectRow({ 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)} + onSelect={(key) => { + if (key !== value) haptics.select(); + onChange(key as T); + }} /> ); @@ -105,7 +109,10 @@ export function SwitchRow({ label, description, value, onChange, first, disabled { + haptics.tap(); + onChange(next); + }} disabled={disabled} trackColor={{ true: colors.primaryFill, false: colors.segmentTrack }} thumbColor="#FFFFFF" diff --git a/apps/mobile/src/components/icon-paths.ts b/apps/mobile/src/components/icon-paths.ts index 23e37a551..9f1eebd2b 100644 --- a/apps/mobile/src/components/icon-paths.ts +++ b/apps/mobile/src/components/icon-paths.ts @@ -54,6 +54,12 @@ export const iconPaths = { alert: '', cloudOff: '', + folder: '', + sort: '', + upload: '', + undo: '', + shieldOff: '', + pencil: '', stop: '', paperclip: '', bookmark: '', diff --git a/apps/mobile/src/components/library-header.tsx b/apps/mobile/src/components/library-header.tsx index 1c96e76de..76e6e40ce 100644 --- a/apps/mobile/src/components/library-header.tsx +++ b/apps/mobile/src/components/library-header.tsx @@ -1,21 +1,48 @@ +import type { LibrarySort } from '@debatelab/core/library'; import { Stack } from 'expo-router'; import { useTranslation } from 'react-i18next'; +import { SORT_LABELS, TYPE_FILTERS, type LibraryHeaderProps } from './library-options'; + import { useTheme } from '@/theme/use-theme'; -export type AddSourceKind = 'link' | 'note'; +export type { AddSourceKind, LibraryHeaderProps, SourceTypeFilter } from './library-options'; /** - * The Library's large title and its round plus button, which opens a native - * menu. "Scan pages" and "Choose a file" from the mock are not in the app yet. + * The Library's large title, its round plus button (Paste a link, Write a + * note, Import a file) and a More menu with the website's sort orders, the + * type filter, New folder and Clear all, all native menus. + * "Scan pages" from the mock is not in the app yet. */ -export function LibraryHeader({ title, onAdd, disabled }: { title: string; onAdd: (kind: AddSourceKind) => void; disabled: boolean }) { +export function LibraryHeader({ title, onAdd, disabled, sort, onSort, filter, onFilter, onNewFolder, onClearAll, canClear }: LibraryHeaderProps) { const { t } = useTranslation(); const { colors } = useTheme(); return ( <> + + + {(Object.keys(SORT_LABELS) as LibrarySort[]).map((value) => ( + onSort(value)}> + {t(SORT_LABELS[value])} + + ))} + + + {TYPE_FILTERS.map((option) => ( + onFilter(option.value)}> + {t(option.label)} + + ))} + + + {t('libraryPage.newFolder')} + + + {t('libraryPage.clearAll')} + + onAdd('note')}> {t('mobile.library.writeNote')} + onAdd('file')}> + {t('mobile.library.importFile')} + diff --git a/apps/mobile/src/components/library-header.web.tsx b/apps/mobile/src/components/library-header.web.tsx index 55ba59a8e..a154bc1b5 100644 --- a/apps/mobile/src/components/library-header.web.tsx +++ b/apps/mobile/src/components/library-header.web.tsx @@ -1,57 +1,109 @@ +import type { LibrarySort } from '@debatelab/core/library'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Pressable, StyleSheet, View } from 'react-native'; +import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; import { Icon } from './icon'; import type { IconName } from './icon-paths'; -import type { AddSourceKind } from './library-header'; +import { SORT_LABELS, TYPE_FILTERS, type AddSourceKind, type LibraryHeaderProps } from './library-options'; import { Text } from './text'; import { useTheme } from '@/theme/use-theme'; -/** The web preview has no native header or menu, so it draws the mock's title row and menu. */ -export function LibraryHeader({ title, onAdd, disabled }: { title: string; onAdd: (kind: AddSourceKind) => void; disabled: boolean }) { +type Item = { key: string; label: string; icon?: IconName; on?: boolean; destructive?: boolean; disabled?: boolean; onPress: () => void }; + +/** The web preview has no native header or menu, so it draws the mock's title row and both menus. */ +export function LibraryHeader({ title, onAdd, disabled, sort, onSort, filter, onFilter, onNewFolder, onClearAll, canClear }: LibraryHeaderProps) { const { t } = useTranslation(); const { colors } = useTheme(); - const [open, setOpen] = useState(false); - const items: { kind: AddSourceKind; label: string; icon: IconName }[] = [ + const [open, setOpen] = useState<'add' | 'more' | null>(null); + const add: Item[] = ([ { kind: 'link', label: t('mobile.library.pasteLink'), icon: 'link' }, { kind: 'note', label: t('mobile.library.writeNote'), icon: 'note' }, + { kind: 'file', label: t('mobile.library.importFile'), icon: 'file' }, + ] as { kind: AddSourceKind; label: string; icon: IconName }[]).map((item) => ({ key: item.kind, label: item.label, icon: item.icon, onPress: () => onAdd(item.kind) })); + const sections: { title?: string; items: Item[] }[] = [ + { + title: t('libraryPage.sortBy'), + items: (Object.keys(SORT_LABELS) as LibrarySort[]).map((value) => ({ key: value, label: t(SORT_LABELS[value]), on: sort === value, onPress: () => onSort(value) })), + }, + { + title: t('mobile.library.show'), + items: TYPE_FILTERS.map((option) => ({ key: option.value, label: t(option.label), on: filter === option.value, onPress: () => onFilter(option.value) })), + }, + { + items: [ + { key: 'folder', label: t('libraryPage.newFolder'), icon: 'folder', onPress: onNewFolder }, + { key: 'clear', label: t('libraryPage.clearAll'), icon: 'trash', destructive: true, disabled: !canClear, onPress: onClearAll }, + ], + }, ]; + + const row = (item: Item, divider: boolean) => ( + { + setOpen(null); + item.onPress(); + }} + style={({ pressed }) => [styles.item, divider && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && { backgroundColor: colors.line }, item.disabled && styles.disabled]}> + {item.on ? : null} + + {item.label} + + {item.icon ? : null} + + ); + return ( - + {title} - setOpen((value) => !value)} - testID="library-add" - style={[styles.plus, { backgroundColor: colors.primaryFill }, disabled && styles.disabled]}> - - - {open ? ( + + setOpen((value) => (value === 'more' ? null : 'more'))} + testID="library-more" + style={[styles.round, { backgroundColor: colors.tile }, disabled && styles.disabled]}> + + + setOpen((value) => (value === 'add' ? null : 'add'))} + testID="library-add" + style={[styles.round, { backgroundColor: colors.primaryFill }, disabled && styles.disabled]}> + + + + {open === 'add' ? ( - {items.map((item, index) => ( - { - setOpen(false); - onAdd(item.kind); - }} - style={({ pressed }) => [ - styles.item, - index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, - pressed && { backgroundColor: colors.line }, - ]}> - {item.label} - - - ))} + {add.map((item, index) => row(item, index > 0))} + + ) : null} + {open === 'more' ? ( + + + {sections.map((section, index) => ( + 0 && { borderTopWidth: 6, borderTopColor: colors.line }}> + {section.title ? ( + + {section.title} + + ) : null} + {section.items.map((item, itemIndex) => row(item, itemIndex > 0 || Boolean(section.title)))} + + ))} + ) : null} @@ -59,20 +111,24 @@ export function LibraryHeader({ title, onAdd, disabled }: { title: string; onAdd } const styles = StyleSheet.create({ - header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: 8, marginBottom: 2, zIndex: 10 }, - title: { fontSize: 34, letterSpacing: -1, lineHeight: 41 }, - plus: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center' }, + header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 10, marginTop: 8, marginBottom: 2, zIndex: 10 }, + title: { flexShrink: 1, fontSize: 34, letterSpacing: -1, lineHeight: 41 }, + buttons: { flexDirection: 'row', gap: 10 }, + round: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center' }, disabled: { opacity: 0.5 }, menu: { position: 'absolute', top: 50, right: 0, - width: 252, + width: 272, borderRadius: 16, borderWidth: StyleSheet.hairlineWidth, overflow: 'hidden', boxShadow: '0 14px 40px -10px rgba(11,42,110,0.4)', }, - item: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 17, minHeight: 52 }, - itemLabel: { fontSize: 17 }, + moreMenu: { maxHeight: 560 }, + section: { fontSize: 13, paddingHorizontal: 17, paddingTop: 10, paddingBottom: 6 }, + item: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingHorizontal: 12, paddingRight: 17, minHeight: 48 }, + check: { width: 20, alignItems: 'center' }, + itemLabel: { flex: 1, fontSize: 16.5 }, }); diff --git a/apps/mobile/src/components/library-options.ts b/apps/mobile/src/components/library-options.ts new file mode 100644 index 000000000..dcf2747d5 --- /dev/null +++ b/apps/mobile/src/components/library-options.ts @@ -0,0 +1,42 @@ +import type { LibrarySort } from '@debatelab/core/library'; + +/** The Library header's choices, shared by the native header and the web preview's (library-header.web.tsx). */ + +export type AddSourceKind = 'link' | 'note' | 'file'; +export type SourceTypeFilter = 'all' | 'file' | 'url' | 'text'; + +/** The website's sort menu labels, in its order. */ +export const SORT_LABELS: Record = { + smart: 'libraryPage.sortSmart', + mostUsed: 'libraryPage.sortMostUsed', + importance: 'libraryPage.sortImportance', + strength: 'libraryPage.sortStrength', + newest: 'libraryPage.sortNewest', + oldest: 'libraryPage.sortOldest', + title: 'libraryPage.sortTitle', + folder: 'libraryPage.sortFolder', +}; + +/** The type filter, in the mock's order. */ +export const TYPE_FILTERS: { value: SourceTypeFilter; label: string }[] = [ + { value: 'all', label: 'mobile.library.filters.all' }, + { value: 'file', label: 'mobile.library.filters.files' }, + { value: 'url', label: 'mobile.library.filters.links' }, + { value: 'text', label: 'mobile.library.filters.notes' }, +]; + +export interface LibraryHeaderProps { + title: string; + onAdd: (kind: AddSourceKind) => void; + /** No Current Debate: nothing can be added, sorted or cleared. */ + disabled: boolean; + sort: LibrarySort; + onSort: (sort: LibrarySort) => void; + filter: SourceTypeFilter; + onFilter: (filter: SourceTypeFilter) => void; + onNewFolder: () => void; + onClearAll: () => void; + /** An empty Library has nothing to clear. */ + canClear: boolean; +} + diff --git a/apps/mobile/src/components/library-parts.tsx b/apps/mobile/src/components/library-parts.tsx new file mode 100644 index 000000000..ac3e91714 --- /dev/null +++ b/apps/mobile/src/components/library-parts.tsx @@ -0,0 +1,144 @@ +import { GENERAL_FOLDER } from '@debatelab/core/library'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, Switch, View } from 'react-native'; + +import { ActionMenu } from './action-menu'; +import { WEB_SWITCH_THUMB } from './form-rows'; +import { Icon } from './icon'; +import { folderLabel } from './source-display'; +import { Text } from './text'; + +import { haptics } from '@/lib/haptics'; +import type { Source } from '@/lib/sources'; +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +/** The website's grounding card: whether the AI's answers use this debate's sources. */ +export function GroundingBanner({ active }: { active: boolean }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + return ( + + + + + + + {active ? t('libraryPage.groundingActive') : t('libraryPage.groundingDisabled')} + + + {active ? t('libraryPage.groundingActiveDesc') : t('libraryPage.groundingDisabledDesc')} + + + + ); +} + +/** + * The website's Folder actions for the folder picked in the chips. The switch + * is on while any source in it is on, so one tap turns a mixed folder off and + * the next turns it all on; the menu has the website's Activate and + * Deactivate, and renames (or merges) and deletes it. General cannot be + * renamed or deleted. + */ +export function FolderBar({ + folder, + sources, + onSetActive, + onRename, + onDelete, +}: { + folder: string; + sources: Source[]; + onSetActive: (isActive: boolean) => void; + onRename: () => void; + onDelete: () => void; +}) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const [menu, setMenu] = useState(false); + const active = sources.filter((source) => source.isActive).length; + const anyOn = active > 0; + const editable = folder !== GENERAL_FOLDER; + const name = folderLabel(t, folder); + return ( + + + + + {name} + + + {sources.length === 0 + ? t('mobile.library.folderEmpty') + : `${t('libraryPage.sourceCount', { count: sources.length })} · ${t('mobile.library.folderOn', { active, total: sources.length })}`} + + + { + haptics.tap(); + onSetActive(value); + }} + accessibilityLabel={`${t('libraryPage.useForGrounding')}: ${name}`} + trackColor={{ true: colors.primaryFill, false: colors.segmentTrack }} + ios_backgroundColor={colors.segmentTrack} + thumbColor="#FFFFFF" + testID="folder-active" + {...WEB_SWITCH_THUMB} + /> + setMenu(true)} + testID="folder-menu" + style={({ pressed }) => [styles.more, { backgroundColor: colors.segmentTrack }, pressed && styles.pressed]}> + + + setMenu(false)} + onSelect={(key) => { + if (key === 'on' || key === 'off') { + haptics.tap(); + onSetActive(key === 'on'); + } else if (key === 'rename') onRename(); + else onDelete(); + }} + /> + + ); +} + +const styles = StyleSheet.create({ + banner: { flexDirection: 'row', alignItems: 'center', gap: 12, borderWidth: 2, borderRadius: radius.card, paddingVertical: 11, paddingHorizontal: 13 }, + bannerIcon: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' }, + bannerText: { flex: 1, gap: 1 }, + bannerTitle: { fontSize: 15.5, lineHeight: 20 }, + bannerDesc: { fontSize: 14, lineHeight: 19 }, + folderBar: { flexDirection: 'row', alignItems: 'center', gap: 11, borderWidth: 2, borderRadius: radius.card, paddingVertical: 10, paddingHorizontal: 13 }, + folderText: { flex: 1, gap: 1 }, + folderName: { fontSize: 16 }, + folderMeta: { fontSize: 13.5, lineHeight: 18 }, + more: { width: 34, height: 34, borderRadius: 17, alignItems: 'center', justifyContent: 'center' }, + pressed: { opacity: 0.6 }, +}); diff --git a/apps/mobile/src/components/segmented-control.tsx b/apps/mobile/src/components/segmented-control.tsx index 5500a568f..061095f22 100644 --- a/apps/mobile/src/components/segmented-control.tsx +++ b/apps/mobile/src/components/segmented-control.tsx @@ -2,6 +2,7 @@ import { Pressable, StyleSheet, View } from 'react-native'; import { Text } from './text'; +import { haptics } from '@/lib/haptics'; import { useTheme } from '@/theme/use-theme'; type Option = { value: T; label: string }; @@ -29,7 +30,10 @@ export function SegmentedControl({ accessibilityRole="tab" accessibilityState={{ selected, disabled }} disabled={disabled} - onPress={() => onChange(option.value)} + onPress={() => { + if (!selected) haptics.select(); + onChange(option.value); + }} style={[ styles.segment, selected && [styles.selected, { backgroundColor: colors.segmentSelected }], diff --git a/apps/mobile/src/components/source-display.ts b/apps/mobile/src/components/source-display.ts index 07058b903..919559ade 100644 --- a/apps/mobile/src/components/source-display.ts +++ b/apps/mobile/src/components/source-display.ts @@ -2,6 +2,8 @@ import type { TFunction } from 'i18next'; import type { IconName } from './icon-paths'; +import { AUTO_SAVED_FOLDER, GENERAL_FOLDER } from '@debatelab/core/library'; + import { formatRelativeDate } from '@/lib/dates'; import type { Source, SourceType } from '@/lib/sources'; @@ -14,3 +16,10 @@ export function sourceMeta(t: TFunction, source: Source): string { ? t('mobile.library.metaCited', { type, count: source.useCount }) : t('mobile.library.metaAdded', { type, when: formatRelativeDate(source.createdAt) }); } + +/** A folder's name as the website shows it: General and Auto-saved are translated, the student's own are not. */ +export function folderLabel(t: TFunction, folder: string): string { + if (folder === GENERAL_FOLDER) return t('libraryPage.generalFolder'); + if (folder === AUTO_SAVED_FOLDER) return t('aiClient.autoSavedFolder'); + return folder; +} diff --git a/apps/mobile/src/components/source-file-reader.tsx b/apps/mobile/src/components/source-file-reader.tsx new file mode 100644 index 000000000..0b50beab1 --- /dev/null +++ b/apps/mobile/src/components/source-file-reader.tsx @@ -0,0 +1,104 @@ +'use dom'; + +import mammoth from 'mammoth'; +import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs'; +import * as pdfjsWorker from 'pdfjs-dist/legacy/build/pdf.worker.mjs'; +import { useEffect } from 'react'; +import { + importSourceFile, + SourceFileImportError, + type MammothLike, + type PdfjsLike, + type SourceFileImportResult, +} from '@debatelab/core/source-file-import'; + +/** A file the native side picked, sent in base64 slices so no single message carries 8 MB. */ +export interface SourceFileJob { + id: string; + name: string; + type: string; + size: number; + chunks: number; +} + +export type SourceFileOutcome = + | { ok: true; result: SourceFileImportResult } + | { ok: false; code: SourceFileImportError['code'] | 'timeout' | 'cancelled'; fileType?: SourceFileImportError['fileType']; message: string }; + +interface Props { + job: SourceFileJob | null; + readChunk: (id: string, index: number) => Promise; + onDone: (id: string, outcome: SourceFileOutcome) => Promise; + dom?: import('expo/dom').DOMProps; +} + +function decodeBase64(chunk: string): Uint8Array { + const binary = atob(chunk); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return bytes; +} + +// pdf.js runs its worker code on this page's thread: the webview has no worker URL to give it. +// Imported statically: Expo cannot export a DOM component that splits into chunks, +// and this page only loads while a file is being read. +(globalThis as { pdfjsWorker?: unknown }).pdfjsWorker = pdfjsWorker; + +const readers = { + loadPdfjs: async () => pdfjs as unknown as PdfjsLike, + loadMammoth: async () => mammoth as unknown as MammothLike, +}; + +/** + * The website's Library file reader (packages/core/src/source-file-import.ts, + * with pdf.js and mammoth) inside an Expo DOM component, because both need a + * browser. It draws nothing; give it a job and it answers through `onDone`. + */ +export default function SourceFileReader({ job, readChunk, onDone }: Props) { + useEffect(() => { + if (!job) return; + let stopped = false; + let bytes: Promise | null = null; + // One slice at a time into one buffer the file's size, so at most one slice is in flight. + const read = () => { + bytes ??= (async () => { + const out = new Uint8Array(job.size); + let offset = 0; + for (let index = 0; index < job.chunks; index += 1) { + if (stopped) throw new Error('This file is no longer being read'); + const part = decodeBase64(await readChunk(job.id, index)); + const fits = part.subarray(0, Math.max(0, job.size - offset)); + out.set(fits, offset); + offset += fits.length; + } + return offset === job.size ? out.buffer : out.slice(0, offset).buffer; + })(); + return bytes; + }; + importSourceFile( + { + name: job.name, + size: job.size, + type: job.type, + text: async () => new TextDecoder().decode(await read()), + bytes: read, + }, + readers, + ) + .then((result) => (stopped ? undefined : onDone(job.id, { ok: true, result }))) + .catch((error: unknown) => { + if (stopped) return undefined; + const outcome: SourceFileOutcome = error instanceof SourceFileImportError + ? { ok: false, code: error.code, fileType: error.fileType, message: error.message } + : { ok: false, code: 'parse-failed', message: error instanceof Error ? error.message : String(error) }; + return onDone(job.id, outcome); + }); + // A replaced or closed job stops reading and answers nothing. + return () => { + stopped = true; + }; + // A job runs once; its id names it. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [job?.id]); + return null; +} diff --git a/apps/mobile/src/components/source-row.tsx b/apps/mobile/src/components/source-row.tsx new file mode 100644 index 000000000..fc3b0cf39 --- /dev/null +++ b/apps/mobile/src/components/source-row.tsx @@ -0,0 +1,239 @@ +import { Link } from 'expo-router'; +import { useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, Switch, View } from 'react-native'; +import ReanimatedSwipeable, { type SwipeableMethods } from 'react-native-gesture-handler/ReanimatedSwipeable'; + +import { IconTile } from './card'; +import { WEB_SWITCH_THUMB } from './form-rows'; +import { Icon } from './icon'; +import { folderLabel, SOURCE_ICONS, sourceMeta } from './source-display'; +import { Text } from './text'; + +import { haptics } from '@/lib/haptics'; +import type { Source } from '@/lib/sources'; +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +export interface SourceRowActions { + onOpen: () => void; + onToggle: (isActive: boolean) => void; + onDelete: () => Promise; + onCopy: () => void; + onShare: () => void; + onMove: (folder: string) => void; +} + +/** The website's quality badges: used in a case, importance and strength. */ +function QualityBadges({ source, showFolder, compact }: { source: Source; showFolder: boolean; compact: boolean }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const semantic = source.metadata?.semantic; + const badge = (key: string, label: string, primary = false) => ( + + + {label} + + + ); + return ( + + {showFolder ? ( + + + + {folderLabel(t, source.folder)} + + + ) : null} + {semantic?.lastUsedInCase ? badge('used', t('sourceQuality.usedInCase'), true) : null} + {semantic && !compact ? badge('importance', t('sourceQuality.importance', { level: t(`sourceQuality.levels.${semantic.importance}`) })) : null} + {semantic && !compact ? badge('strength', t('sourceQuality.strength', { level: t(`sourceQuality.levels.${semantic.strength}`) })) : null} + + ); +} + +/** + * A Library source. Its switch says whether the AI uses it, as on the + * website, and a source that is off is dimmed. Swipe left to delete (with + * Undo), swipe right to turn it on or off, and press and hold for a preview + * and the menu. On iPad, `selectInstead` opens it beside the list. + */ +export function SourceRow({ + source, + folders, + first, + selected = false, + selectInstead = false, + showFolder = true, + compact = false, + actions, +}: { + source: Source; + folders: string[]; + first: boolean; + selected?: boolean; + selectInstead?: boolean; + /** Off while the list shows one folder. */ + showFolder?: boolean; + /** Beside an open source on iPad, whose pane shows the rankings: only the folder and use. */ + compact?: boolean; + actions: SourceRowActions; +}) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const swipeable = useRef(null); + const title = source.title || t('libraryPage.untitledSource'); + const toggleLabel = source.isActive ? t('mobile.library.turnOff') : t('mobile.library.turnOn'); + + const remove = async () => { + const removed = await actions.onDelete(); + if (!removed) swipeable.current?.close(); + }; + const toggle = () => { + swipeable.current?.close(); + haptics.tap(); + actions.onToggle(!source.isActive); + }; + + return ( + ( + + + + {toggleLabel} + + + )} + renderRightActions={() => ( + void remove()} + style={[styles.swipeAction, { backgroundColor: colors.destructive }]}> + + + {t('common.delete')} + + + )}> + {/* The switch sits beside the link, not in it, so turning a source on or off never opens it. */} + + { + if (!selectInstead) return; + event.preventDefault(); + actions.onOpen(); + }}> + + { + if (event.nativeEvent.actionName === 'toggle') toggle(); + if (event.nativeEvent.actionName === 'delete') void remove(); + }} + testID="source-row" + // One flat style object: inside a Link's slot the web preview hands it to the DOM as is. + style={{ flex: 1 }}> + {({ pressed }) => ( + + + + + + + {title} + + + {source.isActive ? sourceMeta(t, source) : `${t('mobile.library.turnedOff')} · ${sourceMeta(t, source)}`} + + + + + )} + + + + + + {t('mobile.library.open')} + + + {toggleLabel} + + + {folders.map((folder) => ( + actions.onMove(folder)}> + {folderLabel(t, folder)} + + ))} + + + {t('common.copy')} + + + {t('mobile.library.share')} + + void remove()}> + {t('common.delete')} + + + + + { + haptics.tap(); + actions.onToggle(value); + }} + accessibilityLabel={t('libraryPage.toggleSourceLabel', { title })} + trackColor={{ true: colors.primaryFill, false: colors.segmentTrack }} + ios_backgroundColor={colors.segmentTrack} + thumbColor="#FFFFFF" + testID="source-active" + {...WEB_SWITCH_THUMB} + /> + + + + ); +} + +const styles = StyleSheet.create({ + row: { flexDirection: 'row', alignItems: 'center' }, + content: { flexDirection: 'row', alignItems: 'center', gap: 13, paddingVertical: 12, paddingLeft: 14, paddingRight: 8 }, + switch: { paddingRight: 14, paddingVertical: 12, justifyContent: 'center' }, + rowText: { flex: 1, gap: 2 }, + rowTitle: { fontSize: 16 }, + rowMeta: { fontSize: 14 }, + off: { opacity: 0.5 }, + pressed: { opacity: 0.7 }, + badges: { flexDirection: 'row', flexWrap: 'wrap', gap: 5, marginTop: 4 }, + badge: { borderWidth: 1, borderRadius: radius.pill, paddingHorizontal: 8, paddingVertical: 1, maxWidth: '100%' }, + folderBadge: { flexDirection: 'row', alignItems: 'center', gap: 4, maxWidth: 200 }, + badgeText: { fontSize: 12, lineHeight: 17, flexShrink: 1 }, + swipeAction: { width: 92, alignItems: 'center', justifyContent: 'center', gap: 3, paddingHorizontal: 6 }, + swipeLabel: { color: '#ffffff', fontSize: 13, textAlign: 'center' }, +}); diff --git a/apps/mobile/src/components/source-view.tsx b/apps/mobile/src/components/source-view.tsx index 25844ff42..a033fbc89 100644 --- a/apps/mobile/src/components/source-view.tsx +++ b/apps/mobile/src/components/source-view.tsx @@ -4,34 +4,58 @@ import { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; -import { TextButton } from './buttons'; +import { MainActionButton, SecondaryButton, TextButton } from './buttons'; import { Card, IconTile } from './card'; +import { Field } from './field'; +import { FolderSelect } from './folder-select'; +import { FormGroup, SelectRow, SwitchRow } from './form-rows'; import { Icon } from './icon'; -import { SOURCE_ICONS, sourceMeta } from './source-display'; +import { folderLabel, SOURCE_ICONS, sourceMeta } from './source-display'; import { Text } from './text'; +import { useToast } from './toast'; import { useAuth } from '@/lib/auth'; -import { fetchSource, type Source } from '@/lib/sources'; +import { formatRelativeDate } from '@/lib/dates'; +import { haptics } from '@/lib/haptics'; +import { useSourceActions } from '@/lib/source-actions'; +import { fetchSource, updateSource, type Source, type SourcePatch, type SourceType } from '@/lib/sources'; import { useCopyHolder } from '@/lib/use-copy-holder'; +import { useLibraryFolders } from '@/lib/use-library-folders'; +import { radius } from '@/theme/colors'; import { useTheme } from '@/theme/use-theme'; function webUrl(origin: string | null): string | null { return origin && /^https?:\/\//i.test(origin) ? origin : null; } +const TYPES: SourceType[] = ['text', 'url', 'file']; +const TYPE_LABELS: Record = { text: 'libraryPage.textSource', url: 'libraryPage.urlSource', file: 'libraryPage.fileSource' }; + +type Draft = Pick & { origin: string }; + /** - * A source, read-only: its title, where it came from and its text. The source - * sheet shows it with a Done button; the iPad Library shows it beside the list. - * Mount it with a key per source, so a new selection never shows the last one. + * A source, as the website's source sheet shows it: its quality and use, the + * switch that decides whether the AI uses it, its folder and its text, with + * Copy, Share and Delete (with Undo). Edit opens the website's fields for + * title, type, origin and text; Save sends only what was changed. + * The source sheet shows it with a Done button; the iPad Library shows it + * beside the list. Mount it with a key per source, so a new selection never shows the last one. */ -export function SourceView({ id, onDone }: { id: string; onDone?: () => void }) { - const { t } = useTranslation(); +export function SourceView({ id, onDone, onRemoved }: { id: string; onDone?: () => void; onRemoved?: () => void }) { + const { t, i18n } = useTranslation(); const { colors } = useTheme(); + const toast = useToast(); const { session } = useAuth(); const userId = session?.user.id ?? null; const [source, setSource] = useState(undefined); const [failed, setFailed] = useState(false); + const [draft, setDraft] = useState(null); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [saveError, setSaveError] = useState(''); const copies = useCopyHolder(); + const actions = useSourceActions(); + const { folders } = useLibraryFolders(source?.debateId ?? null); // The same read as the source sheet. Leaving the screen drops the saved copy it is showing. useFocusEffect( @@ -58,6 +82,58 @@ export function SourceView({ id, onDone }: { id: string; onDone?: () => void }) const url = webUrl(source?.origin ?? null); + const toggle = async (isActive: boolean) => { + if (!source) return; + setSource({ ...source, isActive }); + if (!(await actions.setActive([source], isActive))) setSource((current) => current && { ...current, isActive: source.isActive }); + }; + const move = async (folder: string) => { + if (!source) return; + setSource({ ...source, folder }); + if (!(await actions.move(source, folder))) setSource((current) => current && { ...current, folder: source.folder }); + }; + const remove = async () => { + if (!source) return; + if (await actions.remove(source)) (onRemoved ?? onDone)?.(); + }; + + const edit = () => { + if (!source) return; + setSaved(false); + setSaveError(''); + setDraft({ title: source.title, content: source.content, type: source.type, origin: source.origin ?? '' }); + }; + const save = async () => { + if (!source || !draft || !userId || !source.debateId || saving) return; + if (!draft.title.trim() || !draft.content.trim()) return; + // Only what was edited is written, so a switch flipped meanwhile is kept. + const patch: SourcePatch = {}; + if (draft.title.trim() !== source.title) patch.title = draft.title; + if (draft.content.trim() !== source.content) patch.content = draft.content; + if (draft.type !== source.type) patch.type = draft.type; + if (draft.origin.trim() !== (source.origin ?? '')) patch.origin = draft.origin; + setSaving(true); + setSaveError(''); + try { + await updateSource(userId, source.debateId, source.id, patch); + setSource({ ...source, title: draft.title.trim(), content: draft.content.trim(), type: draft.type, origin: draft.origin.trim() || null }); + setDraft(null); + setSaved(true); + haptics.success(); + // A sheet sits above the toast, so the sheet says it here; the iPad pane gets the toast too. + if (!onDone) toast({ tone: 'success', title: t('libraryPage.sourceUpdated'), message: t('libraryPage.sourceUpdatedDesc') }); + } catch (cause) { + console.warn('Could not save the source:', cause instanceof Error ? cause.message : cause); + haptics.warning(); + setSaveError(t('libraryPage.saveFailed')); + } finally { + setSaving(false); + } + }; + + const semantic = source?.metadata?.semantic; + const words = source ? source.content.trim().split(/\s+/).filter(Boolean).length : 0; + return ( <> @@ -65,13 +141,18 @@ export function SourceView({ id, onDone }: { id: string; onDone?: () => void }) {source ? ( - + {sourceMeta(t, source)} ) : null} - {onDone ? : null} + {draft ? ( + setDraft(null)} disabled={saving} /> + ) : source ? ( + + ) : null} + {onDone && !draft ? : null} {failed ? ( @@ -84,11 +165,62 @@ export function SourceView({ id, onDone }: { id: string; onDone?: () => void }) {t('mobile.library.sourceMissing')} + ) : draft ? ( + <> + setDraft({ ...draft, title })} editable={!saving} invalid={!draft.title.trim()} /> + {!draft.title.trim() ? {t('libraryPage.titleRequired')} : null} + + ({ value, label: t(TYPE_LABELS[value]) }))} + onChange={(type) => setDraft({ ...draft, type })} + /> + + setDraft({ ...draft, origin })} + autoCapitalize="none" + editable={!saving} + /> + setDraft({ ...draft, content })} + multiline + editable={!saving} + invalid={!draft.content.trim()} + style={styles.contentField} + /> + {!draft.content.trim() ? {t('libraryPage.contentRequired')} : null} + {saveError ? ( + + {saveError} + + ) : null} + void save()} /> + ) : ( <> {source.title || t('libraryPage.untitledSource')} + + {[folderLabel(t, source.folder), formatRelativeDate(source.createdAt), t('libraryPage.wordCount', { count: words, formatted: words.toLocaleString(i18n.language) })].join(' · ')} + + {saved ? ( + + + + + + {t('libraryPage.sourceUpdatedDesc')} + + + ) : null} {url ? ( void }) {source.origin} ) : null} + {semantic ? ( + + + {semantic.lastUsedInCase ? t('sourceQuality.usedInCase') : t('sourceQuality.semanticAssessment')} + + + {t('sourceQuality.usageExplanation', { + count: semantic.caseUseCount, + importance: t(`sourceQuality.levels.${semantic.importance}`), + strength: t(`sourceQuality.levels.${semantic.strength}`), + })} + + + ) : null} + + void toggle(value)} + /> + + void move(folder)} /> + + + void actions.copy(source)} /> + + + actions.share(source)} /> + + {source.content} + void remove()} + testID="source-delete" + style={({ pressed }) => [styles.delete, { borderColor: colors.border }, pressed && styles.pressed]}> + + + {t('common.delete')} + + )} @@ -124,9 +298,21 @@ const styles = StyleSheet.create({ meta: { fontSize: 14, flexShrink: 1 }, loading: { marginTop: 32 }, body: { fontSize: 15, lineHeight: 21 }, + error: { fontSize: 14, marginTop: -8, marginLeft: 4 }, title: { fontSize: 23, lineHeight: 29, letterSpacing: -0.4 }, origin: { flexDirection: 'row', alignItems: 'center', gap: 9, borderWidth: 2, borderRadius: 12, paddingHorizontal: 13, minHeight: 46 }, originText: { flex: 1, fontSize: 15 }, pressed: { opacity: 0.7 }, text: { fontSize: 16, lineHeight: 24 }, + quality: { borderWidth: 2, borderRadius: radius.field, paddingVertical: 10, paddingHorizontal: 13, gap: 3 }, + qualityTitle: { fontSize: 15 }, + qualityText: { fontSize: 14, lineHeight: 19 }, + actions: { flexDirection: 'row', gap: 10 }, + action: { flex: 1 }, + saved: { flexDirection: 'row', alignItems: 'center', gap: 9, borderWidth: 2, borderRadius: radius.field, paddingVertical: 9, paddingHorizontal: 12 }, + check: { width: 20, height: 20, borderRadius: 10, alignItems: 'center', justifyContent: 'center' }, + savedText: { flex: 1, fontSize: 14.5 }, + contentField: { minHeight: 260, paddingTop: 13, paddingBottom: 13, textAlignVertical: 'top' }, + delete: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, borderWidth: 2, borderRadius: radius.field, minHeight: 48 }, + deleteText: { fontSize: 16 }, }); diff --git a/apps/mobile/src/components/toast.tsx b/apps/mobile/src/components/toast.tsx new file mode 100644 index 000000000..b9314bfab --- /dev/null +++ b/apps/mobile/src/components/toast.tsx @@ -0,0 +1,152 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { AccessibilityInfo, Pressable, StyleSheet, View } from 'react-native'; +import Animated, { FadeInDown, FadeOutDown } from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { Icon } from './icon'; +import { Text } from './text'; + +import { haptics } from '@/lib/haptics'; +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +export interface ToastOptions { + title: string; + message?: string; + /** Success shows the mint check and taps; error shows the alert and warns. */ + tone?: 'success' | 'error' | 'info'; + /** One action, such as Undo. */ + action?: { label: string; onPress: () => void }; +} + +interface ShownToast extends ToastOptions { + id: number; +} + +const ToastContext = createContext<(toast: ToastOptions) => void>(() => {}); +const ShownContext = createContext<{ toast: ShownToast | null; close: () => void }>({ toast: null, close: () => {} }); + +/** Shows a short message above the tab bar, as the website's toasts do. A newer toast replaces the one showing. */ +export function useToast(): (toast: ToastOptions) => void { + return useContext(ToastContext); +} + +const DURATION_MS = 4000; +// Long enough to reach Undo. +const ACTION_DURATION_MS = 6500; +/** The tab bar's height above the safe area, so the toast clears it. */ +const TAB_BAR = 56; + +let ids = 0; + +/** + * The app's toasts: the provider holds the one showing, and `ToastHost` draws + * it above the screens. Form sheets are drawn above the root view, so a sheet + * keeps its own inline messages and the toast shows once the sheet closes. + */ +export function ToastProvider({ children }: { children: ReactNode }) { + const [toast, setToast] = useState(null); + const timer = useRef | null>(null); + + const show = useCallback((next: ToastOptions) => { + if (next.tone === 'success') haptics.success(); + else if (next.tone === 'error') haptics.warning(); + AccessibilityInfo.announceForAccessibility([next.title, next.message].filter(Boolean).join('. ')); + setToast({ ...next, id: ++ids }); + }, []); + + useEffect(() => { + if (!toast) return; + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(() => setToast((current) => (current?.id === toast.id ? null : current)), toast.action ? ACTION_DURATION_MS : DURATION_MS); + return () => { + if (timer.current) clearTimeout(timer.current); + }; + }, [toast]); + + const shown = useMemo(() => ({ toast, close: () => setToast(null) }), [toast]); + return ( + + {children} + + ); +} + +/** Draws the toast showing; put it once above the screens. */ +export function ToastHost() { + const { toast, close } = useContext(ShownContext); + return toast ? : null; +} + +function ToastView({ toast, onClose }: { toast: ShownToast; onClose: () => void }) { + const { colors, scheme } = useTheme(); + const insets = useSafeAreaInsets(); + const face = scheme === 'dark' ? colors.card : colors.heading; + const text = scheme === 'dark' ? colors.foreground : '#FFFFFF'; + return ( + + + {toast.tone === 'success' ? ( + + + + ) : toast.tone === 'error' ? ( + + ) : null} + + + {toast.title} + + {toast.message ? ( + + {toast.message} + + ) : null} + + {toast.action ? ( + { + onClose(); + toast.action?.onPress(); + }} + testID="toast-action" + style={({ pressed }) => [styles.action, pressed && styles.pressed]}> + + {toast.action.label} + + + ) : null} + + + ); +} + +const styles = StyleSheet.create({ + layer: { position: 'absolute', left: 0, right: 0, alignItems: 'center', paddingHorizontal: 16 }, + toast: { + width: '100%', + maxWidth: 520, + flexDirection: 'row', + alignItems: 'center', + gap: 11, + borderWidth: 2, + borderRadius: radius.field, + paddingVertical: 11, + paddingHorizontal: 14, + boxShadow: '0 6px 20px rgba(11,42,110,0.22)', + }, + badge: { width: 22, height: 22, borderRadius: 11, alignItems: 'center', justifyContent: 'center' }, + text: { flex: 1, gap: 1 }, + title: { fontSize: 15, lineHeight: 20 }, + message: { fontSize: 14, lineHeight: 19, opacity: 0.86 }, + action: { paddingHorizontal: 6, paddingVertical: 4 }, + actionLabel: { fontSize: 16 }, + pressed: { opacity: 0.6 }, +}); diff --git a/apps/mobile/src/components/tool-form.tsx b/apps/mobile/src/components/tool-form.tsx index 46d98cb92..fe550a7b5 100644 --- a/apps/mobile/src/components/tool-form.tsx +++ b/apps/mobile/src/components/tool-form.tsx @@ -10,6 +10,7 @@ import { Text } from './text'; import type { AIErrorKind } from '@/lib/ai-request'; import { useDailyUsage } from '@/lib/usage'; import { useDebates } from '@/lib/debate-context'; +import { haptics } from '@/lib/haptics'; import { radius } from '@/theme/colors'; import { useTheme } from '@/theme/use-theme'; import { OwlLoader, type OwlPose } from '@/components/owl'; @@ -72,6 +73,8 @@ export function ToolError({ title, error, onRetry }: { title: string; error: AIE const { t } = useTranslation(); const router = useRouter(); const { colors } = useTheme(); + // A failed request warns once, when its message appears. + useEffect(() => haptics.warning(), [error]); return ( diff --git a/apps/mobile/src/components/work-row.tsx b/apps/mobile/src/components/work-row.tsx index d471a29ac..bf2effa8f 100644 --- a/apps/mobile/src/components/work-row.tsx +++ b/apps/mobile/src/components/work-row.tsx @@ -1,4 +1,4 @@ -import type { Href } from 'expo-router'; +import { Link, useRouter, type Href } from 'expo-router'; import { useTranslation } from 'react-i18next'; import { Pressable, StyleSheet, View } from 'react-native'; @@ -9,6 +9,7 @@ import { Text } from './text'; import { formatWhen } from '@/lib/dates'; import type { DebateItem, DebateItemKind } from '@/lib/debates'; +import { canDeleteWork, hasDocumentText, useWorkActions } from '@/lib/work-actions'; import { useTheme } from '@/theme/use-theme'; export const KIND_ICONS: Record = { @@ -49,29 +50,88 @@ export function workHref(item: DebateItem, from: OpenedFrom): Href { } } -/** One saved result or round, with its kind and when it was made. */ -export function WorkRow({ item, first, onPress }: { item: DebateItem; first: boolean; onPress: () => void }) { +/** + * One saved result or round, with its kind and when it was made. With `from`, + * it is a link with a preview and a menu (Open, Discuss, Share, and Delete for + * a case or a Sparring round, as the website's History allows). + */ +export function WorkRow({ + item, + first, + onPress, + from, + onDeleted, +}: { + item: DebateItem; + first: boolean; + onPress?: () => void; + from?: OpenedFrom; + onDeleted?: () => void; +}) { const { t } = useTranslation(); const { colors } = useTheme(); - return ( + const router = useRouter(); + const actions = useWorkActions(); + const title = item.title || t(`debate.kinds.${item.kind}`); + const href = from ? workHref(item, from) : null; + // Practice work opens the Practice tab, which has nothing to preview, so it is a plain row that navigates itself. + const linked = href !== null && href !== '/practice'; + const row = ( router.navigate(href) : undefined)} testID="work-row" - style={({ pressed }) => [styles.row, !first && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> - - - - {item.title || t(`debate.kinds.${item.kind}`)} - - - {t('mobile.debate.itemMeta', { kind: t(`debate.kinds.${item.kind}`), when: formatWhen(item.createdAt) })} - - - + // One flat style object: inside a Link's slot the web preview hands it to the DOM as is. + style={StyleSheet.flatten([!first && { borderTopWidth: 1, borderTopColor: colors.line }, { backgroundColor: colors.card }])}> + {({ pressed }) => ( + + + + + {title} + + + {t('mobile.debate.itemMeta', { kind: t(`debate.kinds.${item.kind}`), when: formatWhen(item.createdAt) })} + + + + + )} ); + if (!linked) return row; + const document = hasDocumentText(item); + return ( + + {row} + + + {document ? ( + void actions.discuss(item)}> + {t('common.discuss')} + + ) : null} + {document ? ( + void actions.share(item)}> + {t('mobile.library.share')} + + ) : null} + {canDeleteWork(item) ? ( + { + void actions.remove(item).then((removed) => { + if (removed) onDeleted?.(); + }); + }}> + {t('common.delete')} + + ) : null} + + + ); } const styles = StyleSheet.create({ diff --git a/apps/mobile/src/lib/debate-context.tsx b/apps/mobile/src/lib/debate-context.tsx index 364b8c412..9488d9274 100644 --- a/apps/mobile/src/lib/debate-context.tsx +++ b/apps/mobile/src/lib/debate-context.tsx @@ -3,10 +3,13 @@ import { AppState } from 'react-native'; import { useAuth } from './auth'; import { + continueDebate, createDebate, + deleteDebate, fetchDebates, sortDebates, switchDebate, + updateDebate, type Debate, type DebateSettings, } from './debates'; @@ -23,8 +26,14 @@ type DebateContextValue = { /** The debate that new work attaches to right now, or null for one-off work. */ activeDebateId: string | null; refresh: () => Promise; - create: (input: { motion: string; settings: DebateSettings }) => Promise; + create: (input: { motion: string; settings: DebateSettings; instructions?: string }) => Promise; switchTo: (id: string) => Promise; + /** The website's Edit settings: motion, format, role, judge and instructions. */ + update: (id: string, patch: { motion?: string; settings?: DebateSettings; instructions?: string }) => Promise; + /** Deletes a debate with its sources and saved work. */ + remove: (id: string) => Promise; + /** "Continue" in the five-day check. */ + continueCurrent: () => 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; @@ -97,11 +106,11 @@ export function DebateProvider({ children }: { children: ReactNode }) { // The account is read when the student acts. If the owner changed while // the write ran, the write still happened for that account, so it is // reported as done but not shown to whoever is signed in now. - create: async ({ motion, settings }) => { + create: async ({ motion, settings, instructions }) => { const forUser = owner.current; if (!forUser) throw new Error('Sign in to manage debates'); const gen = generation.current; - const debate = await createDebate(forUser, { motion, settings }); + const debate = await createDebate(forUser, { motion, settings, instructions }); if (generation.current !== gen) return debate; writes.current += 1; setDebates((previous) => sortDebates([debate, ...previous.filter((item) => item.id !== debate.id)])); @@ -121,6 +130,36 @@ export function DebateProvider({ children }: { children: ReactNode }) { setCurrentId(id); setOneOff(false); }, + update: async (id, patch) => { + const forUser = owner.current; + const debate = debates.find((item) => item.id === id); + if (!forUser || !debate) throw new Error('Debate not found'); + const gen = generation.current; + const next = await updateDebate(forUser, debate, patch); + if (generation.current !== gen) return; + writes.current += 1; + setDebates((previous) => previous.map((item) => (item.id === id ? next : item))); + }, + remove: async (id) => { + const forUser = owner.current; + if (!forUser) throw new Error('Sign in to manage debates'); + const gen = generation.current; + await deleteDebate(forUser, id); + if (generation.current !== gen) return; + writes.current += 1; + setDebates((previous) => previous.filter((item) => item.id !== id)); + setCurrentId((previous) => (previous === id ? null : previous)); + }, + continueCurrent: async () => { + const forUser = owner.current; + if (!forUser || !currentId) return; + const gen = generation.current; + const id = currentId; + const at = await continueDebate(forUser, id); + if (generation.current !== gen) return; + writes.current += 1; + setDebates((previous) => sortDebates(previous.map((item) => (item.id === id ? { ...item, lastActivityAt: Math.max(item.lastActivityAt, at) } : item)))); + }, setOneOff, forgetDebates: (forUser) => { if (owner.current !== forUser) return; diff --git a/apps/mobile/src/lib/debates.ts b/apps/mobile/src/lib/debates.ts index 153a01120..41a8ef233 100644 --- a/apps/mobile/src/lib/debates.ts +++ b/apps/mobile/src/lib/debates.ts @@ -1,4 +1,4 @@ -import { cachedRead, pickFailure, readAll, readFailed, updateCopy, type CopyMark, type CopyScope } from './offline-cache'; +import { afterWrite, cachedRead, pickFailure, readAll, readFailed, updateCopy, type CopyMark, type CopyScope } from './offline-cache'; import { supabase } from './supabase'; /** @@ -24,11 +24,30 @@ export interface Debate { } export const MAX_MOTION_LENGTH = 1200; +export const MAX_INSTRUCTIONS_LENGTH = 2000; +/** The website's free-text judge note; its prompts read it as "Judge expectations". */ +export const MAX_JUDGE_LENGTH = 200; +/** How long a debate may sit without meaningful work before the app asks about it, as on the website. */ +export const INACTIVE_DEBATE_MS = 5 * 24 * 60 * 60 * 1000; + +export function isDebateInactive(debate: Pick, now = Date.now()): boolean { + return now - debate.lastActivityAt > INACTIVE_DEBATE_MS; +} /** The web DebateForm's formats, in its order. */ export const DEBATE_FORMATS = ['custom', 'bp', 'policy', 'classroom'] as const; export type DebateFormat = (typeof DEBATE_FORMATS)[number]; +/** The web DebateForm's roles, in its order, with its labels. */ +export const DEBATE_ROLES = ['general', 'intro', 'r1', 'r2', 'conc'] as const; +export const ROLE_LABEL_KEYS: Record<(typeof DEBATE_ROLES)[number], string> = { + general: 'debate.roleAny', + intro: 'generateForm.roleIntro', + r1: 'generateForm.roleR1', + r2: 'generateForm.roleR2', + conc: 'generateForm.roleConc', +}; + const DEBATE_COLUMNS = 'id,motion,settings,instructions,last_activity_at,created_at'; interface DebateRow { @@ -111,13 +130,18 @@ async function loadDebates(userId: string): Promise { /** Creates a debate and makes it current, as the website's create dialog does. */ export async function createDebate( userId: string, - input: { motion: string; settings: DebateSettings }, + input: { motion: string; settings: DebateSettings; instructions?: string }, ): Promise { const motion = input.motion.trim(); if (!motion) throw new Error('A debate needs a motion'); const { data, error } = await supabase .from('debates') - .insert({ user_id: userId, motion: motion.slice(0, MAX_MOTION_LENGTH), settings: input.settings, instructions: '' }) + .insert({ + user_id: userId, + motion: motion.slice(0, MAX_MOTION_LENGTH), + settings: input.settings, + instructions: (input.instructions ?? '').trim().slice(0, MAX_INSTRUCTIONS_LENGTH), + }) .select(DEBATE_COLUMNS) .single(); if (error || !data) throw new Error(error?.message ?? 'Could not create debate'); @@ -140,6 +164,68 @@ export async function switchDebate(userId: string, debateId: string): Promise { + const next: Debate = { + ...current, + ...(patch.motion !== undefined ? { motion: patch.motion.trim().slice(0, MAX_MOTION_LENGTH) || current.motion } : {}), + ...(patch.settings !== undefined ? { settings: patch.settings } : {}), + ...(patch.instructions !== undefined ? { instructions: patch.instructions.trim().slice(0, MAX_INSTRUCTIONS_LENGTH) } : {}), + }; + const { error } = await supabase + .from('debates') + .update({ motion: next.motion, settings: next.settings, instructions: next.instructions, updated_at: new Date().toISOString() }) + .eq('id', current.id) + .eq('user_id', userId); + if (error) throw new Error(error.message); + keepInCopy(userId, (list) => ({ ...list, debates: list.debates.map((item) => (item.id === next.id ? next : item)) })); + return next; +} + +/** + * Deletes a debate. Its sources and saved work go with it (the database + * cascades), as the website warns, so every copy that may hold them is dropped. + */ +export async function deleteDebate(userId: string, debateId: string): Promise { + const { error } = await supabase.from('debates').delete().eq('id', debateId).eq('user_id', userId); + if (error) throw new Error(error.message); + keepInCopy(userId, (list) => ({ + debates: list.debates.filter((item) => item.id !== debateId), + currentId: list.currentId === debateId ? null : list.currentId, + })); + afterWrite(userId, [ + { group: 'sources', key: debateId }, + { group: 'sources', prefix: 'folders:' }, + { group: 'source' }, + { group: 'history', key: debateId }, + { group: 'debate-lists', about: debateId }, + { group: 'flowcharts', key: debateId }, + { group: 'chats', key: debateId }, + { group: 'sparring-rounds', about: debateId }, + // Saved documents do not say their debate in their keys, so all of them go. + { group: 'result:case' }, + { group: 'result:attack' }, + { group: 'result:brainstorm' }, + { group: 'result:factoids' }, + { group: 'flowchart' }, + { group: 'chat' }, + { group: 'sparring' }, + ]); +} + +/** "Continue" in the five-day check: the student is still preparing this debate. */ +export async function continueDebate(userId: string, debateId: string): Promise { + const now = Date.now(); + const { error } = await supabase.from('debates').update({ last_activity_at: new Date(now).toISOString() }).eq('id', debateId).eq('user_id', userId); + if (error) throw new Error(error.message); + keepInCopy(userId, (list) => ({ ...list, debates: sortDebates(list.debates.map((item) => (item.id === debateId ? { ...item, lastActivityAt: now } : item))) })); + return now; +} + /** Tables whose rows are a debate's saved results, as listed by the website's debate counts. */ const RESULT_TABLES = ['cases', 'attacks', 'brainstorms', 'factoids', 'chats', 'flowcharts', 'crossfires'] as const; type ResultTable = (typeof RESULT_TABLES)[number]; diff --git a/apps/mobile/src/lib/haptics.ts b/apps/mobile/src/lib/haptics.ts new file mode 100644 index 000000000..a1f031c8c --- /dev/null +++ b/apps/mobile/src/lib/haptics.ts @@ -0,0 +1,21 @@ +import * as Haptics from 'expo-haptics'; + +/** + * The app's haptics, in one place so every screen feels the same: + * - `tap`: a light tap for a switch, chip or toggle; + * - `select`: a selection tick for a segmented control or a picked option; + * - `success`: something was saved or finished; + * - `warning`: something failed; + * - `turn`: the turn changed in a live round. + * The Timer's beeps keep their own (timer-signals.ts). A device without + * haptics, or with them off, simply ignores them. + */ +export const haptics = { + tap: () => void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {}), + select: () => void Haptics.selectionAsync().catch(() => {}), + success: () => void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}), + warning: () => void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning).catch(() => {}), + turn: () => void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {}), +}; + +export type HapticKind = keyof typeof haptics; diff --git a/apps/mobile/src/lib/haptics.web.ts b/apps/mobile/src/lib/haptics.web.ts new file mode 100644 index 000000000..96bc7a75f --- /dev/null +++ b/apps/mobile/src/lib/haptics.web.ts @@ -0,0 +1,19 @@ +/** + * The web preview has no haptics. It records each one on `window` instead, + * so the screenshot script can check that the right one fired. The phone app + * uses haptics.ts. + */ +function record(kind: string) { + const target = window as unknown as { __debatelabHaptics?: string[] }; + (target.__debatelabHaptics ??= []).push(kind); +} + +export const haptics = { + tap: () => record('tap'), + select: () => record('select'), + success: () => record('success'), + warning: () => record('warning'), + turn: () => record('turn'), +}; + +export type HapticKind = keyof typeof haptics; diff --git a/apps/mobile/src/lib/open-results.ts b/apps/mobile/src/lib/open-results.ts index fcb3281dd..4f2fa5865 100644 --- a/apps/mobile/src/lib/open-results.ts +++ b/apps/mobile/src/lib/open-results.ts @@ -1,5 +1,6 @@ import type { DiscoveredSource } from '@debatelab/core/ai/discovered-sources'; +import { haptics } from './haptics'; import type { OutputFor, ResultKind } from './results'; import { sessionSlot } from './session-memory'; @@ -25,8 +26,9 @@ export interface OpenResult { const store = sessionSlot(() => new Map()); let localId = 0; -/** Keeps a result under its saved id, or a local key when it is not saved. Returns the key to open it with. */ +/** Keeps a result under its saved id, or a local key when it is not saved. Returns the key to open it with. A finished result taps success. */ export function keepOpenResult(result: OpenResult): string { + haptics.success(); const key = result.savedId ?? `local-${Date.now()}-${(localId += 1)}`; store.get().set(key, result as OpenResult); return key; diff --git a/apps/mobile/src/lib/pick-source-file.ts b/apps/mobile/src/lib/pick-source-file.ts new file mode 100644 index 000000000..d8c07d97a --- /dev/null +++ b/apps/mobile/src/lib/pick-source-file.ts @@ -0,0 +1,9 @@ +import * as DocumentPicker from 'expo-document-picker'; + +import { SOURCE_FILE_PICKER_TYPES } from './source-file'; + +/** Opens the Files picker for one Library file. Null when the student cancels. */ +export async function pickSourceFile(): Promise { + const result = await DocumentPicker.getDocumentAsync({ type: SOURCE_FILE_PICKER_TYPES, copyToCacheDirectory: true, multiple: false }); + return result.canceled ? null : result.assets[0] ?? null; +} diff --git a/apps/mobile/src/lib/picked-file.ts b/apps/mobile/src/lib/picked-file.ts new file mode 100644 index 000000000..2f55c6199 --- /dev/null +++ b/apps/mobile/src/lib/picked-file.ts @@ -0,0 +1,13 @@ +import type { DocumentPickerAsset } from 'expo-document-picker'; +import { File } from 'expo-file-system'; +import { readAsStringAsync } from 'expo-file-system/legacy'; + +/** A picked file's size in bytes: the picker's, or the file's own when the picker gave none. */ +export function pickedFileSize(asset: DocumentPickerAsset): number { + return asset.size && asset.size > 0 ? asset.size : new File(asset.uri).size; +} + +/** `length` bytes of a picked file from `start`, as base64, read natively from the copy the picker left in the app's cache. */ +export function readPickedChunk(asset: DocumentPickerAsset, start: number, length: number): Promise { + return readAsStringAsync(asset.uri, { encoding: 'base64', position: start, length }); +} diff --git a/apps/mobile/src/lib/picked-file.web.ts b/apps/mobile/src/lib/picked-file.web.ts new file mode 100644 index 000000000..66ec0ca1c --- /dev/null +++ b/apps/mobile/src/lib/picked-file.web.ts @@ -0,0 +1,15 @@ +import type { DocumentPickerAsset } from 'expo-document-picker'; + +/** The web preview's picker hands back a browser File. */ +export function pickedFileSize(asset: DocumentPickerAsset): number { + return asset.file?.size ?? asset.size ?? 0; +} + +/** `length` bytes of the picked File from `start`, as base64. */ +export async function readPickedChunk(asset: DocumentPickerAsset, start: number, length: number): Promise { + if (!asset.file) throw new Error('The picked file has no contents'); + const bytes = new Uint8Array(await asset.file.slice(start, start + length).arrayBuffer()); + let binary = ''; + for (let index = 0; index < bytes.length; index += 0x8000) binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000)); + return btoa(binary); +} diff --git a/apps/mobile/src/lib/source-actions.ts b/apps/mobile/src/lib/source-actions.ts new file mode 100644 index 000000000..5380424c3 --- /dev/null +++ b/apps/mobile/src/lib/source-actions.ts @@ -0,0 +1,115 @@ +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useToast } from '@/components/toast'; +import { folderLabel } from '@/components/source-display'; +import { useAuth } from './auth'; +import { copyText, shareText } from './export'; +import { haptics } from './haptics'; +import { isKnownOffline } from './network-status'; +import { addSource, removeSource, setSourcesActive, updateSource, type Source } from './sources'; + +/** A source as plain text: its title, where it came from, and its text, as the website copies it. */ +export function sourceText(source: Pick, withOrigin = false): string { + return [source.title, withOrigin ? source.origin : null, source.content].filter(Boolean).join('\n\n'); +} + +/** + * What the Library does to one source, with the website's messages: delete + * at once with Undo, switch it on or off for the AI, move it, copy or share it. + * A change that does not reach the server never looks saved; the caller puts + * the row back when a write resolves false. + */ +export function useSourceActions() { + const { t } = useTranslation(); + const toast = useToast(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + + /** Offline, nothing is written, so the change is refused up front, as on the website. */ + const refuseOffline = useCallback((title: string) => { + if (!isKnownOffline()) return false; + toast({ tone: 'error', title, message: t('libraryPage.offlineNotSaved') }); + return true; + }, [t, toast]); + + const remove = useCallback(async (source: Source): Promise => { + if (!userId || !source.debateId) return false; + try { + await removeSource(userId, source.id, source.debateId); + } catch (cause) { + console.warn('Could not delete the source:', cause instanceof Error ? cause.message : cause); + toast({ tone: 'error', title: t('common.error'), message: t('libraryPage.deleteFailed') }); + return false; + } + haptics.success(); + // A source can be put back exactly, so deleting acts at once and offers Undo instead of asking first. + toast({ + title: t('libraryPage.sourceDeletedTitle', { title: source.title }), + action: { + label: t('common.undo'), + onPress: () => { + addSource(userId, { + id: source.id, + createdAt: source.createdAt, + title: source.title, + content: source.content, + type: source.type, + origin: source.origin, + folder: source.folder, + isActive: source.isActive, + metadata: source.metadata, + debateId: source.debateId!, + }) + .then(() => haptics.success()) + .catch(() => toast({ tone: 'error', title: t('libraryPage.restoreFailed') })); + }, + }, + }); + return true; + }, [t, toast, userId]); + + const setActive = useCallback(async (sources: Source[], isActive: boolean, folder?: string): Promise => { + const failedTitle = folder ? t('libraryPage.folderUpdateFailed') : t('libraryPage.toggleFailed'); + const debateId = sources[0]?.debateId; + if (!userId || !debateId) return false; + if (refuseOffline(t('libraryPage.offlineTitle'))) return false; + try { + await setSourcesActive(userId, debateId, sources.map((source) => source.id), isActive); + return true; + } catch (cause) { + console.warn('Could not change the source:', cause instanceof Error ? cause.message : cause); + toast({ tone: 'error', title: failedTitle, message: isKnownOffline() ? t('libraryPage.offlineNotSaved') : t('libraryPage.toggleFailedDesc') }); + return false; + } + }, [refuseOffline, t, toast, userId]); + + const move = useCallback(async (source: Source, folder: string): Promise => { + if (!userId || !source.debateId || folder === source.folder) return false; + if (refuseOffline(t('libraryPage.saveFailed'))) return false; + try { + await updateSource(userId, source.debateId, source.id, { folder }); + toast({ tone: 'success', title: t('libraryPage.sourceUpdated'), message: `${t('libraryPage.folder')}: ${folderLabel(t, folder)}` }); + return true; + } catch (cause) { + console.warn('Could not move the source:', cause instanceof Error ? cause.message : cause); + toast({ tone: 'error', title: t('libraryPage.saveFailed') }); + return false; + } + }, [refuseOffline, t, toast, userId]); + + const copy = useCallback(async (source: Source) => { + try { + await copyText(sourceText(source)); + toast({ tone: 'success', title: t('common.copied'), message: t('libraryPage.sourceCopied') }); + } catch { + toast({ tone: 'error', title: t('common.error'), message: t('libraryPage.copyFailed') }); + } + }, [t, toast]); + + const share = useCallback((source: Source) => { + shareText(source.title, sourceText(source, true)).catch(() => {}); + }, []); + + return { remove, setActive, move, copy, share }; +} diff --git a/apps/mobile/src/lib/source-file.tsx b/apps/mobile/src/lib/source-file.tsx new file mode 100644 index 000000000..ce10433fd --- /dev/null +++ b/apps/mobile/src/lib/source-file.tsx @@ -0,0 +1,110 @@ +import { checkSourceFile, SourceFileImportError } from '@debatelab/core/source-file-import'; +import type { DocumentPickerAsset } from 'expo-document-picker'; +import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; +import { StyleSheet } from 'react-native'; + +import SourceFileReader, { type SourceFileJob, type SourceFileOutcome } from '@/components/source-file-reader'; +import { pickedFileSize, readPickedChunk } from './picked-file'; + +export type { SourceFileOutcome }; + +/** Bytes per slice sent to the reader: a multiple of 3, so each slice's base64 (1 MB) decodes alone. */ +const CHUNK_BYTES = 786_432; +/** A long PDF can take a while; a reader that never answers (its webview failed to load) cannot hang the sheet. */ +const READ_TIMEOUT_MS = 120_000; + +/** + * What the picker offers: the website's file input's `accept` list, and + * `text/*` so the iOS picker also offers Markdown and CSV, whose types it + * knows by UTI rather than MIME type. iOS skips the extensions; the reader + * checks every file again anyway. + */ +export const SOURCE_FILE_PICKER_TYPES = [ + '.txt', '.md', '.markdown', '.csv', '.json', '.pdf', '.docx', + 'text/plain', 'text/markdown', 'text/csv', 'application/json', 'application/pdf', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'text/*', +]; + +let jobs = 0; + +// A picked file waits here while the Import file sheet opens: the web preview's +// asset holds a browser File, which cannot travel in a route's params. +const picked = new Map(); + +export function holdPickedFile(asset: DocumentPickerAsset): string { + const id = `picked-${++jobs}`; + picked.clear(); + picked.set(id, asset); + return id; +} + +export function pickedFile(id: string | undefined): DocumentPickerAsset | null { + return (id && picked.get(id)) || null; +} + + +/** + * Reads a picked file with the website's reader (pdf.js and mammoth in a DOM + * component). Render `reader` somewhere on the screen; it takes no space. + * Size and type are checked before any of the file is read, as the website + * does, with the file's own size when the picker gives none. The reader then + * pulls the file one slice at a time. A read always ends: with the result, an + * error, `timeout` when the reader never answers (its webview failed to load), + * or `cancelled` when another file replaces it or the screen closes. + */ +export function useSourceFileReader(): { reader: ReactNode; read: (asset: DocumentPickerAsset) => Promise } { + const [job, setJob] = useState(null); + const current = useRef<{ id: string; asset: DocumentPickerAsset; size: number; resolve: (outcome: SourceFileOutcome) => void; timer: ReturnType } | null>(null); + + const finish = useCallback((id: string, outcome: SourceFileOutcome) => { + const entry = current.current; + if (!entry || entry.id !== id) return; + clearTimeout(entry.timer); + current.current = null; + setJob((shown) => (shown?.id === id ? null : shown)); + entry.resolve(outcome); + }, []); + + // Leaving the screen ends the read in progress. + useEffect(() => () => { + const entry = current.current; + if (entry) finish(entry.id, { ok: false, code: 'cancelled', message: 'The file reader closed' }); + }, [finish]); + + const read = useCallback(async (asset: DocumentPickerAsset): Promise => { + const type = asset.mimeType ?? ''; + let size: number; + try { + size = pickedFileSize(asset); + checkSourceFile({ name: asset.name, type, size }); + } catch (error) { + if (error instanceof SourceFileImportError) return { ok: false, code: error.code, message: error.message }; + return { ok: false, code: 'parse-failed', message: error instanceof Error ? error.message : String(error) }; + } + // One file at a time: a new one replaces the read in progress. + if (current.current) finish(current.current.id, { ok: false, code: 'cancelled', message: 'Another file replaced this one' }); + const id = `file-${++jobs}`; + return new Promise((resolve) => { + const timer = setTimeout(() => finish(id, { ok: false, code: 'timeout', message: 'The file reader did not answer' }), READ_TIMEOUT_MS); + current.current = { id, asset, size, resolve, timer }; + setJob({ id, name: asset.name, type, size, chunks: Math.max(1, Math.ceil(size / CHUNK_BYTES)) }); + }); + }, [finish]); + + const readChunk = useCallback(async (id: string, index: number) => { + const entry = current.current; + if (!entry || entry.id !== id) throw new Error('This file is no longer being read'); + const start = index * CHUNK_BYTES; + return readPickedChunk(entry.asset, start, Math.max(0, Math.min(CHUNK_BYTES, entry.size - start))); + }, []); + const onDone = useCallback(async (id: string, outcome: SourceFileOutcome) => finish(id, outcome), [finish]); + + // The webview loads only while a file is being read. + const reader = job ? : null; + return { reader, read }; +} + +const styles = StyleSheet.create({ + hidden: { position: 'absolute', width: 1, height: 1, opacity: 0 }, +}); diff --git a/apps/mobile/src/lib/sources.ts b/apps/mobile/src/lib/sources.ts index 6f40215e6..c4690ea60 100644 --- a/apps/mobile/src/lib/sources.ts +++ b/apps/mobile/src/lib/sources.ts @@ -4,19 +4,25 @@ import type { SourceSemanticMetadata } from '@debatelab/core/ai/discovered-sourc import { SourceImportOutputSchema, type SourceImportOutput } from '@debatelab/core/ai/schemas/source-import'; import { assessedSemantic, isAssessedSource, type SourceAssessmentUpdate } from '@debatelab/core/ai/source-assessments'; import { getEdgeFunctionErrorMessage } from '@debatelab/core/supabase/edge-function-error'; +import { GENERAL_FOLDER, libraryFolders, normalizeFolder } from '@debatelab/core/library'; import { debateListScopes, deletedListScopes } from './debates'; -import { afterWrite, cachedRead, readFailed, type CopyMark, type CopyScope } from './offline-cache'; +import { afterWrite, cachedRead, readAll, readFailed, type CopyMark, type CopyScope } from './offline-cache'; +import { createSerialQueue, type SerialQueue } from './serial-queue'; import { supabase } from './supabase'; /** * Library sources, read and written the way the website's session store - * (src/lib/stores/session-store.ts: addSource, removeSource) and + * (src/lib/stores/session-store.ts: addSource, updateSource, removeSource, + * toggleSource, the folder actions and clearLibrary) and * src/lib/source-import.ts do. A source belongs to one debate. */ export type SourceType = 'url' | 'file' | 'text'; +/** What the website keeps on a source: research provenance and the server's rankings. */ +export type SourceMetadata = Record & { semantic?: SourceSemanticMetadata; qualityScore?: number; url?: string }; + export interface Source { id: string; title: string; @@ -26,10 +32,14 @@ export interface Source { createdAt: number; /** Results that used this source, from the website's source assessments. */ useCount: number; + folder: string; + /** Whether the AI grounds its answers in it. */ + isActive: boolean; + debateId: string | null; + metadata: SourceMetadata | null; } -const SOURCE_COLUMNS = 'id,title,content,type,origin,metadata,created_at'; -const DEFAULT_FOLDER = 'General'; +const SOURCE_COLUMNS = 'id,title,content,type,origin,folder,is_active,metadata,debate_id,created_at'; interface SourceRow { id: string; @@ -37,7 +47,10 @@ interface SourceRow { content: string; type: string; origin: string | null; + folder?: string | null; + is_active?: boolean | null; metadata: unknown; + debate_id?: string | null; created_at: string; } @@ -48,6 +61,10 @@ function timesUsed(metadata: unknown): number { return typeof count === 'number' && Number.isInteger(count) && count > 0 ? count : 0; } +function metadataOf(value: unknown): SourceMetadata | null { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as SourceMetadata) : null; +} + function fromRow(row: SourceRow): Source { return { id: row.id, @@ -57,6 +74,21 @@ function fromRow(row: SourceRow): Source { origin: row.origin || null, createdAt: new Date(row.created_at).getTime(), useCount: timesUsed(row.metadata), + folder: normalizeFolder(row.folder), + isActive: row.is_active !== false, + debateId: row.debate_id ?? null, + metadata: metadataOf(row.metadata), + }; +} + +/** Saved copies from before folders and the Active switch lack those fields. */ +function withDefaults(source: Source): Source { + return { + ...source, + folder: normalizeFolder(source.folder), + isActive: source.isActive !== false, + debateId: source.debateId ?? null, + metadata: source.metadata ?? null, }; } @@ -67,7 +99,7 @@ function fromRow(row: SourceRow): Source { /** One debate's sources, newest first, as the website's Library lists them by default. Offline, the saved copy. */ export function fetchSources(userId: string, debateId: string, mark?: CopyMark): Promise { - return cachedRead(userId, 'sources', debateId, () => loadSources(userId, debateId), mark); + return cachedRead(userId, 'sources', debateId, () => loadSources(userId, debateId), mark).then((sources) => sources.map(withDefaults)); } async function loadSources(userId: string, debateId: string): Promise { @@ -82,7 +114,7 @@ async function loadSources(userId: string, debateId: string): Promise } export function fetchSource(userId: string, id: string, mark?: CopyMark): Promise { - return cachedRead(userId, 'source', id, () => loadSource(userId, id), mark); + return cachedRead(userId, 'source', id, () => loadSource(userId, id), mark).then((source) => source && withDefaults(source)); } async function loadSource(userId: string, id: string): Promise { @@ -100,10 +132,13 @@ async function loadSource(userId: string, id: string): Promise { * Saves a source to a debate, as the website's session store does: in the * General folder unless another is named, as the Library adds them, or in * Auto-saved with research metadata, as Generate and Attack keep their sources. + * Undo after a delete passes the old `id` and `createdAt`, so the row comes back in its place. */ export async function addSource( userId: string, input: { + id?: string; + createdAt?: number; title: string; content: string; type: SourceType; @@ -114,7 +149,7 @@ export async function addSource( metadata?: Record | null; }, ): Promise { - const folder = input.folder?.trim() || DEFAULT_FOLDER; + const folder = normalizeFolder(input.folder); const folderResult = await supabase .from('source_folders') .upsert({ user_id: userId, name: folder }, { onConflict: 'user_id,name' }); @@ -122,6 +157,7 @@ export async function addSource( const { data, error } = await supabase .from('sources') .insert({ + ...(input.id ? { id: input.id, created_at: new Date(input.createdAt ?? Date.now()).toISOString() } : {}), user_id: userId, debate_id: input.debateId, title: input.title, @@ -136,7 +172,7 @@ export async function addSource( .single(); if (error?.code === '23505') throw new DuplicateSourceError(); if (error || !data) throw new Error(error?.message ?? 'Could not save the source'); - afterWrite(userId, [{ group: 'sources', key: input.debateId }, ...debateListScopes(input.debateId, 'source')]); + afterWrite(userId, [{ group: 'sources', key: input.debateId }, FOLDER_COPIES, ...debateListScopes(input.debateId, 'source')]); notifySourcesChanged(); return fromRow(data as SourceRow); } @@ -163,7 +199,7 @@ export async function removeSource(userId: string, id: string, debateId?: string const libraries: CopyScope[] = deletedFrom.length > 0 ? deletedFrom.flatMap((debate) => (debate ? [{ group: 'sources' as const, key: debate }] : [])) : [{ group: 'sources' }]; - afterWrite(userId, [{ group: 'source', key: id }, ...libraries, ...deletedListScopes(deletedFrom, 'source')]); + afterWrite(userId, [{ group: 'source', key: id }, ...libraries, FOLDER_COPIES, ...deletedListScopes(deletedFrom, 'source')]); notifySourcesChanged(); } @@ -202,6 +238,161 @@ export async function applySourceAssessments(userId: string, debateId: string, a if (failed?.error) throw new Error(failed.error.message); } +/** The Library's folder lists, one per debate; any folder change drops them all. */ +const FOLDER_COPIES: CopyScope = { group: 'sources', prefix: 'folders:' }; + +/** + * One debate's folders, as the website's Library lists them: the account's + * folder names without those only other debates use, and every folder this + * debate's sources are in. Offline, the saved copy. + */ +export function fetchFolders(userId: string, debateId: string, mark?: CopyMark): Promise { + return cachedRead(userId, 'sources', `folders:${debateId}`, () => loadFolders(userId, debateId), mark); +} + +async function loadFolders(userId: string, debateId: string): Promise { + const [names, used] = await readAll([ + (async () => { + const { data, error, status } = await supabase.from('source_folders').select('name').eq('user_id', userId).order('created_at', { ascending: true }); + if (error) throw readFailed(error, status); + return ((data ?? []) as { name: string }[]).map((row) => row.name); + })(), + (async () => { + const { data, error, status } = await supabase.from('sources').select('folder,debate_id').eq('user_id', userId); + if (error) throw readFailed(error, status); + return (data ?? []) as { folder: string | null; debate_id: string | null }[]; + })(), + ]); + return libraryFolders( + [GENERAL_FOLDER, ...names], + used.filter((row) => row.debate_id === debateId), + used.filter((row) => row.debate_id !== debateId).map((row) => normalizeFolder(row.folder)), + ); +} + +async function saveFolderName(userId: string, name: string) { + const { error } = await supabase.from('source_folders').upsert({ user_id: userId, name }, { onConflict: 'user_id,name' }); + if (error) throw new Error(error.message); +} + +/** Saves an empty folder, as the website's New folder does. */ +export async function createFolder(userId: string, name: string): Promise { + const folder = normalizeFolder(name); + await saveFolderName(userId, folder); + afterWrite(userId, [FOLDER_COPIES]); + notifySourcesChanged(); + return folder; +} + +/** Whether a source outside this debate (another debate's, or an unsorted one) is in the folder. */ +async function folderUsedElsewhere(userId: string, debateId: string, folder: string): Promise { + const { data, error } = await supabase + .from('sources') + .select('id') + .eq('user_id', userId) + .eq('folder', folder) + .or(`debate_id.is.null,debate_id.neq.${debateId}`) + .limit(1); + if (error) throw new Error(error.message); + return (data ?? []).length > 0; +} + +/** The copies a folder change touches: this debate's Library, every open source and the folder lists. */ +function folderChangeScopes(debateId: string): CopyScope[] { + return [{ group: 'sources', key: debateId }, { group: 'source' }, FOLDER_COPIES]; +} + +/** + * Renames a folder, or merges it into one that exists, as the website does: + * only this debate's sources move, and the old name stays while another debate uses it. + */ +export async function renameFolder(userId: string, debateId: string, oldName: string, newName: string): Promise { + const from = normalizeFolder(oldName); + const to = normalizeFolder(newName); + if (from === GENERAL_FOLDER || from === to) return; + const usedElsewhere = await folderUsedElsewhere(userId, debateId, from); + await saveFolderName(userId, to); + const moved = await supabase.from('sources').update({ folder: to }).eq('user_id', userId).eq('debate_id', debateId).eq('folder', from); + if (moved.error) throw new Error(moved.error.message); + if (!usedElsewhere) { + const removed = await supabase.from('source_folders').delete().eq('user_id', userId).eq('name', from); + if (removed.error) throw new Error(removed.error.message); + } + afterWrite(userId, folderChangeScopes(debateId)); + notifySourcesChanged(); +} + +/** Deletes a folder; this debate's sources in it move to General, as on the website. */ +export async function deleteFolder(userId: string, debateId: string, name: string): Promise { + const folder = normalizeFolder(name); + if (folder === GENERAL_FOLDER) return; + const usedElsewhere = await folderUsedElsewhere(userId, debateId, folder); + const moved = await supabase.from('sources').update({ folder: GENERAL_FOLDER }).eq('user_id', userId).eq('debate_id', debateId).eq('folder', folder); + if (moved.error) throw new Error(moved.error.message); + if (!usedElsewhere) { + const removed = await supabase.from('source_folders').delete().eq('user_id', userId).eq('name', folder); + if (removed.error) throw new Error(removed.error.message); + } + afterWrite(userId, folderChangeScopes(debateId)); + notifySourcesChanged(); +} + +// Every change to whether sources are on reaches the server in the order the +// student made it, as the website's source switch queue does. +const activeWrites: SerialQueue = createSerialQueue(); +// Other screens hear about switch changes once the queue is idle, so a reload never lands between two taps. +let activePending = 0; + +/** + * Turns sources on or off for AI grounding: one source's switch, or every + * source shown in a folder (the website's Activate and Deactivate). + */ +export function setSourcesActive(userId: string, debateId: string, ids: string[], isActive: boolean): Promise { + if (ids.length === 0) return Promise.resolve(); + activePending += 1; + return activeWrites(async () => { + const { error } = await supabase.from('sources').update({ is_active: isActive }).eq('user_id', userId).eq('debate_id', debateId).in('id', ids); + if (error) throw new Error(error.message); + afterWrite(userId, [{ group: 'sources', key: debateId }, ...ids.map((id) => ({ group: 'source' as const, key: id }))]); + }).finally(() => { + activePending -= 1; + if (activePending === 0) notifySourcesChanged(); + }); +} + +export type SourcePatch = Partial>; + +/** + * Saves the fields the student edited, and only those, as the website's + * source sheet does, so a switch or research refresh meanwhile is kept. + */ +export async function updateSource(userId: string, debateId: string, id: string, patch: SourcePatch): Promise { + const row: Record = {}; + if (patch.title !== undefined && patch.title.trim()) row.title = patch.title.trim(); + if (patch.content !== undefined && patch.content.trim()) row.content = patch.content.trim(); + if (patch.type !== undefined) row.type = patch.type; + if (patch.origin !== undefined) row.origin = patch.origin?.trim() || null; + if (patch.folder !== undefined) row.folder = normalizeFolder(patch.folder); + if (patch.isActive !== undefined) row.is_active = patch.isActive; + if (Object.keys(row).length === 0) return; + const write = async () => { + if (typeof row.folder === 'string') await saveFolderName(userId, row.folder); + const { error } = await supabase.from('sources').update(row).eq('user_id', userId).eq('debate_id', debateId).eq('id', id); + if (error) throw new Error(error.message); + }; + await (patch.isActive !== undefined ? activeWrites(write) : write()); + afterWrite(userId, [{ group: 'sources', key: debateId }, { group: 'source', key: id }, ...(row.folder ? [FOLDER_COPIES] : [])]); + notifySourcesChanged(); +} + +/** Deletes every source in one debate's Library, as the website's Clear all does. Other debates keep theirs. */ +export async function clearLibrary(userId: string, debateId: string): Promise { + const { error } = await supabase.from('sources').delete().eq('user_id', userId).eq('debate_id', debateId); + if (error) throw new Error(error.message); + afterWrite(userId, [{ group: 'source' }, ...folderChangeScopes(debateId), ...debateListScopes(debateId, 'source')]); + notifySourcesChanged(); +} + export class DuplicateSourceError extends Error { constructor() { super('This page is already in the debate Library.'); diff --git a/apps/mobile/src/lib/use-inactive-debate-check.ts b/apps/mobile/src/lib/use-inactive-debate-check.ts new file mode 100644 index 000000000..c6a9347fd --- /dev/null +++ b/apps/mobile/src/lib/use-inactive-debate-check.ts @@ -0,0 +1,27 @@ +import { useRouter } from 'expo-router'; +import { useEffect } from 'react'; + +import { useDebates } from './debate-context'; +import { isDebateInactive } from './debates'; + +// Asked once per debate while the app runs; Continue, Switch or Start new answers it. +const asked = new Set(); + +/** + * The website's inactive-debate check: after five days without work on the + * Current Debate, the app asks whether the student is still preparing it. + */ +export function useInactiveDebateCheck() { + const router = useRouter(); + const { status, current } = useDebates(); + useEffect(() => { + if (status !== 'ready' || !current || asked.has(current.id) || !isDebateInactive(current)) return; + const id = current.id; + // Once the tabs have settled, so the sheet opens over them. + const timer = setTimeout(() => { + asked.add(id); + router.push('/debate-check'); + }, 700); + return () => clearTimeout(timer); + }, [status, current, router]); +} diff --git a/apps/mobile/src/lib/use-library-folders.ts b/apps/mobile/src/lib/use-library-folders.ts new file mode 100644 index 000000000..847a7f129 --- /dev/null +++ b/apps/mobile/src/lib/use-library-folders.ts @@ -0,0 +1,30 @@ +import { GENERAL_FOLDER } from '@debatelab/core/library'; +import { useCallback, useEffect, useState } from 'react'; + +import { useAuth } from './auth'; +import { fetchFolders, subscribeSourcesChanged } from './sources'; + +/** One debate's folders, as the Library lists them; reloads when sources or folders change. General until they load. */ +export function useLibraryFolders(debateId: string | null): { folders: string[]; reload: () => void } { + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const [folders, setFolders] = useState([GENERAL_FOLDER]); + const [tick, setTick] = useState(0); + const reload = useCallback(() => setTick((value) => value + 1), []); + + useEffect(() => subscribeSourcesChanged(reload), [reload]); + useEffect(() => { + if (!userId || !debateId) return; + let active = true; + fetchFolders(userId, debateId) + .then((next) => { + if (active) setFolders(next); + }) + .catch((cause: unknown) => console.warn('Could not load folders:', cause instanceof Error ? cause.message : cause)); + return () => { + active = false; + }; + }, [userId, debateId, tick]); + + return { folders, reload }; +} diff --git a/apps/mobile/src/lib/work-actions.ts b/apps/mobile/src/lib/work-actions.ts new file mode 100644 index 000000000..e192591d2 --- /dev/null +++ b/apps/mobile/src/lib/work-actions.ts @@ -0,0 +1,101 @@ +import { useRouter } from 'expo-router'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useToast } from '@/components/toast'; +import { useAuth } from './auth'; +import { confirmDestructive } from './confirm'; +import type { DebateItem } from './debates'; +import { shareText } from './export'; +import { haptics } from './haptics'; +import { + attackToText, + brainstormToText, + caseToText, + discussAttackMessage, + discussBrainstormMessage, + discussCaseMessage, + discussFactoidsMessage, + factoidsToText, +} from './result-text'; +import { deleteResult, fetchResult, isCompleteAttack, isCompleteCase, readBrainstorm, readFactoids, type ResultKind } from './results'; +import { deleteSparringRound, sparringDraft } from './sparring'; + +const DOCUMENT_KINDS = new Set(['case', 'attack', 'brainstorm', 'factoids']); + +/** Kinds whose saved row reads back as a document with text: Discuss and Share work on them. */ +export function hasDocumentText(item: Pick): item is { kind: ResultKind } { + return DOCUMENT_KINDS.has(item.kind); +} + +/** The website's History deletes cases and Sparring rounds, each after its own confirmation; the menus do the same. */ +export function canDeleteWork(item: Pick): boolean { + return item.kind === 'case' || item.kind === 'sparring'; +} + +/** + * What a saved result's context menu does: Discuss takes it to Ask with the + * document's own Discuss message, Share sends its text, and Delete removes a + * case or a Sparring round after the website's confirmation. + */ +export function useWorkActions() { + const { t } = useTranslation(); + const router = useRouter(); + const toast = useToast(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + + const read = useCallback(async (item: DebateItem): Promise<{ text: string; discuss: string } | null> => { + if (!userId || !hasDocumentText(item)) return null; + const saved = await fetchResult(userId, item.kind, item.id); + const output = saved?.output; + if (item.kind === 'case' && isCompleteCase(output)) return { text: caseToText(output), discuss: discussCaseMessage(output) }; + if (item.kind === 'attack' && isCompleteAttack(output)) return { text: attackToText(output, t), discuss: discussAttackMessage(output) }; + const brainstorm = item.kind === 'brainstorm' ? readBrainstorm(output) : null; + if (brainstorm) return { text: brainstormToText(brainstorm, t), discuss: discussBrainstormMessage(brainstorm, t) }; + const factoids = item.kind === 'factoids' ? readFactoids(output) : null; + if (factoids) return { text: factoidsToText(factoids, t), discuss: discussFactoidsMessage(factoids) }; + return null; + }, [t, userId]); + + const failed = useCallback(() => toast({ tone: 'error', title: t('mobile.results.loadFailed') }), [t, toast]); + + const discuss = useCallback(async (item: DebateItem) => { + const doc = await read(item).catch(() => null); + if (!doc) return failed(); + router.push({ pathname: '/ask', params: { prompt: doc.discuss } }); + }, [failed, read, router]); + + const share = useCallback(async (item: DebateItem) => { + const doc = await read(item).catch(() => null); + if (!doc) return failed(); + await shareText(item.title, doc.text).catch(() => {}); + }, [failed, read]); + + const remove = useCallback(async (item: DebateItem): Promise => { + if (!userId || !canDeleteWork(item)) return false; + const sparring = item.kind === 'sparring'; + const confirmed = await confirmDestructive({ + title: t(sparring ? 'historyPage.deleteSparringTitle' : 'historyPage.deleteCaseTitle'), + message: t(sparring ? 'historyPage.deleteSparringDesc' : 'historyPage.deleteCaseDesc'), + confirm: t('common.delete'), + cancel: t('common.cancel'), + }); + if (!confirmed) return false; + try { + if (sparring) { + await deleteSparringRound(userId, item.id); + if (sparringDraft.get()?.id === item.id) sparringDraft.set(null); + } else { + await deleteResult(userId, 'case', item.id); + } + haptics.success(); + return true; + } catch { + toast({ tone: 'error', title: t(sparring ? 'historyPage.sparringDeleteFailed' : 'historyPage.deleteFailed') }); + return false; + } + }, [t, toast, userId]); + + return { discuss, share, remove }; +} diff --git a/apps/mobile/src/pdfjs-worker.d.ts b/apps/mobile/src/pdfjs-worker.d.ts new file mode 100644 index 000000000..78db68019 --- /dev/null +++ b/apps/mobile/src/pdfjs-worker.d.ts @@ -0,0 +1,2 @@ +// pdf.js ships no types for its worker module; the Library's file reader only puts it on globalThis. +declare module 'pdfjs-dist/legacy/build/pdf.worker.mjs'; diff --git a/knip.json b/knip.json index d36cfefa2..2651de673 100644 --- a/knip.json +++ b/knip.json @@ -53,7 +53,8 @@ "ignoreDependencies": [ "expo-updates", "expo-speech-recognition", - "expo-widgets" + "expo-widgets", + "@expo/dom-webview" ] } }, diff --git a/package-lock.json b/package-lock.json index b804a4084..b2feb56a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -124,6 +124,7 @@ "@debatelab/core": "*", "@expo-google-fonts/fredoka": "^0.4.1", "@expo-google-fonts/nunito": "^0.4.2", + "@expo/dom-webview": "~57.0.1", "@formatjs/intl-listformat": "^8.3.15", "@formatjs/intl-pluralrules": "^6.3.15", "@react-native-async-storage/async-storage": "2.2.0", @@ -156,6 +157,8 @@ "expo-web-browser": "~57.0.3", "expo-widgets": "~57.0.24", "i18next": "^23.16.8", + "mammoth": "^1.12.0", + "pdfjs-dist": "^6.3.289", "react": "19.2.3", "react-dom": "19.2.3", "react-i18next": "^15.4.1", diff --git a/packages/core/src/library.ts b/packages/core/src/library.ts new file mode 100644 index 000000000..1606d6905 --- /dev/null +++ b/packages/core/src/library.ts @@ -0,0 +1,89 @@ +import type { SourceSemanticMetadata } from './ai/discovered-sources'; + +/** + * The Library's folder list, search and sort orders, shared by the website's + * LibraryPage and the iOS app's Library so both list a debate's sources the same way. + */ + +export const GENERAL_FOLDER = 'General'; +export const AUTO_SAVED_FOLDER = 'Auto-saved'; + +/** The website's sort menu, in its order. `smart` is the default. */ +export const LIBRARY_SORTS = ['smart', 'mostUsed', 'importance', 'strength', 'newest', 'oldest', 'title', 'folder'] as const; +export type LibrarySort = (typeof LIBRARY_SORTS)[number]; + +/** The fields of a Library source these rules read. */ +export interface LibrarySourceLike { + title: string; + content: string; + origin?: string | null; + folder?: string | null; + debateId?: string | null; + createdAt: number; + metadata?: { semantic?: SourceSemanticMetadata; qualityScore?: number } | null; +} + +export function normalizeFolder(folder?: string | null): string { + return folder?.trim() || GENERAL_FOLDER; +} + +/** + * One debate's folders, sorted, as the Library's filter chips list them. + * Folder names belong to the account, so a name only other debates use is + * hidden, while General and new empty folders stay. + */ +export function libraryFolders( + accountFolders: string[], + debateSources: Pick[], + otherDebateFolders: Iterable, +): string[] { + const elsewhere = new Set(Array.from(otherDebateFolders, normalizeFolder)); + const here = debateSources.map((source) => normalizeFolder(source.folder)); + return Array.from(new Set([ + ...(accountFolders.length > 0 ? accountFolders : [GENERAL_FOLDER]) + .map(normalizeFolder) + .filter((folder) => folder === GENERAL_FOLDER || !elsewhere.has(folder) || here.includes(folder)), + ...here, + ])).sort(); +} + +/** The website's search: title, text, origin or folder contain the query, ignoring case. */ +export function matchesLibrarySearch(source: LibrarySourceLike, query: string): boolean { + const needle = query.trim().toLowerCase(); + if (!needle) return true; + return [source.title, source.content, source.origin || '', normalizeFolder(source.folder)] + .some((value) => value.toLowerCase().includes(needle)); +} + +/** A sorted copy, in one of the website's sort orders. */ +export function sortLibrarySources(sources: readonly T[], sortBy: LibrarySort): T[] { + return sources.slice().sort((a, b) => { + const aSemantic = a.metadata?.semantic; + const bSemantic = b.metadata?.semantic; + switch (sortBy) { + case 'smart': + return Number(Boolean(bSemantic?.lastUsedInCase)) - Number(Boolean(aSemantic?.lastUsedInCase)) || + (bSemantic?.importance ?? 0) - (aSemantic?.importance ?? 0) || + (bSemantic?.strength ?? 0) - (aSemantic?.strength ?? 0) || + (b.metadata?.qualityScore ?? 0) - (a.metadata?.qualityScore ?? 0) || + b.createdAt - a.createdAt; + case 'mostUsed': + return (bSemantic?.useCount ?? 0) - (aSemantic?.useCount ?? 0) || b.createdAt - a.createdAt; + case 'importance': + return (bSemantic?.importance ?? 0) - (aSemantic?.importance ?? 0) || b.createdAt - a.createdAt; + case 'strength': + return (bSemantic?.strength ?? 0) - (aSemantic?.strength ?? 0) || b.createdAt - a.createdAt; + case 'oldest': + return a.createdAt - b.createdAt; + case 'title': + return (a.title || '').localeCompare(b.title || ''); + case 'folder': { + const folderCmp = normalizeFolder(a.folder).localeCompare(normalizeFolder(b.folder)); + return folderCmp !== 0 ? folderCmp : (a.title || '').localeCompare(b.title || ''); + } + case 'newest': + default: + return b.createdAt - a.createdAt; + } + }); +} diff --git a/packages/core/src/source-file-import.test.ts b/packages/core/src/source-file-import.test.ts new file mode 100644 index 000000000..02d1de47a --- /dev/null +++ b/packages/core/src/source-file-import.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import { importSourceFile, MAX_SOURCE_FILE_CHARS, SourceFileImportError, type SourceFileReaders } from './source-file-import'; + +const noReaders: SourceFileReaders = { + loadPdfjs: () => Promise.reject(new Error('not a PDF')), + loadMammoth: () => Promise.reject(new Error('not a DOCX')), +}; + +function textFile(name: string, text: string, type = '') { + return { name, type, size: text.length, text: async () => text, bytes: async () => new ArrayBuffer(0) }; +} + +const words = (count: number) => Array.from({ length: count }, (_, index) => `word${index}`).join(' '); + +describe('importSourceFile', () => { + it('refuses a file over 8 MB before reading it', async () => { + const file = { ...textFile('big.txt', ''), size: 8_000_001, text: () => Promise.reject(new Error('read')) }; + await expect(importSourceFile(file, noReaders)).rejects.toMatchObject({ code: 'too-large' }); + }); + + it('refuses a kind the Library cannot read', async () => { + await expect(importSourceFile(textFile('photo.png', 'x', 'image/png'), noReaders)).rejects.toBeInstanceOf(SourceFileImportError); + await expect(importSourceFile(textFile('photo.png', 'x', 'image/png'), noReaders)).rejects.toMatchObject({ code: 'unsupported' }); + }); + + it('cuts long text at the limit without splitting a surrogate pair', async () => { + const text = `${'a'.repeat(MAX_SOURCE_FILE_CHARS - 1)}😀 and more`; + const result = await importSourceFile(textFile('long.md', text), noReaders); + expect(result.content).toBe('a'.repeat(MAX_SOURCE_FILE_CHARS - 1)); + expect(result.notices.map((notice) => notice.code)).toContain('truncated'); + }); + + it('pretty-prints JSON, and imports broken JSON as raw text with a notice', async () => { + const valid = await importSourceFile(textFile('data.json', `{"claims":["${words(30)}"]}`), noReaders); + expect(valid.content.startsWith('{\n "claims"')).toBe(true); + expect(valid.notices).toEqual([]); + const broken = await importSourceFile(textFile('data.json', `{"claims": ${words(30)}`), noReaders); + expect(broken.notices[0]).toMatchObject({ code: 'invalid-json', message: 'Invalid JSON. Imported as raw text.' }); + }); + + it('says when a file has no readable text', async () => { + await expect(importSourceFile(textFile('blank.txt', ' \n\u0000 '), noReaders)).rejects.toMatchObject({ code: 'empty' }); + }); + + it('reads the first 80 pages of a long PDF and says so', async () => { + let destroyed = false; + const readers: SourceFileReaders = { + ...noReaders, + loadPdfjs: async () => ({ + getDocument: () => ({ + promise: Promise.resolve({ + numPages: 120, + getPage: async (page: number) => ({ getTextContent: async () => ({ items: [{ str: `page ${page}` }, {}] }), cleanup: () => {} }), + }), + destroy: async () => { + destroyed = true; + }, + }), + }), + }; + const result = await importSourceFile({ ...textFile('report.pdf', ''), type: 'application/pdf' }, readers); + expect(result.content.split('\n\n')).toHaveLength(80); + expect(result.notices[0]).toMatchObject({ code: 'pdf-pages', pages: 120, imported: 80 }); + expect(destroyed).toBe(true); + }); + + it('names the reader that failed', async () => { + await expect(importSourceFile(textFile('essay.docx', ''), noReaders)).rejects.toMatchObject({ code: 'parse-failed', fileType: 'docx', message: 'Could not parse DOCX: not a DOCX' }); + }); +}); diff --git a/packages/core/src/source-file-import.ts b/packages/core/src/source-file-import.ts new file mode 100644 index 000000000..19abd40f8 --- /dev/null +++ b/packages/core/src/source-file-import.ts @@ -0,0 +1,259 @@ +/** + * Reads a Library file (TXT, Markdown, CSV, JSON, PDF, DOCX) into an editable + * source draft, as the website's Library has always done. The website passes a + * browser File; the iOS app passes the bytes it picked, inside an Expo DOM + * component. pdf.js and mammoth are loaded by the caller, because only the + * caller knows how its bundler serves them. + */ + +export const MAX_SOURCE_FILE_BYTES = 8_000_000; +export const MAX_SOURCE_FILE_CHARS = 120_000; +/** PDFs longer than this are imported up to here, with a notice. */ +export const MAX_SOURCE_PDF_PAGES = 80; +/** Fewer words than this is a short extraction worth checking. */ +const SHORT_EXTRACTION_WORDS = 25; + +export type SourceFileType = 'text' | 'markdown' | 'csv' | 'json' | 'pdf' | 'docx'; + +/** Why a draft needs a look, with the website's English text in `message`. */ +export type SourceFileNotice = + | { code: 'truncated'; message: string } + | { code: 'short'; message: string } + | { code: 'invalid-json'; message: string } + | { code: 'pdf-pages'; pages: number; imported: number; message: string } + /** A message from mammoth about the DOCX, in English. */ + | { code: 'docx'; message: string }; + +export interface SourceFileImportResult { + title: string; + content: string; + type: 'file'; + origin: string; + wordCount: number; + fileType: SourceFileType; + /** The notices' English messages, as the website shows them. */ + warnings: string[]; + notices: SourceFileNotice[]; +} + +export type SourceFileImportErrorCode = 'unsupported' | 'too-large' | 'empty' | 'parse-failed'; + +export class SourceFileImportError extends Error { + constructor( + message: string, + public code: SourceFileImportErrorCode, + /** For a parse failure: which reader failed. */ + public fileType?: SourceFileType, + ) { + super(message); + this.name = 'SourceFileImportError'; + } +} + +/** The fields of a picked file the reader needs; a browser File provides them through a small wrapper. */ +export interface SourceFileInput { + name: string; + /** Bytes. */ + size: number; + /** MIME type, or '' when unknown. */ + type: string; + text: () => Promise; + bytes: () => Promise; +} + +interface PdfTextItem { + str?: string; +} + +interface PdfPage { + getTextContent: () => Promise<{ items: Array }>; + cleanup: () => unknown; +} + +interface PdfDocument { + numPages: number; + getPage: (pageNumber: number) => Promise; +} + +/** The part of pdf.js the reader uses. */ +export interface PdfjsLike { + getDocument: (source: { data: Uint8Array; useWorkerFetch: boolean }) => { + promise: Promise; + destroy: () => Promise; + }; +} + +/** The part of mammoth the reader uses. */ +export interface MammothLike { + extractRawText: (input: { arrayBuffer: ArrayBuffer }) => Promise<{ value: string; messages?: Array<{ message?: string }> }>; +} + +export interface SourceFileReaders { + loadPdfjs: () => Promise; + loadMammoth: () => Promise; +} + +const extensionOf = (name: string) => name.split('.').pop()?.toLowerCase() || ''; + +/** The file's kind from its extension or MIME type, or null when the Library cannot read it. */ +export function detectSourceFileType(file: Pick): SourceFileType | null { + const extension = extensionOf(file.name); + const mime = file.type.toLowerCase(); + + if (['txt'].includes(extension) || mime === 'text/plain') return 'text'; + if (['md', 'markdown'].includes(extension) || mime === 'text/markdown') return 'markdown'; + if (extension === 'csv' || mime === 'text/csv') return 'csv'; + if (extension === 'json' || mime === 'application/json') return 'json'; + if (extension === 'pdf' || mime === 'application/pdf') return 'pdf'; + if ( + extension === 'docx' || + mime === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ) { + return 'docx'; + } + if (mime.startsWith('text/')) return 'text'; + return null; +} + +/** + * Rejects a file before it is read: too large, or a kind the Library cannot + * read. Returns its kind otherwise. + */ +export function checkSourceFile(file: Pick): SourceFileType { + if (file.size > MAX_SOURCE_FILE_BYTES) { + throw new SourceFileImportError('File is too large. Import files under 8 MB.', 'too-large'); + } + const fileType = detectSourceFileType(file); + if (!fileType) { + throw new SourceFileImportError('Unsupported file type. Import TXT, Markdown, CSV, JSON, PDF, or DOCX.', 'unsupported'); + } + return fileType; +} + +function cleanExtractedText(raw: string) { + return raw + .replace(/\r\n/g, '\n') + .replaceAll(String.fromCharCode(0), '') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{4,}/g, '\n\n\n') + .trim(); +} + +export function finalizeSourceFile( + file: Pick, + fileType: SourceFileType, + rawContent: string, + notices: SourceFileNotice[] = [], +): SourceFileImportResult { + let content = cleanExtractedText(rawContent); + if (!content) { + throw new SourceFileImportError('No readable text was found in this file.', 'empty'); + } + if (content.length > MAX_SOURCE_FILE_CHARS) { + let cut = MAX_SOURCE_FILE_CHARS; + // Never end on the first half of a surrogate pair. + const finalCodeUnit = content.charCodeAt(cut - 1); + if (finalCodeUnit >= 0xD800 && finalCodeUnit <= 0xDBFF) cut -= 1; + content = content.slice(0, cut).trim(); + notices.push({ code: 'truncated', message: 'Large file truncated to the first readable section.' }); + } + + const wordCount = content.split(/\s+/).filter(Boolean).length; + if (wordCount < SHORT_EXTRACTION_WORDS) { + notices.push({ code: 'short', message: 'Short extraction. Review the text before saving.' }); + } + + return { + title: file.name.replace(/\.[^.]+$/, '') || file.name, + content, + type: 'file', + origin: file.name, + wordCount, + fileType, + warnings: notices.map((notice) => notice.message), + notices, + }; +} + +async function readTextLikeFile(file: SourceFileInput, fileType: SourceFileType) { + const text = await file.text(); + if (fileType !== 'json') return finalizeSourceFile(file, fileType, text); + + try { + const parsed = JSON.parse(text) as unknown; + return finalizeSourceFile(file, fileType, JSON.stringify(parsed, null, 2)); + } catch { + return finalizeSourceFile(file, fileType, text, [{ code: 'invalid-json', message: 'Invalid JSON. Imported as raw text.' }]); + } +} + +async function readDocxFile(file: SourceFileInput, readers: SourceFileReaders) { + try { + const mammoth = await readers.loadMammoth(); + const result = await mammoth.extractRawText({ arrayBuffer: await file.bytes() }); + const notices = (result.messages ?? []) + .map((message) => message.message) + .filter((message): message is string => Boolean(message)) + .slice(0, 3) + .map((message): SourceFileNotice => ({ code: 'docx', message })); + return finalizeSourceFile(file, 'docx', result.value, notices); + } catch (error) { + if (error instanceof SourceFileImportError) throw error; + throw new SourceFileImportError( + error instanceof Error ? `Could not parse DOCX: ${error.message}` : 'Could not parse DOCX.', + 'parse-failed', + 'docx', + ); + } +} + +async function readPdfFile(file: SourceFileInput, readers: SourceFileReaders) { + let loadingTask: ReturnType | undefined; + try { + const pdfjs = await readers.loadPdfjs(); + loadingTask = pdfjs.getDocument({ + data: new Uint8Array(await file.bytes()), + useWorkerFetch: false, + }); + const pdfDocument = await loadingTask.promise; + + const pages: string[] = []; + const maxPages = Math.min(pdfDocument.numPages, MAX_SOURCE_PDF_PAGES); + for (let pageNumber = 1; pageNumber <= maxPages; pageNumber += 1) { + const page = await pdfDocument.getPage(pageNumber); + const textContent = await page.getTextContent(); + const pageText = textContent.items + .map((item) => ('str' in item && typeof item.str === 'string' ? item.str : '')) + .join(' '); + pages.push(pageText); + page.cleanup(); + } + + const notices: SourceFileNotice[] = pdfDocument.numPages > maxPages + ? [{ + code: 'pdf-pages', + pages: pdfDocument.numPages, + imported: maxPages, + message: `PDF has ${pdfDocument.numPages} pages. Imported the first ${maxPages}.`, + }] + : []; + return finalizeSourceFile(file, 'pdf', pages.join('\n\n'), notices); + } catch (error) { + if (error instanceof SourceFileImportError) throw error; + throw new SourceFileImportError( + error instanceof Error ? `Could not parse PDF: ${error.message}` : 'Could not parse PDF.', + 'parse-failed', + 'pdf', + ); + } finally { + await loadingTask?.destroy(); + } +} + +/** Checks the file, then reads it with the reader for its kind. */ +export async function importSourceFile(file: SourceFileInput, readers: SourceFileReaders): Promise { + const fileType = checkSourceFile(file); + if (fileType === 'pdf') return readPdfFile(file, readers); + if (fileType === 'docx') return readDocxFile(file, readers); + return readTextLikeFile(file, fileType); +} diff --git a/src/components/features/debate/DebateForm.tsx b/src/components/features/debate/DebateForm.tsx index 900671f98..0ec966eca 100644 --- a/src/components/features/debate/DebateForm.tsx +++ b/src/components/features/debate/DebateForm.tsx @@ -58,7 +58,8 @@ export function DebateForm({ initial, submitLabel, pendingLabel, onSubmit, onCan setSaving(true); setError(''); try { - await onSubmit({ motion: motion.trim(), settings: { format, role }, instructions: instructions.trim() }); + // Settings this form does not show (the judge the iOS app sets) are kept. + await onSubmit({ motion: motion.trim(), settings: { ...initial?.settings, format, role }, instructions: instructions.trim() }); } catch (submitError) { setError(submitError instanceof Error ? submitError.message : t('debate.saveFailed', 'Could not save the debate.')); } finally { diff --git a/src/lib/source-file-import.ts b/src/lib/source-file-import.ts index 1760c5f68..848e0b772 100644 --- a/src/lib/source-file-import.ts +++ b/src/lib/source-file-import.ts @@ -1,54 +1,12 @@ import pdfWorkerUrl from 'pdfjs-dist/legacy/build/pdf.worker.mjs?url'; +import { + importSourceFile as importSourceInput, + SourceFileImportError, + type SourceFileImportResult, + type SourceFileReaders, +} from '@debatelab/core/source-file-import'; -const MAX_FILE_BYTES = 8_000_000; -const MAX_EXTRACTED_CHARS = 120_000; - -export interface SourceFileImportResult { - title: string; - content: string; - type: 'file'; - origin: string; - wordCount: number; - fileType: 'text' | 'markdown' | 'csv' | 'json' | 'pdf' | 'docx'; - warnings: string[]; -} - -export class SourceFileImportError extends Error { - constructor(message: string, public code: 'unsupported' | 'too-large' | 'empty' | 'parse-failed') { - super(message); - this.name = 'SourceFileImportError'; - } -} - -const extensionFor = (file: File) => file.name.split('.').pop()?.toLowerCase() || ''; - -function detectFileType(file: File): SourceFileImportResult['fileType'] | null { - const extension = extensionFor(file); - const mime = file.type.toLowerCase(); - - if (['txt'].includes(extension) || mime === 'text/plain') return 'text'; - if (['md', 'markdown'].includes(extension) || mime === 'text/markdown') return 'markdown'; - if (extension === 'csv' || mime === 'text/csv') return 'csv'; - if (extension === 'json' || mime === 'application/json') return 'json'; - if (extension === 'pdf' || mime === 'application/pdf') return 'pdf'; - if ( - extension === 'docx' || - mime === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' - ) { - return 'docx'; - } - if (mime.startsWith('text/')) return 'text'; - return null; -} - -function cleanExtractedText(raw: string) { - return raw - .replace(/\r\n/g, '\n') - .replaceAll(String.fromCharCode(0), '') - .replace(/[ \t]+\n/g, '\n') - .replace(/\n{4,}/g, '\n\n\n') - .trim(); -} +export { SourceFileImportError, type SourceFileImportResult }; async function readFileAsText(file: File) { if (typeof file.text === 'function') { @@ -80,117 +38,25 @@ async function readFileAsArrayBuffer(file: File) { }); } -function finalizeImport(file: File, fileType: SourceFileImportResult['fileType'], rawContent: string, warnings: string[] = []): SourceFileImportResult { - let content = cleanExtractedText(rawContent); - if (!content) { - throw new SourceFileImportError('No readable text was found in this file.', 'empty'); - } - if (content.length > MAX_EXTRACTED_CHARS) { - let cut = MAX_EXTRACTED_CHARS; - const finalCodeUnit = content.charCodeAt(cut - 1); - if (finalCodeUnit >= 0xD800 && finalCodeUnit <= 0xDBFF) cut -= 1; - content = content.slice(0, cut).trim(); - warnings.push('Large file truncated to the first readable section.'); - } - - const wordCount = content.split(/\s+/).filter(Boolean).length; - if (wordCount < 25) { - warnings.push('Short extraction. Review the text before saving.'); - } - - return { - title: file.name.replace(/\.[^.]+$/, '') || file.name, - content, - type: 'file', - origin: file.name, - wordCount, - fileType, - warnings, - }; -} - -async function readTextLikeFile(file: File, fileType: SourceFileImportResult['fileType']) { - const text = await readFileAsText(file); - if (fileType !== 'json') return finalizeImport(file, fileType, text); - - try { - const parsed = JSON.parse(text) as unknown; - return finalizeImport(file, fileType, JSON.stringify(parsed, null, 2)); - } catch { - return finalizeImport(file, fileType, text, ['Invalid JSON. Imported as raw text.']); - } -} - -async function readDocxFile(file: File) { - try { - const mammoth = await import('mammoth'); - const result = await mammoth.extractRawText({ arrayBuffer: await readFileAsArrayBuffer(file) }); - const warnings = result.messages - ?.map((message) => message.message) - .filter(Boolean) - .slice(0, 3) || []; - return finalizeImport(file, 'docx', result.value, warnings); - } catch (error) { - if (error instanceof SourceFileImportError) throw error; - throw new SourceFileImportError( - error instanceof Error ? `Could not parse DOCX: ${error.message}` : 'Could not parse DOCX.', - 'parse-failed', - ); - } -} - -async function readPdfFile(file: File) { - type PdfjsModule = typeof import('pdfjs-dist/legacy/build/pdf.mjs'); - let pdfLoadingTask: ReturnType | undefined; - let pdfDocument: Awaited['promise']> | undefined; - try { +const readers: SourceFileReaders = { + loadPdfjs: async () => { const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs'); pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerUrl; - - pdfLoadingTask = pdfjs.getDocument({ - data: new Uint8Array(await readFileAsArrayBuffer(file)), - useWorkerFetch: false, - }); - pdfDocument = await pdfLoadingTask.promise; - - const pages: string[] = []; - const maxPages = Math.min(pdfDocument.numPages, 80); - for (let pageNumber = 1; pageNumber <= maxPages; pageNumber += 1) { - const page = await pdfDocument.getPage(pageNumber); - const textContent = await page.getTextContent(); - const pageText = textContent.items - .map((item) => ('str' in item ? item.str : '')) - .join(' '); - pages.push(pageText); - page.cleanup(); - } - - const warnings = pdfDocument.numPages > maxPages - ? [`PDF has ${pdfDocument.numPages} pages. Imported the first ${maxPages}.`] - : []; - return finalizeImport(file, 'pdf', pages.join('\n\n'), warnings); - } catch (error) { - if (error instanceof SourceFileImportError) throw error; - throw new SourceFileImportError( - error instanceof Error ? `Could not parse PDF: ${error.message}` : 'Could not parse PDF.', - 'parse-failed', - ); - } finally { - await pdfLoadingTask?.destroy(); - } -} - -export async function importSourceFile(file: File): Promise { - if (file.size > MAX_FILE_BYTES) { - throw new SourceFileImportError('File is too large. Import files under 8 MB.', 'too-large'); - } - - const fileType = detectFileType(file); - if (!fileType) { - throw new SourceFileImportError('Unsupported file type. Import TXT, Markdown, CSV, JSON, PDF, or DOCX.', 'unsupported'); - } - - if (fileType === 'pdf') return readPdfFile(file); - if (fileType === 'docx') return readDocxFile(file); - return readTextLikeFile(file, fileType); + return pdfjs; + }, + loadMammoth: () => import('mammoth'), +}; + +/** The shared reader (packages/core/src/source-file-import.ts) on a browser File. */ +export function importSourceFile(file: File): Promise { + return importSourceInput( + { + name: file.name, + size: file.size, + type: file.type, + text: () => readFileAsText(file), + bytes: () => readFileAsArrayBuffer(file), + }, + readers, + ); } diff --git a/src/locales/en.json b/src/locales/en.json index 48c10145d..10a8951c0 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2697,7 +2697,12 @@ "loadFailed": "Could not load your debate.", "viewAria": "Show preparation for", "recentSources": "Recent sources", - "seeAll": "See all" + "seeAll": "See all", + "judge": "Judge", + "judgePlaceholder": "Lay judge, a parent volunteer", + "judgeHint": "The AI adapts its arguments to this judge.", + "deleteTitle": "Delete this debate?", + "settings": "Debate settings" }, "common": { "done": "Done", @@ -2762,7 +2767,33 @@ "sourceMissing": "This source is no longer in your Library.", "openPage": "Opens the page in the browser", "deleteSourceTitle": "Delete source?", - "deleteSourceDesc": "This removes the source from your synced library." + "deleteSourceDesc": "This removes the source from your synced library.", + "importFile": "Import a file", + "more": "More", + "show": "Show", + "turnOn": "Turn on", + "turnOff": "Turn off", + "turnedOff": "Off for AI", + "moveTo": "Move to folder", + "share": "Share", + "edit": "Edit", + "open": "Open", + "folderOn": "{{active}} of {{total}} on", + "folderEmpty": "No sources in this folder yet.", + "renamePrompt": "Sources in this debate move to the new name.", + "details": "Details", + "file": { + "tooLarge": "File is too large. Import files under 8 MB.", + "unsupported": "Unsupported file type. Import TXT, Markdown, CSV, JSON, PDF, or DOCX.", + "empty": "No readable text was found in this file.", + "parsePdf": "Could not parse PDF.", + "parseDocx": "Could not parse DOCX.", + "truncated": "Large file truncated to the first readable section.", + "short": "Short extraction. Review the text before saving.", + "invalidJson": "Invalid JSON. Imported as raw text.", + "pdfPages_one": "PDF has {{pages}} pages. Imported the first {{imported}}.", + "pdfPages_other": "PDF has {{pages}} pages. Imported the first {{imported}}." + } }, "ask": { "placeholder": "Ask your coach", diff --git a/src/locales/es.json b/src/locales/es.json index 7866a90c3..d2b12546a 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2697,7 +2697,12 @@ "loadFailed": "No se pudo cargar tu debate.", "viewAria": "Mostrar la preparación de", "recentSources": "Fuentes recientes", - "seeAll": "Ver todo" + "seeAll": "Ver todo", + "judge": "Juez", + "judgePlaceholder": "Juez lego, un padre voluntario", + "judgeHint": "La IA adapta sus argumentos a este juez.", + "deleteTitle": "¿Eliminar este debate?", + "settings": "Ajustes del debate" }, "common": { "done": "Listo", @@ -2762,7 +2767,33 @@ "sourceMissing": "Esta fuente ya no está en tu biblioteca.", "openPage": "Abre la página en el navegador", "deleteSourceTitle": "¿Eliminar fuente?", - "deleteSourceDesc": "Esto eliminará la fuente de tu biblioteca sincronizada." + "deleteSourceDesc": "Esto eliminará la fuente de tu biblioteca sincronizada.", + "importFile": "Importar un archivo", + "more": "Más", + "show": "Mostrar", + "turnOn": "Activar", + "turnOff": "Desactivar", + "turnedOff": "Desactivada para la IA", + "moveTo": "Mover a carpeta", + "share": "Compartir", + "edit": "Editar", + "open": "Abrir", + "folderOn": "{{active}} de {{total}} activadas", + "folderEmpty": "Aún no hay fuentes en esta carpeta.", + "renamePrompt": "Las fuentes de este debate pasan al nuevo nombre.", + "details": "Detalles", + "file": { + "tooLarge": "El archivo es demasiado grande. Importa archivos de menos de 8 MB.", + "unsupported": "Tipo de archivo no compatible. Importa TXT, Markdown, CSV, JSON, PDF o DOCX.", + "empty": "No se encontró texto legible en este archivo.", + "parsePdf": "No se pudo procesar el PDF.", + "parseDocx": "No se pudo procesar el DOCX.", + "truncated": "Archivo grande recortado a la primera sección legible.", + "short": "Se extrajo poco texto. Revísalo antes de guardar.", + "invalidJson": "JSON no válido. Se importó como texto sin formato.", + "pdfPages_one": "El PDF tiene {{pages}} páginas. Se importaron las primeras {{imported}}.", + "pdfPages_other": "El PDF tiene {{pages}} páginas. Se importaron las primeras {{imported}}." + } }, "ask": { "placeholder": "Pregunta a tu coach", diff --git a/src/locales/hu.json b/src/locales/hu.json index ffb822980..109356bb1 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2697,7 +2697,12 @@ "loadFailed": "Nem sikerült betölteni a vitát.", "viewAria": "Felkészülés megjelenítése", "recentSources": "Legutóbbi források", - "seeAll": "Összes" + "seeAll": "Összes", + "judge": "Bíró", + "judgePlaceholder": "Laikus bíró, önkéntes szülő", + "judgeHint": "Az MI ehhez a bíróhoz igazítja az érveit.", + "deleteTitle": "Törlöd ezt a vitát?", + "settings": "A vita beállításai" }, "common": { "done": "Kész", @@ -2762,7 +2767,33 @@ "sourceMissing": "Ez a forrás már nincs a könyvtáradban.", "openPage": "Megnyitja az oldalt a böngészőben", "deleteSourceTitle": "Törlöd a forrást?", - "deleteSourceDesc": "Ezzel eltávolítod a forrást a szinkronizált könyvtáradból." + "deleteSourceDesc": "Ezzel eltávolítod a forrást a szinkronizált könyvtáradból.", + "importFile": "Fájl importálása", + "more": "Továbbiak", + "show": "Megjelenítés", + "turnOn": "Bekapcsolás", + "turnOff": "Kikapcsolás", + "turnedOff": "MI-nek kikapcsolva", + "moveTo": "Áthelyezés mappába", + "share": "Megosztás", + "edit": "Szerkesztés", + "open": "Megnyitás", + "folderOn": "{{total}} forrásból {{active}} bekapcsolva", + "folderEmpty": "Ebben a mappában még nincs forrás.", + "renamePrompt": "A vita forrásai átkerülnek az új nevű mappába.", + "details": "Részletek", + "file": { + "tooLarge": "A fájl túl nagy. 8 MB-nál kisebb fájlt importálj.", + "unsupported": "Nem támogatott fájltípus. TXT, Markdown, CSV, JSON, PDF vagy DOCX fájlt importálj.", + "empty": "Ebben a fájlban nincs olvasható szöveg.", + "parsePdf": "Nem sikerült feldolgozni a PDF-et.", + "parseDocx": "Nem sikerült feldolgozni a DOCX-fájlt.", + "truncated": "A nagy fájlból csak az első olvasható rész került be.", + "short": "Kevés szöveg került ki. Mentés előtt nézd át.", + "invalidJson": "Érvénytelen JSON. Nyers szövegként került be.", + "pdfPages_one": "A PDF {{pages}} oldalas. Az első {{imported}} oldal került be.", + "pdfPages_other": "A PDF {{pages}} oldalas. Az első {{imported}} oldal került be." + } }, "ask": { "placeholder": "Kérdezd az edződet", diff --git a/src/pages/LibraryPage.tsx b/src/pages/LibraryPage.tsx index c4e246ff7..77aa9d6ce 100644 --- a/src/pages/LibraryPage.tsx +++ b/src/pages/LibraryPage.tsx @@ -19,6 +19,7 @@ import { validateUrl } from '@/lib/validation/url'; import { browserIsOffline, isBrowserNetworkFailure } from '@/lib/ai/client'; import { useCurrentDebate, useDebateStore } from '@/lib/stores/debate-store'; import { importSourceFile, SourceFileImportError } from '@/lib/source-file-import'; +import { libraryFolders, matchesLibrarySearch, normalizeFolder, sortLibrarySources, type LibrarySort } from '@debatelab/core/library'; import { AlertDialog, AlertDialogAction, @@ -240,7 +241,7 @@ export default function LibraryPage() { const [newFolderName, setNewFolderName] = useState(''); const [newFolderOpen, setNewFolderOpen] = useState(false); const [folderRenameValue, setFolderRenameValue] = useState(''); - const [sortBy, setSortBy] = useState<'smart' | 'mostUsed' | 'importance' | 'strength' | 'newest' | 'oldest' | 'title' | 'folder'>('smart'); + const [sortBy, setSortBy] = useState('smart'); const [importUrl, setImportUrl] = useState(''); const [isImportingUrl, setIsImportingUrl] = useState(false); const [selectedSourceId, setSelectedSourceId] = useState(null); @@ -457,59 +458,20 @@ export default function LibraryPage() { const firstLoadPending = serverHydration === 'pending' && sourceLibrary.length === 0; const hasActiveSources = activeSourceCount > 0; // Folder names are account-wide; hide the ones only other debates use, but keep new empty ones. - const otherDebateFolders = new Set(allSources.filter((source) => source.debateId !== currentDebate?.id).map((source) => source.folder || 'General')); - const thisDebateFolders = sourceLibrary.map(s => s.folder || 'General').filter(Boolean); - const folders = Array.from(new Set([ - ...(sourceFolders || ['General']).filter((folder) => folder === 'General' || !otherDebateFolders.has(folder) || thisDebateFolders.includes(folder)), - ...thisDebateFolders, - ])).sort(); + const otherDebateFolders = allSources.filter((source) => source.debateId !== currentDebate?.id).map((source) => source.folder || 'General'); + const folders = libraryFolders(sourceFolders || ['General'], sourceLibrary, otherDebateFolders); const folderKey = folders.join('\n'); useEffect(() => { // A folder picked in another debate may not exist here. if (selectedFolder !== 'all' && !folderKey.split('\n').includes(selectedFolder)) setSelectedFolder('all'); }, [folderKey, selectedFolder]); - const visibleSources = sourceLibrary - .filter((source) => selectedFolder === 'all' || (source.folder || 'General') === selectedFolder) - .filter((source) => { - const query = searchQuery.trim().toLowerCase(); - if (!query) return true; - return [ - source.title, - source.content, - source.origin || '', - source.folder || 'General', - ].some((value) => value.toLowerCase().includes(query)); - }) - .slice() - .sort((a, b) => { - const aSemantic = a.metadata?.semantic; - const bSemantic = b.metadata?.semantic; - switch (sortBy) { - case 'smart': - return Number(Boolean(bSemantic?.lastUsedInCase)) - Number(Boolean(aSemantic?.lastUsedInCase)) || - (bSemantic?.importance ?? 0) - (aSemantic?.importance ?? 0) || - (bSemantic?.strength ?? 0) - (aSemantic?.strength ?? 0) || - (b.metadata?.qualityScore ?? 0) - (a.metadata?.qualityScore ?? 0) || - b.createdAt - a.createdAt; - case 'mostUsed': - return (bSemantic?.useCount ?? 0) - (aSemantic?.useCount ?? 0) || b.createdAt - a.createdAt; - case 'importance': - return (bSemantic?.importance ?? 0) - (aSemantic?.importance ?? 0) || b.createdAt - a.createdAt; - case 'strength': - return (bSemantic?.strength ?? 0) - (aSemantic?.strength ?? 0) || b.createdAt - a.createdAt; - case 'oldest': - return a.createdAt - b.createdAt; - case 'title': - return (a.title || '').localeCompare(b.title || ''); - case 'folder': { - const folderCmp = (a.folder || 'General').localeCompare(b.folder || 'General'); - return folderCmp !== 0 ? folderCmp : (a.title || '').localeCompare(b.title || ''); - } - case 'newest': - default: - return b.createdAt - a.createdAt; - } - }); + const visibleSources = sortLibrarySources( + sourceLibrary + // The chips list trimmed names, so a legacy folder saved with spaces still matches its chip. + .filter((source) => selectedFolder === 'all' || normalizeFolder(source.folder) === selectedFolder) + .filter((source) => matchesLibrarySearch(source, searchQuery)), + sortBy, + ); const selectedSource = selectedSourceId ? sourceLibrary.find((source) => source.id === selectedSourceId) || null : null; useEffect(() => { diff --git a/src/test/debate/debate-form.test.tsx b/src/test/debate/debate-form.test.tsx new file mode 100644 index 000000000..7978b87dd --- /dev/null +++ b/src/test/debate/debate-form.test.tsx @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +import '@/i18n'; +import { DebateForm } from '@/components/features/debate/DebateForm'; + +describe('DebateForm', () => { + it('keeps settings it does not show, such as the judge the iOS app sets, when it saves', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(onSubmit.mock.calls[0][0].settings).toEqual({ format: 'bp', role: 'r1', judge: 'Lay judge, a parent volunteer' }); + }); +});