From a2474212aa0889793befda7512984c654fca819f Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 11:48:05 +0000 Subject: [PATCH 01/16] Share the Library's file reader, sort orders and folder list with the app The website's file reader moves to packages/core with pdf.js and mammoth passed in, so the iOS app can run the same code in a DOM component. Its errors and warnings now carry codes the app can translate; the website still shows the same English text. LibraryPage's folder list, search and sort orders move to core/library.ts unchanged. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/library.ts | 89 ++++++++ packages/core/src/source-file-import.ts | 259 ++++++++++++++++++++++++ src/lib/source-file-import.ts | 186 +++-------------- src/pages/LibraryPage.tsx | 59 +----- 4 files changed, 384 insertions(+), 209 deletions(-) create mode 100644 packages/core/src/library.ts create mode 100644 packages/core/src/source-file-import.ts 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.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/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/pages/LibraryPage.tsx b/src/pages/LibraryPage.tsx index c4e246ff7..563696c91 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, 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,19 @@ 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 + .filter((source) => selectedFolder === 'all' || (source.folder || 'General') === selectedFolder) + .filter((source) => matchesLibrarySearch(source, searchQuery)), + sortBy, + ); const selectedSource = selectedSourceId ? sourceLibrary.find((source) => source.id === selectedSourceId) || null : null; useEffect(() => { From ceaf1b6e39e1c7ab59e84e9ce9e3bdaa6c6c963e Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 12:06:23 +0000 Subject: [PATCH 02/16] Add one haptics helper and an app toast, and tap the shared controls haptics.ts gives every screen the same feel: a light tap for switches and chips, a selection tick for segmented controls and pickers, success when a tool's result arrives, a warning when a tool's request fails, and a tap when the turn comes back in Crossfire (Sparring already had one). The web preview records them for the screenshot script. toast.tsx shows the website's short messages above the tab bar, with an optional action such as Undo, the mint check on success and a VoiceOver announcement. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/crossfire.tsx | 3 + apps/mobile/src/components/chip.tsx | 8 +- apps/mobile/src/components/form-rows.tsx | 11 +- .../src/components/segmented-control.tsx | 6 +- apps/mobile/src/components/toast.tsx | 145 ++++++++++++++++++ apps/mobile/src/components/tool-form.tsx | 3 + apps/mobile/src/lib/haptics.ts | 21 +++ apps/mobile/src/lib/haptics.web.ts | 19 +++ apps/mobile/src/lib/open-results.ts | 4 +- 9 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 apps/mobile/src/components/toast.tsx create mode 100644 apps/mobile/src/lib/haptics.ts create mode 100644 apps/mobile/src/lib/haptics.web.ts diff --git a/apps/mobile/src/app/crossfire.tsx b/apps/mobile/src/app/crossfire.tsx index 38947dccd..055371252 100644 --- a/apps/mobile/src/app/crossfire.tsx +++ b/apps/mobile/src/app/crossfire.tsx @@ -27,6 +27,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 { buildDebateContext } from '@/lib/context-request'; import { @@ -223,6 +224,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/components/chip.tsx b/apps/mobile/src/components/chip.tsx index 6680dcb3e..2362bb51b 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'; @@ -32,7 +33,12 @@ export function Chip({ { + // A switch chip taps; a filter or tab 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/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/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/toast.tsx b/apps/mobile/src/components/toast.tsx new file mode 100644 index 000000000..c8c294e31 --- /dev/null +++ b/apps/mobile/src/components/toast.tsx @@ -0,0 +1,145 @@ +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>(() => {}); + +/** 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. 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 value = useMemo(() => show, [show]); + return ( + + {children} + {toast ? setToast(null)} /> : 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 97ea80b1e..49df34ead 100644 --- a/apps/mobile/src/components/tool-form.tsx +++ b/apps/mobile/src/components/tool-form.tsx @@ -9,6 +9,7 @@ import { Text } from './text'; import type { AIErrorKind } from '@/lib/ai-request'; 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'; @@ -58,6 +59,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/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 a9a85321f..2785ab2a2 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; From 984c9df2cd95a41ab24e8fc325f65405ce538ce7 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 12:06:23 +0000 Subject: [PATCH 03/16] Give the app the website's Library: files, folders, the Active switch and editing - Import a file (PDF, DOCX, TXT, Markdown, CSV, JSON): the Files picker hands the bytes to the website's reader, which runs with pdf.js and mammoth in an Expo DOM component, with its 8 MB and 120,000-character limits, its notices and its error messages in all three languages. - Folders: chips to filter by folder, New folder, and for the chosen folder a switch for every source in it, Rename (or merge, after asking) and Delete, which moves its sources to General, as on the website. - The Active switch on every row, by swiping right and in the menu, with the website's grounding banner and its rollback when a change fails. - The website's eight sort orders and its search, from packages/core. - The source view shows quality and use, the switch and folder, Copy, Share and Delete, and Edit saves only the fields changed. - Delete acts at once with Undo, which puts the source back in its place; Clear all asks first. Paste a link and Write a note pick a folder too. - Press and hold a source for a preview and Open, Turn on or off, Move to folder, Copy, Share and Delete. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/package.json | 4 + .../src/app/(tabs)/(library)/library.tsx | 468 ++++++++++-------- apps/mobile/src/app/_layout.tsx | 82 +-- apps/mobile/src/app/add-file.tsx | 248 ++++++++++ apps/mobile/src/app/add-link.tsx | 12 +- apps/mobile/src/app/new-note.tsx | 12 +- apps/mobile/src/components/folder-select.tsx | 59 +++ apps/mobile/src/components/icon-paths.ts | 6 + apps/mobile/src/components/library-header.tsx | 72 ++- .../src/components/library-header.web.tsx | 138 ++++-- apps/mobile/src/components/library-parts.tsx | 128 +++++ apps/mobile/src/components/source-display.ts | 9 + .../src/components/source-file-reader.tsx | 95 ++++ apps/mobile/src/components/source-row.tsx | 214 ++++++++ apps/mobile/src/components/source-view.tsx | 204 +++++++- apps/mobile/src/lib/pick-source-file.ts | 9 + apps/mobile/src/lib/picked-file.ts | 7 + apps/mobile/src/lib/picked-file.web.ts | 10 + apps/mobile/src/lib/source-actions.ts | 116 +++++ apps/mobile/src/lib/source-file.tsx | 94 ++++ apps/mobile/src/lib/sources.ts | 203 +++++++- apps/mobile/src/lib/use-library-folders.ts | 30 ++ apps/mobile/src/pdfjs-worker.d.ts | 2 + package-lock.json | 13 + src/locales/en.json | 35 +- src/locales/es.json | 35 +- src/locales/hu.json | 35 +- 27 files changed, 2014 insertions(+), 326 deletions(-) create mode 100644 apps/mobile/src/app/add-file.tsx create mode 100644 apps/mobile/src/components/folder-select.tsx create mode 100644 apps/mobile/src/components/library-parts.tsx create mode 100644 apps/mobile/src/components/source-file-reader.tsx create mode 100644 apps/mobile/src/components/source-row.tsx create mode 100644 apps/mobile/src/lib/pick-source-file.ts create mode 100644 apps/mobile/src/lib/picked-file.ts create mode 100644 apps/mobile/src/lib/picked-file.web.ts create mode 100644 apps/mobile/src/lib/source-actions.ts create mode 100644 apps/mobile/src/lib/source-file.tsx create mode 100644 apps/mobile/src/lib/use-library-folders.ts create mode 100644 apps/mobile/src/pdfjs-worker.d.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 12ee988ef..e8659608e 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-pluralrules": "^6.3.15", "@react-native-async-storage/async-storage": "2.2.0", "@supabase/supabase-js": "^2.49.1", @@ -23,6 +24,7 @@ "expo-constants": "~57.0.21", "expo-crypto": "~57.0.3", "expo-dev-client": "~57.0.20", + "expo-document-picker": "~57.0.3", "expo-file-system": "~57.0.7", "expo-font": "~57.0.4", "expo-haptics": "~57.0.3", @@ -42,6 +44,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/src/app/(tabs)/(library)/library.tsx b/apps/mobile/src/app/(tabs)/(library)/library.tsx index f92f66ea8..2af275be9 100644 --- a/apps/mobile/src/app/(tabs)/(library)/library.tsx +++ b/apps/mobile/src/app/(tabs)/(library)/library.tsx @@ -1,102 +1,160 @@ +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, type AddSourceKind, type SourceTypeFilter } from '@/components/library-header'; +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 +218,34 @@ 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, deleted, or only another debate's) shows all folders again. useEffect(() => { - if (!selectedId || sources === null) return; - if (openRowId(selectedId, sources) === null) onRemoved?.(selectedId); - }, [sources, selectedId, onRemoved]); + if (folder !== ALL && !folders.includes(folder) && !(sources ?? []).some((source) => source.folder === folder)) setFolder(ALL); + }, [folder, folders, sources]); + 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 { sources, failed, folders, folder, inFolder, visible, patch, userId, debateId } = library; if (failed && !sources) { return ( @@ -224,31 +253,95 @@ 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(); + + const toggle = async (targets: Source[], isActive: boolean, inFolderName?: string) => { + const ids = targets.map((source) => source.id); + const before = new Map(targets.map((source) => [source.id, source.isActive])); + patch(ids, { isActive }); + const saved = await actions.setActive(targets, isActive, inFolderName); + if (!saved) { + // Back to how it was, as on the website. + library.setSources((previous) => previous?.map((source) => (before.has(source.id) ? { ...source, isActive: before.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 +354,23 @@ function SourceList({ (onOpen ? onOpen(source) : router.push({ pathname: '/source/[id]', params: { id: source.id } }))} - onDelete={() => remove(source)} + selectInstead={Boolean(onSelect)} + 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 +379,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/_layout.tsx b/apps/mobile/src/app/_layout.tsx index d6d0dbe62..3ba362c81 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 { ToastProvider } from '@/components/toast'; import { AuthProvider, useAuth } from '@/lib/auth'; import { DebateProvider } from '@/lib/debate-context'; import { watchNetwork } from '@/lib/network-status'; @@ -84,44 +85,49 @@ function Root() { {/* The screenshot script waits for auth-settled before it checks an offline cold start. Swipeable rows need the gesture root. */} - - - - - - - - - - - {/* Full screen above the tabs, as in mock 07. */} - - {/* History, full screen above the tabs like the documents it opens. */} - - {/* Generate, Attack, Brainstorm, Factoids and their documents, full screen above the tabs, as in mock 03. */} - - - - - - - - - {/* Flowchart full screen above the tabs, its card, claim and saved-map sheets as form sheets, as in mock A. */} - - - - - {/* Live practice screens, full screen with no tab bar, as the live round in mock 05. */} - - - - - - - - - + + + + + + + + + + + + + + + {/* Full screen above the tabs, as in mock 07. */} + + {/* History, full screen above the tabs like the documents it opens. */} + + {/* Generate, Attack, Brainstorm, Factoids and their documents, full screen above the tabs, as in mock 03. */} + + + + + + + + + {/* Flowchart full screen above the tabs, its card, claim and saved-map sheets as form sheets, as in mock A. */} + + + + + {/* Live practice screens, full screen with no tab bar, as the live round in mock 05. */} + + + + + + + + + + ); diff --git a/apps/mobile/src/app/add-file.tsx b/apps/mobile/src/app/add-file.tsx new file mode 100644 index 000000000..2a736de1d --- /dev/null +++ b/apps/mobile/src/app/add-file.tsx @@ -0,0 +1,248 @@ +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') }; + 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); + 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' }, + 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/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/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/icon-paths.ts b/apps/mobile/src/components/icon-paths.ts index 9ad11d6df..187698e11 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: '', } as const; export type IconName = keyof typeof iconPaths; diff --git a/apps/mobile/src/components/library-header.tsx b/apps/mobile/src/components/library-header.tsx index 1c96e76de..30b51be2f 100644 --- a/apps/mobile/src/components/library-header.tsx +++ b/apps/mobile/src/components/library-header.tsx @@ -1,21 +1,82 @@ +import type { LibrarySort } from '@debatelab/core/library'; import { Stack } from 'expo-router'; import { useTranslation } from 'react-i18next'; import { useTheme } from '@/theme/use-theme'; -export type AddSourceKind = 'link' | 'note'; +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; +} /** - * 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..4dd51e1a3 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-header'; 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-parts.tsx b/apps/mobile/src/components/library-parts.tsx new file mode 100644 index 000000000..20d95633e --- /dev/null +++ b/apps/mobile/src/components/library-parts.tsx @@ -0,0 +1,128 @@ +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 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: one switch + * turns every source in it on or off for the AI, and the menu renames + * (or merges) and deletes it. General can only be switched. + */ +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 allOn = sources.length > 0 && active === sources.length; + 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 })}`} + + + + {editable ? ( + setMenu(true)} + testID="folder-menu" + style={({ pressed }) => [styles.more, { backgroundColor: colors.segmentTrack }, pressed && styles.pressed]}> + + + ) : null} + setMenu(false)} + onSelect={(key) => (key === 'rename' ? onRename() : 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/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..98b80ff6f --- /dev/null +++ b/apps/mobile/src/components/source-file-reader.tsx @@ -0,0 +1,95 @@ +'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 chunks 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']; 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(chunks: string[]): ArrayBuffer { + const parts = chunks.map((chunk) => { + 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; + }); + const out = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out.buffer; +} + +// 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 bytes: Promise | null = null; + const read = () => { + bytes ??= Promise.all(Array.from({ length: job.chunks }, (_, index) => readChunk(job.id, index))).then(decodeBase64); + return bytes; + }; + importSourceFile( + { + name: job.name, + size: job.size, + type: job.type, + text: async () => new TextDecoder().decode(await read()), + bytes: read, + }, + readers, + ) + .then((result) => onDone(job.id, { ok: true, result })) + .catch((error: unknown) => { + 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 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..42ea8cd2c --- /dev/null +++ b/apps/mobile/src/components/source-row.tsx @@ -0,0 +1,214 @@ +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 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 }: { source: Source }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const semantic = source.metadata?.semantic; + const badge = (key: string, label: string, primary = false) => ( + + + {label} + + + ); + return ( + + + + + {folderLabel(t, source.folder)} + + + {semantic?.lastUsedInCase ? badge('used', t('sourceQuality.usedInCase'), true) : null} + {semantic ? badge('importance', t('sourceQuality.importance', { level: t(`sourceQuality.levels.${semantic.importance}`) })) : null} + {semantic ? 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, + actions, +}: { + source: Source; + folders: string[]; + first: boolean; + selected?: boolean; + selectInstead?: 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(); + actions.onToggle(!source.isActive); + }; + + return ( + ( + + + + {toggleLabel} + + + )} + renderRightActions={() => ( + void remove()} + style={[styles.swipeAction, { backgroundColor: colors.destructive }]}> + + + {t('common.delete')} + + + )}> + { + if (!selectInstead) return; + event.preventDefault(); + actions.onOpen(); + }}> + + { + if (event.nativeEvent.actionName === 'toggle') toggle(); + if (event.nativeEvent.actionName === 'delete') void remove(); + }} + testID="source-row" + style={({ pressed }) => [styles.row, { backgroundColor: selected ? colors.tile : colors.card }, pressed && styles.pressed]}> + + + + + + {title} + + + {source.isActive ? sourceMeta(t, source) : `${t('mobile.library.turnedOff')} · ${sourceMeta(t, source)}`} + + + + 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} + /> + + + + + + {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')} + + + + + ); +} + +const styles = StyleSheet.create({ + row: { flexDirection: 'row', alignItems: 'center', gap: 13, paddingVertical: 12, paddingHorizontal: 14 }, + 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..635b7fa15 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 ( <> @@ -71,7 +147,12 @@ export function SourceView({ id, onDone }: { id: string; onDone?: () => void }) ) : 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/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..75b19c079 --- /dev/null +++ b/apps/mobile/src/lib/picked-file.ts @@ -0,0 +1,7 @@ +import type { DocumentPickerAsset } from 'expo-document-picker'; +import { File } from 'expo-file-system'; + +/** A picked file's bytes as base64, read from the copy the picker left in the app's cache. */ +export function readPickedFile(asset: DocumentPickerAsset): Promise { + return new File(asset.uri).base64(); +} 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..c635f1288 --- /dev/null +++ b/apps/mobile/src/lib/picked-file.web.ts @@ -0,0 +1,10 @@ +import type { DocumentPickerAsset } from 'expo-document-picker'; + +/** The web preview's picker hands back a browser File; its bytes as base64. */ +export async function readPickedFile(asset: DocumentPickerAsset): Promise { + if (!asset.file) throw new Error('The picked file has no contents'); + const bytes = new Uint8Array(await asset.file.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..9110c3c56 --- /dev/null +++ b/apps/mobile/src/lib/source-actions.ts @@ -0,0 +1,116 @@ +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; + haptics.tap(); + 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..ba54616ae --- /dev/null +++ b/apps/mobile/src/lib/source-file.tsx @@ -0,0 +1,94 @@ +import { checkSourceFile, SourceFileImportError } from '@debatelab/core/source-file-import'; +import type { DocumentPickerAsset } from 'expo-document-picker'; +import { useCallback, useRef, useState, type ReactNode } from 'react'; +import { StyleSheet } from 'react-native'; + +import SourceFileReader, { type SourceFileJob, type SourceFileOutcome } from '@/components/source-file-reader'; +import { readPickedFile } from './picked-file'; + +export type { SourceFileOutcome }; + +/** Base64 characters per message to the reader: a multiple of 4, so every chunk decodes alone. */ +const CHUNK_CHARS = 1_048_576; + +/** + * 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 the file is read, as the website does. + */ +export function useSourceFileReader(): { reader: ReactNode; read: (asset: DocumentPickerAsset) => Promise } { + const [job, setJob] = useState(null); + const pending = useRef(new Map void }>()); + + const read = useCallback(async (asset: DocumentPickerAsset): Promise => { + const file = { name: asset.name, type: asset.mimeType ?? '', size: asset.size ?? 0 }; + try { + checkSourceFile(file); + } catch (error) { + if (error instanceof SourceFileImportError) return { ok: false, code: error.code, message: error.message }; + throw error; + } + let base64: string; + try { + base64 = await readPickedFile(asset); + } catch (error) { + return { ok: false, code: 'parse-failed', message: error instanceof Error ? error.message : String(error) }; + } + const chunks: string[] = []; + for (let start = 0; start < base64.length; start += CHUNK_CHARS) chunks.push(base64.slice(start, start + CHUNK_CHARS)); + // The picker's size can be missing; the bytes read decide. + const size = Math.floor((base64.length * 3) / 4) - (base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0); + const id = `file-${++jobs}`; + return new Promise((resolve) => { + pending.current.set(id, { chunks, resolve }); + setJob({ id, name: asset.name, type: file.type, size, chunks: chunks.length }); + }); + }, []); + + const readChunk = useCallback(async (id: string, index: number) => pending.current.get(id)?.chunks[index] ?? '', []); + const onDone = useCallback(async (id: string, outcome: SourceFileOutcome) => { + const entry = pending.current.get(id); + pending.current.delete(id); + setJob((current) => (current?.id === id ? null : current)); + entry?.resolve(outcome); + }, []); + + // 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..783204f5b 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,155 @@ 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(); + +/** + * 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(); + 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 }))]); + }); +} + +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-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/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/package-lock.json b/package-lock.json index 6a0742052..ba61615c9 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-pluralrules": "^6.3.15", "@react-native-async-storage/async-storage": "2.2.0", "@supabase/supabase-js": "^2.49.1", @@ -135,6 +136,7 @@ "expo-constants": "~57.0.21", "expo-crypto": "~57.0.3", "expo-dev-client": "~57.0.20", + "expo-document-picker": "~57.0.3", "expo-file-system": "~57.0.7", "expo-font": "~57.0.4", "expo-haptics": "~57.0.3", @@ -154,6 +156,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", @@ -672,6 +676,15 @@ "expo": "*" } }, + "apps/mobile/node_modules/expo-document-picker": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-57.0.3.tgz", + "integrity": "sha512-XlxdZ0zzv44JRD8O15DISgIO8FTaZ581oc4kQp6MJoR7vdahjcY+2qCuDt5k1J8/lc0YR+bXon/v81norciuxw==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "apps/mobile/node_modules/expo-file-system": { "version": "57.0.7", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.7.tgz", diff --git a/src/locales/en.json b/src/locales/en.json index ba2f2b2b5..a26dc9781 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 dd3b5fc5c..4d07ac111 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 b01b54fad..cf1b877fb 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", From 6c20bb70cb06f4a76c3459acaa08963f7ea0ad86 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 12:06:33 +0000 Subject: [PATCH 04/16] Press and hold saved work for a preview and its menu Rows on the Debate tab, Prepare, Practice and History open a preview of the document on a long press, with Discuss and Share for cases, attacks, brainstorms and factoids, and Delete for cases and Sparring rounds after the website's History confirmation. History's chat rows preview the chat. Co-Authored-By: Claude Opus 5.5 --- .../src/app/(tabs)/(practice)/practice.tsx | 3 +- .../src/app/(tabs)/(prepare)/prepare.tsx | 4 +- apps/mobile/src/app/history.tsx | 37 ++++++- apps/mobile/src/components/work-row.tsx | 67 ++++++++++-- apps/mobile/src/lib/work-actions.ts | 101 ++++++++++++++++++ 5 files changed, 200 insertions(+), 12 deletions(-) create mode 100644 apps/mobile/src/lib/work-actions.ts 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/history.tsx b/apps/mobile/src/app/history.tsx index 71b7a1816..c71daa3f6 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 = ( : 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/components/work-row.tsx b/apps/mobile/src/components/work-row.tsx index d471a29ac..791a44330 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, 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,21 +50,40 @@ 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 actions = useWorkActions(); + const title = item.title || t(`debate.kinds.${item.kind}`); + const href = from ? workHref(item, from) : null; + const row = ( [styles.row, !first && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> + style={({ pressed }) => [styles.row, !first && { borderTopWidth: 1, borderTopColor: colors.line }, { backgroundColor: colors.card }, pressed && styles.pressed]}> - {item.title || t(`debate.kinds.${item.kind}`)} + {title} {t('mobile.debate.itemMeta', { kind: t(`debate.kinds.${item.kind}`), when: formatWhen(item.createdAt) })} @@ -72,6 +92,39 @@ export function WorkRow({ item, first, onPress }: { item: DebateItem; first: boo ); + // Practice work opens the Practice tab, which has nothing to preview. + if (!href || href === '/practice') 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/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 }; +} From 0b6be85040b43d7e682bbd9b4775d7d39b1e66d1 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 12:06:33 +0000 Subject: [PATCH 05/16] Debates: role, judge and instructions, Edit settings, Delete and the 5-day check - Start new debate and Edit settings share the website's DebateForm: motion, format, your role and instructions, with its limits. The app adds the judge, which the website's prompts already read as "Judge expectations" but its form never shows. Settings the form does not show are kept. - The Debate tab shows the format, role, judge and instructions under the motion, as the website's card does, with Edit settings. - Swipe left on any debate but the current one in the switcher to delete it, after the website's warning that its sources and saved work go too. - After five days without work on the Current Debate, the website's "Still preparing this debate?" asks to Continue, Switch or Start new; closing it counts as Continue. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/(tabs)/(debate)/index.tsx | 89 +++++++- apps/mobile/src/app/(tabs)/_layout.tsx | 2 + apps/mobile/src/app/debate-check.tsx | 90 ++++++++ apps/mobile/src/app/debates.tsx | 80 ++++++-- apps/mobile/src/app/edit-debate.tsx | 51 +++++ apps/mobile/src/app/new-debate.tsx | 104 ++-------- apps/mobile/src/components/debate-form.tsx | 192 ++++++++++++++++++ apps/mobile/src/lib/debate-context.tsx | 45 +++- apps/mobile/src/lib/debates.ts | 92 ++++++++- .../src/lib/use-inactive-debate-check.ts | 27 +++ 10 files changed, 655 insertions(+), 117 deletions(-) create mode 100644 apps/mobile/src/app/debate-check.tsx create mode 100644 apps/mobile/src/app/edit-debate.tsx create mode 100644 apps/mobile/src/components/debate-form.tsx create mode 100644 apps/mobile/src/lib/use-inactive-debate-check.ts diff --git a/apps/mobile/src/app/(tabs)/(debate)/index.tsx b/apps/mobile/src/app/(tabs)/(debate)/index.tsx index 5200728e8..3da53654e 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 ( @@ -436,7 +505,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: { flexDirection: 'row', alignItems: 'flex-start', gap: 12, borderTopWidth: 1, borderBottomWidth: 1, paddingVertical: 10, marginBottom: 15 }, + settingsText: { flex: 1, gap: 3 }, + settingsFacts: { fontSize: 14, lineHeight: 19 }, + settingsInstructions: { fontSize: 14.5, lineHeight: 20 }, + editButton: { 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)/_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/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/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/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/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/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]); +} From 61830558affa2082723264e0b5808c3e1fbaefa2 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 12:07:07 +0000 Subject: [PATCH 06/16] Test the shared file reader's limits, truncation, JSON fallback and PDF page cap Co-Authored-By: Claude Opus 5.5 --- packages/core/src/source-file-import.test.ts | 71 ++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 packages/core/src/source-file-import.test.ts 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' }); + }); +}); From de1c315a5489b120e5a392ca90c3da60111c263e Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 12:32:49 +0000 Subject: [PATCH 07/16] Shoot the Library, file import and debate screens from fixed rows, and fix what they showed - The harness answers the Library from made-up sources and folders and writes nothing, imports the PDF, DOCX and PNG in scripts/fixtures through the real reader, and shoots Edit settings, the long new-debate form and the five-day check. Debates read as active, so the check stays out of the other shots. A missing browser no longer leaves its server up. - Rows inside a Link keep their layout: the Link's slot drops a style function, so the pressed dim moves to an inner view. - The web header takes its choices from library-options.ts; importing them from library-header resolved to itself on the web. - A chosen folder hides each row's folder badge; the import error is centred; the debate settings card gives its text the full width. - The guide describes file import, folders, toasts, haptics and debates. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 6 +- .../fixtures/congestion-charge-results.pdf | Bin 0 -> 1307 bytes .../scripts/fixtures/rebuttal-notes.docx | Bin 0 -> 1102 bytes .../scripts/fixtures/whiteboard-photo.png | Bin 0 -> 69 bytes apps/mobile/scripts/screenshots.ts | 208 +++++++++++++++++- apps/mobile/src/app/(tabs)/(debate)/index.tsx | 32 +-- .../src/app/(tabs)/(library)/library.tsx | 4 +- apps/mobile/src/app/add-file.tsx | 3 +- apps/mobile/src/app/history.tsx | 26 ++- apps/mobile/src/components/library-header.tsx | 40 +--- .../src/components/library-header.web.tsx | 2 +- apps/mobile/src/components/library-options.ts | 42 ++++ apps/mobile/src/components/source-row.tsx | 69 +++--- apps/mobile/src/components/work-row.tsx | 26 ++- 14 files changed, 341 insertions(+), 117 deletions(-) create mode 100644 apps/mobile/scripts/fixtures/congestion-charge-results.pdf create mode 100644 apps/mobile/scripts/fixtures/rebuttal-notes.docx create mode 100644 apps/mobile/scripts/fixtures/whiteboard-photo.png create mode 100644 apps/mobile/src/components/library-options.ts diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 842c984da..41eb90103 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -21,7 +21,11 @@ The iOS app DebateOwl (`@debatelab/mobile`; the bundle id is still `com.adam.deb - `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; the form sheets `debates.tsx` (switcher), `new-debate.tsx`, `delete-account.tsx`, `delete-data.tsx`, `add-link.tsx`, `new-note.tsx` and `source/[id].tsx`; and `ask.tsx`, `generate.tsx`, `case/[id].tsx`, `attack/`, `brainstorm/`, `factoids/` and `sparring/` (each a form `index.tsx` and a document `[id].tsx`), `crossfire.tsx` and `timer.tsx` (a full-screen modal), `flowchart/` (the screen `index.tsx` with its `card`, `claim` and `maps` form sheets), and `history.tsx`, full screen above the tabs. All are declared in the root `_layout.tsx`. Every Practice tool is in the app, so `(practice)/soon/[tool].tsx` is no longer reached from the Practice tab. - `src/components/`: shared UI. `app-tabs.tsx` uses native tabs; `app-tabs.web.tsx` draws the mock's tab bar for the web preview. - `src/lib/`: auth (`auth.tsx`), `native-capabilities.ts` (what the running binary has beyond Expo Go), the Supabase client, session storage (AES key in SecureStore, ciphertext in AsyncStorage) and i18n. `debates.ts`, `settings.tsx`, `subscription.ts` and `account.ts` read and write the website's tables the way its stores do (`src/lib/stores/debate-store.ts`, `settings-store.ts`, `src/hooks/use-subscription.ts`, `src/lib/account.ts`), so the app and the website share one Current Debate and one set of settings. Change them together with the website code they mirror. -- `sources.ts` mirrors the session store's `addSource` and `removeSource` and `src/lib/source-import.ts`. The Library always shows the Current Debate, also in one-off mode, as on the website. Adding a file (the website parses files in the browser) and Scan pages are not in the app yet. +- `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 and type, reads the picked copy as base64 (`picked-file.ts`; the web preview reads the browser File) and sends it to the reader in 1 MB chunks, 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 with the website's default selection and pins, and `chats.ts` saves finished exchanges to `chats` and `messages`. The SSE parser is shared in `packages/core/src/ai/sse.ts`. `markdown.ts` reads answers, because react-markdown needs the DOM. - Generate and Attack mirror `generateCase` and `attackCase` in `src/lib/ai/client.ts` and the session store's `recordResult`: `results.ts` sends the requests through `ai-request.ts`, checks the output with the website's transform (moved to `packages/core/src/ai/generate-output.ts`), keeps researched sources in the debate's Auto-saved folder (`packages/core/src/ai/discovered-sources.ts`), saves the server's source rankings onto the Library sources a result used (`packages/core/src/ai/source-assessments.ts`, also for Ask's whole answers), says when the server shortened the input, and saves rows to `cases` and `attacks`. `motion.ts` and `motion-gate.ts` are the website's motion gate. `open-results.ts` holds results made in this session, so one-off work and failed saves still open as documents. It and the last Generate and Attack forms live in `session-memory.ts`, which the auth provider clears on every account change, including sign-out. `components/document.tsx` draws results as documents (mock 03); Brainstorm and Factoids reuse it. Export renders `export-html.ts` to a PDF with expo-print and shares it (`export.ts`); the web preview cannot print, so `export.web.ts` keeps the HTML and copied text on `window` for the screenshot script. Word export and Google Docs are not in the app. - Brainstorm and Factoids mirror `brainstorm` and `factoids` in `src/lib/ai/client.ts` and their pages, through the same `results.ts`: the brainstorm transform and the verified-factoid selection live in `packages/core/src/ai/brainstorm-output.ts` and `factoids-output.ts`, rows go to `brainstorms` and `factoids`. Brainstorm has the motion gate; Factoids, like Attack, follows the Current Debate and saves with side `neutral`. A factoid's bookmark saves it to the Current Debate's `Factoids` folder, and a search suggestion reopens the Factoids form with that query. `prepareToolHref` in `components/prepare-tools.ts` is where both tabs open a tool. 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 0000000000000000000000000000000000000000..a54a5dd01cc2dd1c2dac85cea93c0e15706b9635 GIT binary patch literal 1307 zcmcIkO>f&U488kTyi0*@*lIi90g4>bwHUAt3tZod9Tc02)j3ii$xYK=Kg!7xx7lHr z88B>95IjeyU*#>AWD(IET5w5YsC3(Lx}H;_J|*vrEfojF-1HVj`k;CseUG} z3W3=yB3*{)UI*o#^vYN0_mbe?yd|-Y<~akWNc+eoMhV7z=Oxk!^84uW#^@C}uZ%`f z3A-X#a!u{sCrD+T$Bu;kfcy#h$LKP*LP}LYY50ov5lXM{YBWJoU1PZm$Q!Z+F_(ki z9?5S*`B*c_%3U2X=Z(XYX{@FlyjqikT5qg*rZRv{k62t`$hj5_S{Q3pX>123BQI19 zX^fYG3pu22E3;HEo@GRDpu7Wfsufw(^h`Cj%2(`8YE}97<=y5%=!298$@}M$_n7p* zN`Cu7>rGJHLFf`vDCX|mQILm9Nsq0uzsPbM3Ss%#x-RR6uoNYCsWNpL%v+`fY7rr= zS)cksCy6trsX0_Ox?~Y_OLW;d)|yq7EIu@CVNK(M{ClBK?LPwjO^iWDjQtcZ1lJcY zxNM$)5g%uWzf-Bi+`Qm1VZ$ixVLa{qM4XZ1>;-2HThLB<`@-|u2F_+ID`K8f&u9)| z+Oy8|5BdA;4*k#Uq4C}9f!uj^hxnT+9Ug-;8A6E}Q@t?_>(T2JKRye;5nj}071ZOc zJcxJN^%qMrN-&9%b7})l z`W-e9_$!*dPNVQagFx41=F@@}Q}`urPCl~Ayz&qROyb@iV0#>HpM z*jTscvNtvL9c%ME`25$_N``~1&5q*Cnic|wvtC?pchOm8wxMa~lrI{puV>m6Nej1j z^%z#`d_6PSXN9@N#67QfRj*9e5b+8Y-xttK#PM?VYu=(9yzR7D9KQ`{%vv9r6Ptna2)n2V# zf9hNPY;D`e7E!+9e;-XNx^g5&{QJ7S3UawWUh4N3^WP6Z37FY`&0nnr28bpwV7P&F zd{JsnvA!ON>^*PIcgTQ;?ZIl7dzn#eEdiJHI96(Fv2PGEUz;K4b!hQ+b!KK)o}O*> z_0wK|%Sf$17`C&vr?j&k-QINPXRF0$i=+A~$&HOE5$K~8ObiTyKwp*T z7p3T@i(7=G`&i`73&;I6I!%M5AZ-=>V1FcBP*Tk&DhB@?7F@s_@-= z=baq8N;EbFC~4Z+*}eMlVVdxsMHwkprT2e1vwO^QUUGV6b!FYJUyGU7_BDC?RJ_Vt zarf}g9leczXHC?SV0gJ>?yjZRjQE;=v2MORk9*S7v}tcv?JSZsn-amF0*6@-S|8Ovt9rpzDOT~CoSg3lX%4C8lY$SVp9NiwJF>Hh zx6;)1pvmTD{^^>&^w1V9tuu3k@&$Y%xs-oIEOvdJ^!eA3u7!m?O!v%`bPuoH zK9S>K(V^8ZS)$i?FR9&O*%h{9;bi|w)m|HCh}CZipKdQ^FjGtHlI^=5L17(@4LeI( zBQ4%@K4v?5@@xFr_Wpi@KYvr6mj`$=GKnzb&Vay30fPo$EMd)#=mwxCRETy4h6YAS kpb<#P7F{EHT0m%g57dZ}G6K9=*+5E|fp8~~u4e}E04OoEnE(I) literal 0 HcmV?d00001 diff --git a/apps/mobile/scripts/fixtures/whiteboard-photo.png b/apps/mobile/scripts/fixtures/whiteboard-photo.png new file mode 100644 index 0000000000000000000000000000000000000000..94381b429d7f7fe87e1bade52d893ab348ae29cc GIT binary patch literal 69 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryJf1F&Ar*6yfBgS%&%pYR=^yWV Rw;e!n22WQ%mvv4FO#tpo5$gZ| literal 0 HcmV?d00001 diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index 5b19459ed..bea12853a 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -11,7 +11,7 @@ * 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, + * debate-check, library, prepare, practice, you, empty, ask, results, brainstorm, factoids, * 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 +40,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 @@ -162,7 +172,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; @@ -207,6 +217,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(); @@ -224,6 +235,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'); @@ -263,6 +288,46 @@ 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 scrollToEnd(page, 'new-debate-sheet'); + await shoot(page, `${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 scrollToEnd(page, 'edit-debate-sheet'); + await shoot(page, `${prefix}-edit-debate-end`); +} + +/** Scrolls a sheet's own scroll view to its end; the web preview draws sheets as pages. */ +async function scrollToEnd(page: Page, testId: string) { + await page.getByTestId(testId).evaluate((root) => { + for (const element of [root, ...Array.from(root.querySelectorAll('*'))]) { + if (element.scrollHeight > element.clientHeight + 1 && getComputedStyle(element).overflowY !== 'visible') 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) { @@ -746,12 +811,95 @@ 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')) { + 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`); + 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`); + await page.getByTestId('library-add').click(); await page.getByRole('menu').waitFor(); await shoot(page, `${prefix}-library-menu`); @@ -765,11 +913,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[]) { @@ -1604,10 +1786,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) { @@ -1643,14 +1827,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; @@ -1735,7 +1923,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 3da53654e..9d29cf38a 100644 --- a/apps/mobile/src/app/(tabs)/(debate)/index.tsx +++ b/apps/mobile/src/app/(tabs)/(debate)/index.tsx @@ -408,17 +408,21 @@ function RecentSourceRow({ source, first, onPress }: { source: Source; first: bo 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)} + + + + + )} ); } @@ -506,11 +510,11 @@ const styles = StyleSheet.create({ 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: 10 }, - settings: { flexDirection: 'row', alignItems: 'flex-start', gap: 12, borderTopWidth: 1, borderBottomWidth: 1, paddingVertical: 10, marginBottom: 15 }, - settingsText: { flex: 1, gap: 3 }, + 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: { flexDirection: 'row', alignItems: 'center', gap: 5, borderRadius: radius.pill, paddingHorizontal: 11, minHeight: 32 }, + 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 }, diff --git a/apps/mobile/src/app/(tabs)/(library)/library.tsx b/apps/mobile/src/app/(tabs)/(library)/library.tsx index 2af275be9..90f4bee11 100644 --- a/apps/mobile/src/app/(tabs)/(library)/library.tsx +++ b/apps/mobile/src/app/(tabs)/(library)/library.tsx @@ -8,7 +8,8 @@ import { TextButton } from '@/components/buttons'; import { Card } from '@/components/card'; import { Chip } from '@/components/chip'; import { EmptyState } from '@/components/empty-state'; -import { LibraryHeader, type AddSourceKind, type SourceTypeFilter } from '@/components/library-header'; +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'; @@ -358,6 +359,7 @@ function LibraryBody({ library, selectedId, onSelect }: { library: Library; sele first={index === 0} selected={selectedId === source.id} selectInstead={Boolean(onSelect)} + showFolder={folder === ALL} actions={{ onOpen: () => (onSelect ? onSelect(source) : router.push({ pathname: '/source/[id]', params: { id: source.id } })), onToggle: (isActive) => void toggle([source], isActive), diff --git a/apps/mobile/src/app/add-file.tsx b/apps/mobile/src/app/add-file.tsx index 2a736de1d..d23151b5b 100644 --- a/apps/mobile/src/app/add-file.tsx +++ b/apps/mobile/src/app/add-file.tsx @@ -175,7 +175,7 @@ export default function AddFileSheet() { {failure.title} - + {failure.message} @@ -239,6 +239,7 @@ const styles = StyleSheet.create({ 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 }, diff --git a/apps/mobile/src/app/history.tsx b/apps/mobile/src/app/history.tsx index c71daa3f6..49da13873 100644 --- a/apps/mobile/src/app/history.tsx +++ b/apps/mobile/src/app/history.tsx @@ -355,17 +355,21 @@ function HistoryRow({ item, first, onPress, onDelete }: { item: HistoryItem; fir disabled={!onPress} onPress={onPress} testID="history-row" - style={({ pressed }) => [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. diff --git a/apps/mobile/src/components/library-header.tsx b/apps/mobile/src/components/library-header.tsx index 30b51be2f..76e6e40ce 100644 --- a/apps/mobile/src/components/library-header.tsx +++ b/apps/mobile/src/components/library-header.tsx @@ -2,45 +2,11 @@ import type { LibrarySort } from '@debatelab/core/library'; import { Stack } from 'expo-router'; import { useTranslation } from 'react-i18next'; -import { useTheme } from '@/theme/use-theme'; - -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', -}; +import { SORT_LABELS, TYPE_FILTERS, type LibraryHeaderProps } from './library-options'; -/** 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' }, -]; +import { useTheme } from '@/theme/use-theme'; -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; -} +export type { AddSourceKind, LibraryHeaderProps, SourceTypeFilter } from './library-options'; /** * The Library's large title, its round plus button (Paste a link, Write a diff --git a/apps/mobile/src/components/library-header.web.tsx b/apps/mobile/src/components/library-header.web.tsx index 4dd51e1a3..a154bc1b5 100644 --- a/apps/mobile/src/components/library-header.web.tsx +++ b/apps/mobile/src/components/library-header.web.tsx @@ -5,7 +5,7 @@ import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; import { Icon } from './icon'; import type { IconName } from './icon-paths'; -import { SORT_LABELS, TYPE_FILTERS, type AddSourceKind, type LibraryHeaderProps } 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'; 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/source-row.tsx b/apps/mobile/src/components/source-row.tsx index 42ea8cd2c..d055834fe 100644 --- a/apps/mobile/src/components/source-row.tsx +++ b/apps/mobile/src/components/source-row.tsx @@ -24,7 +24,7 @@ export interface SourceRowActions { } /** The website's quality badges: used in a case, importance and strength. */ -function QualityBadges({ source }: { source: Source }) { +function QualityBadges({ source, showFolder }: { source: Source; showFolder: boolean }) { const { t } = useTranslation(); const { colors } = useTheme(); const semantic = source.metadata?.semantic; @@ -39,12 +39,14 @@ function QualityBadges({ source }: { source: Source }) { ); return ( - - - - {folderLabel(t, source.folder)} - - + {showFolder ? ( + + + + {folderLabel(t, source.folder)} + + + ) : null} {semantic?.lastUsedInCase ? badge('used', t('sourceQuality.usedInCase'), true) : null} {semantic ? badge('importance', t('sourceQuality.importance', { level: t(`sourceQuality.levels.${semantic.importance}`) })) : null} {semantic ? badge('strength', t('sourceQuality.strength', { level: t(`sourceQuality.levels.${semantic.strength}`) })) : null} @@ -64,6 +66,7 @@ export function SourceRow({ first, selected = false, selectInstead = false, + showFolder = true, actions, }: { source: Source; @@ -71,6 +74,8 @@ export function SourceRow({ first: boolean; selected?: boolean; selectInstead?: boolean; + /** Off while the list shows one folder. */ + showFolder?: boolean; actions: SourceRowActions; }) { const { t } = useTranslation(); @@ -143,29 +148,33 @@ export function SourceRow({ if (event.nativeEvent.actionName === 'delete') void remove(); }} testID="source-row" - style={({ pressed }) => [styles.row, { backgroundColor: selected ? colors.tile : colors.card }, pressed && styles.pressed]}> - - - - - - {title} - - - {source.isActive ? sourceMeta(t, source) : `${t('mobile.library.turnedOff')} · ${sourceMeta(t, source)}`} - - - - 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} - /> + style={{ backgroundColor: selected ? colors.tile : colors.card }}> + {({ pressed }) => ( + + + + + + + {title} + + + {source.isActive ? sourceMeta(t, source) : `${t('mobile.library.turnedOff')} · ${sourceMeta(t, source)}`} + + + + 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} + /> + + )} diff --git a/apps/mobile/src/components/work-row.tsx b/apps/mobile/src/components/work-row.tsx index 791a44330..2f75946cd 100644 --- a/apps/mobile/src/components/work-row.tsx +++ b/apps/mobile/src/components/work-row.tsx @@ -79,17 +79,21 @@ export function WorkRow({ accessibilityLabel={t('mobile.common.openItem', { title })} onPress={onPress} testID="work-row" - style={({ pressed }) => [styles.row, !first && { borderTopWidth: 1, borderTopColor: colors.line }, { backgroundColor: colors.card }, pressed && styles.pressed]}> - - - - {title} - - - {t('mobile.debate.itemMeta', { kind: t(`debate.kinds.${item.kind}`), when: formatWhen(item.createdAt) })} - - - + style={[!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) })} + + + + + )} ); // Practice work opens the Practice tab, which has nothing to preview. From cd9774ac57f3892dbd80ee31710a5c928cc57b54 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 12:40:13 +0000 Subject: [PATCH 08/16] Flatten a linked row's style for the web, and close a source opened with nothing under it Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 2 +- apps/mobile/src/app/source/[id].tsx | 3 ++- apps/mobile/src/components/work-row.tsx | 3 ++- knip.json | 3 ++- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 41eb90103..4a69d4426 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -59,4 +59,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/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/work-row.tsx b/apps/mobile/src/components/work-row.tsx index 2f75946cd..fb608423f 100644 --- a/apps/mobile/src/components/work-row.tsx +++ b/apps/mobile/src/components/work-row.tsx @@ -79,7 +79,8 @@ export function WorkRow({ accessibilityLabel={t('mobile.common.openItem', { title })} onPress={onPress} testID="work-row" - style={[!first && { borderTopWidth: 1, borderTopColor: colors.line }, { backgroundColor: colors.card }]}> + // 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 }) => ( 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" ] } }, From c78db6ce6bc1407a25d9c6c816fa9c1ab9d8a333 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 12:53:32 +0000 Subject: [PATCH 09/16] Keep Hungarian source text whole beside an open source on iPad Rows next to an open source show only the folder and its use (the pane shows the rankings), and source meta lines and badges wrap instead of being cut off. The harness scrolls a sheet with whatever scrolls it. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/scripts/screenshots.ts | 7 +++++-- apps/mobile/src/app/(tabs)/(library)/library.tsx | 1 + apps/mobile/src/components/source-row.tsx | 15 +++++++++------ apps/mobile/src/components/source-view.tsx | 2 +- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index bea12853a..5b6b4e49b 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -303,9 +303,12 @@ async function shootDebateScreens(page: Page, prefix: string) { /** 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) => { - for (const element of [root, ...Array.from(root.querySelectorAll('*'))]) { - if (element.scrollHeight > element.clientHeight + 1 && getComputedStyle(element).overflowY !== 'visible') element.scrollTo({ top: element.scrollHeight }); + 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); diff --git a/apps/mobile/src/app/(tabs)/(library)/library.tsx b/apps/mobile/src/app/(tabs)/(library)/library.tsx index 90f4bee11..939b34f92 100644 --- a/apps/mobile/src/app/(tabs)/(library)/library.tsx +++ b/apps/mobile/src/app/(tabs)/(library)/library.tsx @@ -360,6 +360,7 @@ function LibraryBody({ library, selectedId, onSelect }: { library: Library; sele selected={selectedId === source.id} 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), diff --git a/apps/mobile/src/components/source-row.tsx b/apps/mobile/src/components/source-row.tsx index d055834fe..eeeddcc04 100644 --- a/apps/mobile/src/components/source-row.tsx +++ b/apps/mobile/src/components/source-row.tsx @@ -24,7 +24,7 @@ export interface SourceRowActions { } /** The website's quality badges: used in a case, importance and strength. */ -function QualityBadges({ source, showFolder }: { source: Source; showFolder: boolean }) { +function QualityBadges({ source, showFolder, compact }: { source: Source; showFolder: boolean; compact: boolean }) { const { t } = useTranslation(); const { colors } = useTheme(); const semantic = source.metadata?.semantic; @@ -32,7 +32,7 @@ function QualityBadges({ source, showFolder }: { source: Source; showFolder: boo - + {label} @@ -48,8 +48,8 @@ function QualityBadges({ source, showFolder }: { source: Source; showFolder: boo ) : null} {semantic?.lastUsedInCase ? badge('used', t('sourceQuality.usedInCase'), true) : null} - {semantic ? badge('importance', t('sourceQuality.importance', { level: t(`sourceQuality.levels.${semantic.importance}`) })) : null} - {semantic ? badge('strength', t('sourceQuality.strength', { level: t(`sourceQuality.levels.${semantic.strength}`) })) : 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} ); } @@ -67,6 +67,7 @@ export function SourceRow({ selected = false, selectInstead = false, showFolder = true, + compact = false, actions, }: { source: Source; @@ -76,6 +77,8 @@ export function SourceRow({ 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(); @@ -158,10 +161,10 @@ export function SourceRow({ {title} - + {source.isActive ? sourceMeta(t, source) : `${t('mobile.library.turnedOff')} · ${sourceMeta(t, source)}`} - + - + {sourceMeta(t, source)} From a689f67a3289f4d65b33bac50333b93da983a8d1 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 13:32:17 +0000 Subject: [PATCH 10/16] Shoot the end of a long sheet with a window tall enough for it Co-Authored-By: Claude Opus 5.5 --- apps/mobile/scripts/screenshots.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index 5b6b4e49b..f9ce2312d 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -288,8 +288,7 @@ 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 scrollToEnd(page, 'new-debate-sheet'); - await shoot(page, `${prefix}-new-debate-end`); + 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}/`); @@ -297,8 +296,22 @@ async function shootDebateScreens(page: Page, prefix: string) { await page.getByTestId('edit-debate-sheet').waitFor(); await page.waitForTimeout(700); await shoot(page, `${prefix}-edit-debate`); - await scrollToEnd(page, 'edit-debate-sheet'); - await shoot(page, `${prefix}-edit-debate-end`); + 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. */ From 0e4b0fb086707b6221dd2c0fe4b44dfcda190685 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 13:52:26 +0000 Subject: [PATCH 11/16] Keep a new or renamed folder chosen, and keep the layout's diff small - The Library reset its folder chip to All folders when the folder list had not reloaded yet, so New folder and Rename lost the selection. The check now runs only when the folder list itself reloads; the harness creates a folder through the prompt and checks it stays chosen. - ToastProvider holds the toast and ToastHost draws it, so the root layout gains two lines instead of reindenting its whole Stack. - A switch taps once: the haptic sits in the controls that lack one. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/scripts/screenshots.ts | 14 +++ .../src/app/(tabs)/(library)/library.tsx | 10 +- apps/mobile/src/app/_layout.tsx | 91 ++++++++++--------- apps/mobile/src/components/library-parts.tsx | 6 +- apps/mobile/src/components/source-row.tsx | 7 +- apps/mobile/src/components/toast.tsx | 17 +++- apps/mobile/src/lib/source-actions.ts | 1 - 7 files changed, 90 insertions(+), 56 deletions(-) diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index f9ce2312d..b3630cd1a 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -865,6 +865,10 @@ async function fakeLibrary(page: Page) { 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); @@ -916,6 +920,16 @@ async function shootLibraryScreens(page: Page, prefix: string, problems: string[ 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`); diff --git a/apps/mobile/src/app/(tabs)/(library)/library.tsx b/apps/mobile/src/app/(tabs)/(library)/library.tsx index 939b34f92..d3ba32a63 100644 --- a/apps/mobile/src/app/(tabs)/(library)/library.tsx +++ b/apps/mobile/src/app/(tabs)/(library)/library.tsx @@ -219,10 +219,14 @@ function useLibrary(debateId: string | null) { ); useEffect(() => subscribeSourcesChanged(() => void load()), [load]); - // A folder that no longer exists here (renamed, deleted, or only another debate's) shows all folders again. + // 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 (folder !== ALL && !folders.includes(folder) && !(sources ?? []).some((source) => source.folder === folder)) setFolder(ALL); - }, [folder, folders, sources]); + if (chosen.current !== ALL && !folders.includes(chosen.current)) setFolder(ALL); + }, [folders]); const inFolder = useMemo( () => (sources ?? []).filter((source) => folder === ALL || normalizeFolder(source.folder) === folder), diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 3ba362c81..5b6aa5ebb 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -9,7 +9,7 @@ import { useEffect } from 'react'; import { StyleSheet } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; -import { ToastProvider } from '@/components/toast'; +import { ToastHost, ToastProvider } from '@/components/toast'; import { AuthProvider, useAuth } from '@/lib/auth'; import { DebateProvider } from '@/lib/debate-context'; import { watchNetwork } from '@/lib/network-status'; @@ -25,7 +25,9 @@ export default function RootLayout() { - + + + @@ -85,49 +87,48 @@ function Root() { {/* The screenshot script waits for auth-settled before it checks an offline cold start. Swipeable rows need the gesture root. */} - - - - - - - - - - - - - - - {/* Full screen above the tabs, as in mock 07. */} - - {/* History, full screen above the tabs like the documents it opens. */} - - {/* Generate, Attack, Brainstorm, Factoids and their documents, full screen above the tabs, as in mock 03. */} - - - - - - - - - {/* Flowchart full screen above the tabs, its card, claim and saved-map sheets as form sheets, as in mock A. */} - - - - - {/* Live practice screens, full screen with no tab bar, as the live round in mock 05. */} - - - - - - - - - - + + + + + + + + + + + + + + {/* Full screen above the tabs, as in mock 07. */} + + {/* History, full screen above the tabs like the documents it opens. */} + + {/* Generate, Attack, Brainstorm, Factoids and their documents, full screen above the tabs, as in mock 03. */} + + + + + + + + + {/* Flowchart full screen above the tabs, its card, claim and saved-map sheets as form sheets, as in mock A. */} + + + + + {/* Live practice screens, full screen with no tab bar, as the live round in mock 05. */} + + + + + + + + + + ); diff --git a/apps/mobile/src/components/library-parts.tsx b/apps/mobile/src/components/library-parts.tsx index 20d95633e..12f07dc68 100644 --- a/apps/mobile/src/components/library-parts.tsx +++ b/apps/mobile/src/components/library-parts.tsx @@ -9,6 +9,7 @@ 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'; @@ -79,7 +80,10 @@ export function FolderBar({ { + haptics.tap(); + onSetActive(value); + }} accessibilityLabel={`${t('libraryPage.useForGrounding')}: ${name}`} trackColor={{ true: colors.primaryFill, false: colors.segmentTrack }} ios_backgroundColor={colors.segmentTrack} diff --git a/apps/mobile/src/components/source-row.tsx b/apps/mobile/src/components/source-row.tsx index eeeddcc04..f90b3101f 100644 --- a/apps/mobile/src/components/source-row.tsx +++ b/apps/mobile/src/components/source-row.tsx @@ -10,6 +10,7 @@ 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'; @@ -93,6 +94,7 @@ export function SourceRow({ }; const toggle = () => { swipeable.current?.close(); + haptics.tap(); actions.onToggle(!source.isActive); }; @@ -168,7 +170,10 @@ export function SourceRow({ actions.onToggle(value)} + onValueChange={(value) => { + haptics.tap(); + actions.onToggle(value); + }} accessibilityLabel={t('libraryPage.toggleSourceLabel', { title })} trackColor={{ true: colors.primaryFill, false: colors.segmentTrack }} ios_backgroundColor={colors.segmentTrack} diff --git a/apps/mobile/src/components/toast.tsx b/apps/mobile/src/components/toast.tsx index c8c294e31..b9314bfab 100644 --- a/apps/mobile/src/components/toast.tsx +++ b/apps/mobile/src/components/toast.tsx @@ -24,6 +24,7 @@ interface ShownToast extends ToastOptions { } 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 { @@ -39,7 +40,8 @@ const TAB_BAR = 56; let ids = 0; /** - * The app's toasts. Form sheets are drawn above the root view, so a sheet + * 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 }) { @@ -62,15 +64,20 @@ export function ToastProvider({ children }: { children: ReactNode }) { }; }, [toast]); - const value = useMemo(() => show, [show]); + const shown = useMemo(() => ({ toast, close: () => setToast(null) }), [toast]); return ( - - {children} - {toast ? setToast(null)} /> : null} + + {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(); diff --git a/apps/mobile/src/lib/source-actions.ts b/apps/mobile/src/lib/source-actions.ts index 9110c3c56..5380424c3 100644 --- a/apps/mobile/src/lib/source-actions.ts +++ b/apps/mobile/src/lib/source-actions.ts @@ -74,7 +74,6 @@ export function useSourceActions() { const debateId = sources[0]?.debateId; if (!userId || !debateId) return false; if (refuseOffline(t('libraryPage.offlineTitle'))) return false; - haptics.tap(); try { await setSourcesActive(userId, debateId, sources.map((source) => source.id), isActive); return true; From 029d7314b5d02c2c5e5b19476d2b9f8c1c8b798d Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 14:15:16 +0000 Subject: [PATCH 12/16] Open Crossfire and chat rows again on the Debate tab and Prepare A row whose work opens the Practice tab has no Link, so it navigates itself; linked rows still navigate only through their Link. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/components/work-row.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/components/work-row.tsx b/apps/mobile/src/components/work-row.tsx index fb608423f..bf2effa8f 100644 --- a/apps/mobile/src/components/work-row.tsx +++ b/apps/mobile/src/components/work-row.tsx @@ -1,4 +1,4 @@ -import { Link, 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'; @@ -70,14 +70,17 @@ export function WorkRow({ }) { const { t } = useTranslation(); const { colors } = useTheme(); + 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" // 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 }])}> @@ -97,8 +100,7 @@ export function WorkRow({ )} ); - // Practice work opens the Practice tab, which has nothing to preview. - if (!href || href === '/practice') return row; + if (!linked) return row; const document = hasDocumentText(item); return ( From 6d906cdba8af79afc7f9d424d73c40893fcd00c5 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 14:15:16 +0000 Subject: [PATCH 13/16] Read a picked file in slices, after a real size check, and never hang - When the picker gives no size, the file's own size is checked against the 8 MB limit before any of it is read into JavaScript. - The reader pulls 768 KB slices one at a time into one buffer, read natively as base64, so at most one slice is in flight. - A read always ends: a two-minute timeout covers a webview that never loads (the website's "Could not read this file."), a new file cancels the read before it, and closing the sheet stops the reader. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/add-file.tsx | 3 + .../src/components/source-file-reader.tsx | 45 +++++++----- apps/mobile/src/lib/picked-file.ts | 12 +++- apps/mobile/src/lib/picked-file.web.ts | 11 ++- apps/mobile/src/lib/source-file.tsx | 70 ++++++++++++------- 5 files changed, 90 insertions(+), 51 deletions(-) diff --git a/apps/mobile/src/app/add-file.tsx b/apps/mobile/src/app/add-file.tsx index d23151b5b..b6b8177d7 100644 --- a/apps/mobile/src/app/add-file.tsx +++ b/apps/mobile/src/app/add-file.tsx @@ -37,6 +37,7 @@ function failureText(t: TFunction, failure: Failure): { title: string; message: 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, @@ -95,6 +96,8 @@ export default function AddFileSheet() { 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(); diff --git a/apps/mobile/src/components/source-file-reader.tsx b/apps/mobile/src/components/source-file-reader.tsx index 98b80ff6f..0b50beab1 100644 --- a/apps/mobile/src/components/source-file-reader.tsx +++ b/apps/mobile/src/components/source-file-reader.tsx @@ -12,7 +12,7 @@ import { type SourceFileImportResult, } from '@debatelab/core/source-file-import'; -/** A file the native side picked, sent in base64 chunks so no single message carries 8 MB. */ +/** A file the native side picked, sent in base64 slices so no single message carries 8 MB. */ export interface SourceFileJob { id: string; name: string; @@ -23,7 +23,7 @@ export interface SourceFileJob { export type SourceFileOutcome = | { ok: true; result: SourceFileImportResult } - | { ok: false; code: SourceFileImportError['code']; fileType?: SourceFileImportError['fileType']; message: string }; + | { ok: false; code: SourceFileImportError['code'] | 'timeout' | 'cancelled'; fileType?: SourceFileImportError['fileType']; message: string }; interface Props { job: SourceFileJob | null; @@ -32,20 +32,11 @@ interface Props { dom?: import('expo/dom').DOMProps; } -function decodeBase64(chunks: string[]): ArrayBuffer { - const parts = chunks.map((chunk) => { - 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; - }); - const out = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)); - let offset = 0; - for (const part of parts) { - out.set(part, offset); - offset += part.length; - } - return out.buffer; +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. @@ -66,9 +57,22 @@ const readers = { 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 ??= Promise.all(Array.from({ length: job.chunks }, (_, index) => readChunk(job.id, index))).then(decodeBase64); + 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( @@ -81,13 +85,18 @@ export default function SourceFileReader({ job, readChunk, onDone }: Props) { }, readers, ) - .then((result) => onDone(job.id, { ok: true, result })) + .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]); diff --git a/apps/mobile/src/lib/picked-file.ts b/apps/mobile/src/lib/picked-file.ts index 75b19c079..2f55c6199 100644 --- a/apps/mobile/src/lib/picked-file.ts +++ b/apps/mobile/src/lib/picked-file.ts @@ -1,7 +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 bytes as base64, read from the copy the picker left in the app's cache. */ -export function readPickedFile(asset: DocumentPickerAsset): Promise { - return new File(asset.uri).base64(); +/** 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 index c635f1288..66ec0ca1c 100644 --- a/apps/mobile/src/lib/picked-file.web.ts +++ b/apps/mobile/src/lib/picked-file.web.ts @@ -1,9 +1,14 @@ import type { DocumentPickerAsset } from 'expo-document-picker'; -/** The web preview's picker hands back a browser File; its bytes as base64. */ -export async function readPickedFile(asset: DocumentPickerAsset): Promise { +/** 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.arrayBuffer()); + 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-file.tsx b/apps/mobile/src/lib/source-file.tsx index ba54616ae..ce10433fd 100644 --- a/apps/mobile/src/lib/source-file.tsx +++ b/apps/mobile/src/lib/source-file.tsx @@ -1,15 +1,17 @@ import { checkSourceFile, SourceFileImportError } from '@debatelab/core/source-file-import'; import type { DocumentPickerAsset } from 'expo-document-picker'; -import { useCallback, useRef, useState, type ReactNode } from 'react'; +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 { readPickedFile } from './picked-file'; +import { pickedFileSize, readPickedChunk } from './picked-file'; export type { SourceFileOutcome }; -/** Base64 characters per message to the reader: a multiple of 4, so every chunk decodes alone. */ -const CHUNK_CHARS = 1_048_576; +/** 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 @@ -45,44 +47,58 @@ export function pickedFile(id: string | undefined): DocumentPickerAsset | 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 the file is read, as the website does. + * 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 pending = useRef(new Map void }>()); + 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 file = { name: asset.name, type: asset.mimeType ?? '', size: asset.size ?? 0 }; + const type = asset.mimeType ?? ''; + let size: number; try { - checkSourceFile(file); + size = pickedFileSize(asset); + checkSourceFile({ name: asset.name, type, size }); } catch (error) { if (error instanceof SourceFileImportError) return { ok: false, code: error.code, message: error.message }; - throw error; - } - let base64: string; - try { - base64 = await readPickedFile(asset); - } catch (error) { return { ok: false, code: 'parse-failed', message: error instanceof Error ? error.message : String(error) }; } - const chunks: string[] = []; - for (let start = 0; start < base64.length; start += CHUNK_CHARS) chunks.push(base64.slice(start, start + CHUNK_CHARS)); - // The picker's size can be missing; the bytes read decide. - const size = Math.floor((base64.length * 3) / 4) - (base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0); + // 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) => { - pending.current.set(id, { chunks, resolve }); - setJob({ id, name: asset.name, type: file.type, size, chunks: chunks.length }); + 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) => pending.current.get(id)?.chunks[index] ?? '', []); - const onDone = useCallback(async (id: string, outcome: SourceFileOutcome) => { - const entry = pending.current.get(id); - pending.current.delete(id); - setJob((current) => (current?.id === id ? null : current)); - entry?.resolve(outcome); + 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; From 7de2b134c45af3a84898e3d701104818dfe1f165 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 14:15:16 +0000 Subject: [PATCH 14/16] Let the newest switch change decide, and keep the switch out of the row's link - A failed write rolls a source back only if no newer tap replaced it, and then to the last value the server accepted. - Other screens reload once the switch queue is idle. - A mixed folder's switch shows on, so one tap turns it all off; the folder menu has the website's Activate and Deactivate, for General too. - The switch sits beside the row's link, so tapping it never opens the source; the harness checks that. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/scripts/screenshots.ts | 8 + .../src/app/(tabs)/(library)/library.tsx | 22 ++- apps/mobile/src/components/library-parts.tsx | 50 ++++-- apps/mobile/src/components/source-row.tsx | 166 +++++++++--------- apps/mobile/src/lib/sources.ts | 6 + 5 files changed, 150 insertions(+), 102 deletions(-) diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index b3630cd1a..c1e30a9f8 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -911,6 +911,14 @@ async function shootLibraryScreens(page: Page, prefix: string, problems: string[ 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`); diff --git a/apps/mobile/src/app/(tabs)/(library)/library.tsx b/apps/mobile/src/app/(tabs)/(library)/library.tsx index d3ba32a63..59088805b 100644 --- a/apps/mobile/src/app/(tabs)/(library)/library.tsx +++ b/apps/mobile/src/app/(tabs)/(library)/library.tsx @@ -250,6 +250,7 @@ function LibraryBody({ library, selectedId, onSelect }: { library: Library; sele 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) { @@ -267,15 +268,28 @@ function LibraryBody({ library, selectedId, onSelect }: { library: Library; sele 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); - const before = new Map(targets.map((source) => [source.id, source.isActive])); + 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); - if (!saved) { - // Back to how it was, as on the website. - library.setSources((previous) => previous?.map((source) => (before.has(source.id) ? { ...source, isActive: before.get(source.id)! } : source)) ?? previous); + 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) => { diff --git a/apps/mobile/src/components/library-parts.tsx b/apps/mobile/src/components/library-parts.tsx index 12f07dc68..ac3e91714 100644 --- a/apps/mobile/src/components/library-parts.tsx +++ b/apps/mobile/src/components/library-parts.tsx @@ -40,9 +40,11 @@ export function GroundingBanner({ active }: { active: boolean }) { } /** - * The website's Folder actions for the folder picked in the chips: one switch - * turns every source in it on or off for the AI, and the menu renames - * (or merges) and deletes it. General can only be switched. + * 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, @@ -61,7 +63,7 @@ export function FolderBar({ const { colors } = useTheme(); const [menu, setMenu] = useState(false); const active = sources.filter((source) => source.isActive).length; - const allOn = sources.length > 0 && active === sources.length; + const anyOn = active > 0; const editable = folder !== GENERAL_FOLDER; const name = folderLabel(t, folder); return ( @@ -78,7 +80,7 @@ export function FolderBar({ { haptics.tap(); @@ -91,27 +93,37 @@ export function FolderBar({ testID="folder-active" {...WEB_SWITCH_THUMB} /> - {editable ? ( - setMenu(true)} - testID="folder-menu" - style={({ pressed }) => [styles.more, { backgroundColor: colors.segmentTrack }, pressed && styles.pressed]}> - - - ) : null} + setMenu(true)} + testID="folder-menu" + style={({ pressed }) => [styles.more, { backgroundColor: colors.segmentTrack }, pressed && styles.pressed]}> + + setMenu(false)} - onSelect={(key) => (key === 'rename' ? onRename() : onDelete())} + onSelect={(key) => { + if (key === 'on' || key === 'off') { + haptics.tap(); + onSetActive(key === 'on'); + } else if (key === 'rename') onRename(); + else onDelete(); + }} /> ); diff --git a/apps/mobile/src/components/source-row.tsx b/apps/mobile/src/components/source-row.tsx index f90b3101f..fc3b0cf39 100644 --- a/apps/mobile/src/components/source-row.tsx +++ b/apps/mobile/src/components/source-row.tsx @@ -131,92 +131,100 @@ export function SourceRow({ )}> - { - if (!selectInstead) return; - event.preventDefault(); - actions.onOpen(); - }}> - - { - if (event.nativeEvent.actionName === 'toggle') toggle(); - if (event.nativeEvent.actionName === 'delete') void remove(); - }} - testID="source-row" - style={{ backgroundColor: selected ? colors.tile : colors.card }}> - {({ pressed }) => ( - - - - - - - {title} - - - {source.isActive ? sourceMeta(t, source) : `${t('mobile.library.turnedOff')} · ${sourceMeta(t, source)}`} - - + {/* 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)}`} + + + - { - 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} - /> - - )} - - - - - - {t('mobile.library.open')} - - - {toggleLabel} - - - {folders.map((folder) => ( - actions.onMove(folder)}> - {folderLabel(t, folder)} - - ))} + )} + + + + + + {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')} + - - {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', gap: 13, paddingVertical: 12, paddingHorizontal: 14 }, + 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 }, diff --git a/apps/mobile/src/lib/sources.ts b/apps/mobile/src/lib/sources.ts index 783204f5b..c4690ea60 100644 --- a/apps/mobile/src/lib/sources.ts +++ b/apps/mobile/src/lib/sources.ts @@ -340,6 +340,8 @@ export async function deleteFolder(userId: string, debateId: string, name: strin // 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 @@ -347,10 +349,14 @@ const activeWrites: SerialQueue = createSerialQueue(); */ 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(); }); } From a540b1b3c4478185df95a8c7392740f182618e4c Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 14:15:16 +0000 Subject: [PATCH 15/16] Website: keep the app's judge when Edit settings saves, and match legacy folder names DebateForm saved only format and role, which erased settings.judge set in the iOS app; it now keeps the settings it does not show. The Library filters by the trimmed folder name its chips show. Co-Authored-By: Claude Opus 5.5 --- src/components/features/debate/DebateForm.tsx | 3 ++- src/pages/LibraryPage.tsx | 5 +++-- src/test/debate/debate-form.test.tsx | 21 +++++++++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 src/test/debate/debate-form.test.tsx 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/pages/LibraryPage.tsx b/src/pages/LibraryPage.tsx index 563696c91..77aa9d6ce 100644 --- a/src/pages/LibraryPage.tsx +++ b/src/pages/LibraryPage.tsx @@ -19,7 +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, sortLibrarySources, type LibrarySort } from '@debatelab/core/library'; +import { libraryFolders, matchesLibrarySearch, normalizeFolder, sortLibrarySources, type LibrarySort } from '@debatelab/core/library'; import { AlertDialog, AlertDialogAction, @@ -467,7 +467,8 @@ export default function LibraryPage() { }, [folderKey, selectedFolder]); const visibleSources = sortLibrarySources( sourceLibrary - .filter((source) => selectedFolder === 'all' || (source.folder || 'General') === selectedFolder) + // 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, ); 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' }); + }); +}); From a6c5e50113400e714e5dfb59c4818c8e28a9cbc9 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 14:15:29 +0000 Subject: [PATCH 16/16] Describe the sliced file read in the app's guide Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 4a69d4426..4243bc6bf 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -22,7 +22,7 @@ The iOS app DebateOwl (`@debatelab/mobile`; the bundle id is still `com.adam.deb - `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 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 and type, reads the picked copy as base64 (`picked-file.ts`; the web preview reads the browser File) and sends it to the reader in 1 MB chunks, 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. +- 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).