diff --git a/apps/mobile/.eas/build/sideload-ios.yml b/apps/mobile/.eas/build/sideload-ios.yml new file mode 100644 index 000000000..a03e96b32 --- /dev/null +++ b/apps/mobile/.eas/build/sideload-ios.yml @@ -0,0 +1,86 @@ +# The `sideload` profile in eas.json: a Release build for iPhone and iPad that +# no Apple team has signed, packaged as an IPA that a free Apple ID re-signs in +# Sideloadly or AltStore. EAS cannot export an unsigned IPA (every export +# method signs), so this archives with signing off and packs Payload/ itself. +# Each bundle is then signed ad hoc ("-") with the entitlements prebuild wrote, +# so a re-signer can read the App Group from the IPA; app.config.ts keeps +# nothing else in a release build without DEBATELAB_APPLE_ACCOUNT. +# See docs/development.md#sideloaded-ipa. +build: + name: Unsigned iOS device build for sideloading + steps: + - eas/checkout + - eas/install_node_modules + - eas/resolve_build_config + # Custom builds skip npm's eas-build-* hooks, so the patches run here: + # before prebuild, which copies the share extension's template, and again + # after it, because prebuild may reinstall node_modules. + - run: + name: Patch native modules before prebuild + command: npm run eas-build-post-install + - eas/prebuild + - run: + name: Patch native modules after prebuild + command: | + npm run eas-build-post-install + grep -l ALTAppGroups ios/DebateOwlShare/*.swift + - run: + name: Install pods + working_directory: ./ios + command: | + export LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 + pod install + - run: + name: Archive without signing + command: | + set -eo pipefail + mkdir -p ios/build/logs + LOG=ios/build/logs/xcodebuild.log + if ! xcodebuild archive \ + -workspace ios/DebateOwl.xcworkspace \ + -scheme DebateOwl \ + -configuration Release \ + -sdk iphoneos \ + -destination 'generic/platform=iOS' \ + -archivePath ios/build/DebateOwl.xcarchive \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" \ + > "$LOG" 2>&1; then + grep -nE 'error:|\*\* ARCHIVE FAILED' "$LOG" | head -60 || true + tail -80 "$LOG" + exit 1 + fi + tail -5 "$LOG" + - run: + name: Sign ad hoc and pack the IPA + command: | + set -eo pipefail + OUT=ios/build/Payload + rm -rf "$OUT" ios/build/*.ipa + mkdir -p "$OUT" + cp -R ios/build/DebateOwl.xcarchive/Products/Applications/DebateOwl.app "$OUT/" + APP="$OUT/DebateOwl.app" + sign() { codesign --force --sign - --timestamp=none --generate-entitlement-der "$@"; } + # Frameworks and dylibs first, then each extension, then the app. + find "$APP" -depth \( -name '*.framework' -o -name '*.dylib' \) -print0 | while IFS= read -r -d '' f; do sign "$f"; done + entitlements_for() { + case "$1" in + ExpoWidgetsTarget) echo ios/ExpoWidgetsTarget/ExpoWidgetsTarget.entitlements ;; + DebateOwlShare) echo ios/DebateOwlShare/ShareExtension.entitlements ;; + *) echo "No entitlements known for the extension $1; add it to sideload-ios.yml." >&2; return 1 ;; + esac + } + for x in "$APP"/PlugIns/*.appex; do + [ -e "$x" ] || continue + ENT=$(entitlements_for "$(basename "$x" .appex)") + sign --entitlements "$ENT" "$x" + done + sign --entitlements ios/DebateOwl/DebateOwl.entitlements "$APP" + echo "Extensions: $(ls "$APP/PlugIns" 2>/dev/null | tr '\n' ' ')" + for b in "$APP" "$APP"/PlugIns/*.appex; do + echo "== $(basename "$b")" + codesign -d --entitlements - --xml "$b" 2>/dev/null | plutil -p - || true + done + /usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' -c 'Print :CFBundleShortVersionString' -c 'Print :CFBundleVersion' -c 'Print :MinimumOSVersion' "$APP/Info.plist" + (cd ios/build && zip -qry DebateOwl-sideload.ipa Payload) + ls -la ios/build/*.ipa + - eas/find_and_upload_build_artifacts diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 43db914e2..9cf8c0439 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -12,43 +12,46 @@ The iOS app DebateOwl (`@debatelab/mobile`; bundle id `com.pistack.debateowl`, E - Strings come from the website's catalogs in `src/locales/{en,es,hu}.json`. App-only strings go under `mobile.*`, in all three languages. - The Supabase project URLs and anon keys live once, in `src/lib/supabase/projects.ts` at the repo root; `src/lib/supabase-config.ts` imports them. The app uses staging unless a build sets `EXPO_PUBLIC_SUPABASE_ENV=production`. - Sign-in is email code and demo code everywhere; Google adds itself in development builds and Sign in with Apple in builds signed by DebateOwl's own Apple account (`social-sign-in.ts`, below). Expo Go cannot receive OAuth redirects. -- What needs DebateOwl's own Apple account (Sign in with Apple, push, Time Sensitive alerts, Handoff from Safari) turns on with `DEBATELAB_APPLE_ACCOUNT=1` in a development build. [docs/apple-platform.md](../../docs/apple-platform.md) is the account checklist and the map of the Apple integrations. +- What needs DebateOwl's own Apple account (Sign in with Apple, push, Time Sensitive alerts, Handoff from Safari) turns on with `DEBATELAB_APPLE_ACCOUNT=1` in a development or release build. [docs/apple-platform.md](../../docs/apple-platform.md) is the account checklist and the map of the Apple integrations. - Native code has no DOM. eslint rejects `window`, `document`, `localStorage`, `sessionStorage` and `location` outside `*.web.ts(x)` files. A `.web.tsx` file exists only for the web preview. -- `ios/` and `android/` are generated and gitignored. Configure native behaviour in `app.config.ts`; without `DEBATELAB_BUILD` it must stay exactly the Expo Go config (compare `npx expo config --json` before and after). `eas.json` holds the one `development` profile; signing comes from a gitignored `credentials.json` written on build day. -- A form sheet's root view is `SheetScrollView` (`src/components/sheet.tsx`), with nothing around it: no `KeyboardAvoidingView`, no wrapping `View`. react-native-screens positions only a scroll view that is the sheet's direct child; a nested one lands below the visible sheet and the sheet looks empty. +- `ios/` and `android/` are generated and gitignored. Configure native behaviour in `app.config.ts`; without `DEBATELAB_BUILD` it must stay exactly the Expo Go config (compare `npx expo config --json` before and after). `eas.json` holds the `development` profile (signing comes from a gitignored `credentials.json` written on build day), `development-simulator`, and the release profiles: `release-simulator` and `sideload`, the unsigned production IPA built by the custom build in `.eas/build/sideload-ios.yml` ([docs/development.md](../../docs/development.md#sideloaded-ipa)). +- A form sheet's root scroll view is `SheetScrollView`, or `SheetList` for a virtualized list (`src/components/sheet.tsx`), with nothing around it: no `KeyboardAvoidingView`, no wrapping `View`. react-native-screens positions only a scroll view that is the sheet's direct child; a nested one lands below the visible sheet and the sheet looks empty. ## Layout -- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; the form sheets `debates.tsx` (switcher), `new-debate.tsx`, `delete-account.tsx`, `delete-data.tsx`, `add-link.tsx`, `new-note.tsx`, `add-file.tsx` (Import file), `source/[id].tsx`, `edit-debate.tsx` (Edit settings), `debate-check.tsx` (the five-day check), `limit.tsx` (the website's limit-reached and model-restricted dialogs), `plans.tsx` (Basic and Pro through the App Store), `save-case.tsx` (Save case with a title), `context-review.tsx` (the debate context Review) and `welcome.tsx` (Ollie's welcome and the first-run tour); `share/[code].tsx`, the read-only viewer for a share link, outside the sign-in guard; `+native-intent.tsx`, which maps website links to app routes; 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`, `crossfire-round/[id].tsx` (a saved round, read only), `timer.tsx` (a full-screen modal) with its `timer-format.tsx` sheet (a custom format), `speech-time.tsx` and `motions.tsx` (the website's speech time calculator and motion generator), `flowchart/` (the screen `index.tsx` with its `card`, `claim` and `maps` form sheets), and `history.tsx`, full screen above the tabs. All are declared in the root `_layout.tsx`. Every Practice tool is in the app, so `(practice)/soon/[tool].tsx` is no longer reached from the Practice tab. +- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; the form sheets `debates.tsx` (switcher), `new-debate.tsx`, `delete-account.tsx`, `delete-data.tsx`, `add-link.tsx`, `new-note.tsx`, `add-file.tsx` (Import file), `source/[id].tsx`, `edit-debate.tsx` (Edit settings), `debate-check.tsx` (the five-day check), `limit.tsx` (the website's limit-reached and model-restricted dialogs), `plans.tsx` (Basic and Pro through the App Store), `save-case.tsx` (Save case with a title), `context-review.tsx` (the debate context Review) and `welcome.tsx` (Ollie's welcome and the first-run tour); `share/[code].tsx`, the read-only viewer for a share link, outside the sign-in guard; `+native-intent.tsx`, which maps website links to app routes; 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`, `crossfire-round/[id].tsx` (a saved round, read only), `timer.tsx` (a full-screen modal) with its `timer-format.tsx` sheet (a custom format), `speech-time.tsx`, `motions.tsx` and `flow-sheet.tsx` (the website's speech time calculator, motion generator and printable flow sheet), `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. - `modules/`: the app's own Expo modules, autolinked into development builds only. `key-commands` is the iPad's UIKeyCommand shortcuts (Swift, `DebateOwlKeyCommands`). -- Lists that grow without bound (History, the Library, saved chats) are `ListScreen` (`components/list-screen.tsx`): `Screen`'s column and offline notice around a Reanimated FlatList, so only rows near the screen are drawn, with pull to refresh. A list drawn as one card frames each row with `CardRowFrame`, and the list slides rows into a removed row's gap (`itemLayoutAnimation`). A row fades up (`ListItemIn`) only when it arrives in the data, not when it scrolls back into view (`useRowArrivals`, `lib/row-arrivals.ts`). Not FlashList: it recycles cells, and these rows hold swipe, press and link-preview state. Debates, a round's speeches and other lists bounded by their format stay plain views. -- React Compiler compiles each component it can; a component that writes `ref.current` during render, disables a react-hooks lint rule or uses a function before its declaration is skipped whole. To check, run `babel-plugin-react-compiler` with a `logger` over a file to list what it skips. Values that change many times a second (Ask's streamed answer) live in `lib/live-value.ts` and are read by the small component that shows them; `useLatest` hands effects the latest handler without a ref write in render. Crossfire's clock ticks in its own component. +- Lists that grow without bound (History, the Library, saved chats) are `ListScreen` (`components/list-screen.tsx`): `Screen`'s column and offline notice around a Reanimated FlatList, so only rows near the screen are drawn, with pull to refresh. A list drawn as one card frames each row with `CardRowFrame`, and the list slides rows into a removed row's gap (`itemLayoutAnimation`). A row fades up (`ListItemIn`) only when it arrives in the data, not when it scrolls back into view (`useRowArrivals`, `lib/row-arrivals.ts`). Not FlashList: it recycles cells, and these rows hold swipe, press and link-preview state. The debate switcher and saved maps use `SheetList`, whose FlatList's native scroll view stays the sheet's direct child. A source's reading view owns its list too, in the sheet and the iPad pane: text is split into paragraphs of at most 4,000 characters (`source-paragraphs.ts`) and rendered near the viewport, with the controls in its header and footer. Never nest it in another scroll view. A round's speeches and other lists bounded by their format stay plain views. +- React Compiler compiles each component it can; a component that writes `ref.current` during render, disables a react-hooks lint rule or uses a function before its declaration is skipped whole. To check all native TSX files, run `node apps/mobile/scripts/compiler-audit.mjs` from the repo root; it runs `babel-plugin-react-compiler` with a `logger` and lists the skipped files, lines and reasons. Request bodies with error handling and cleanup use `run-request.ts` so unsupported try/catch/finally syntax stays outside render functions. Values that change many times a second (Ask's streamed answer) live in `lib/live-value.ts` and are read by the small component that shows them; `useLatest` hands effects the latest handler without a ref write in render. Crossfire's and Sparring's clocks tick in their own components; Sparring only commits its session when the deadline expires. The iPad outline records scroll offsets in a ref and sets state only when the current section changes. CountUp updates its text at most ten times a second. - `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. +- Startup: settings preload the last owner's device copy alongside auth restoration (`settings-cache.ts`), but apply it only after auth confirms that owner. The preload is consumed once; later account changes read that account's copy again and show device defaults until it is ready. The splash hides on the root view's first ready `onLayout`. i18n initializes English and the device language synchronously; `changeAppLanguage` loads both bundled namespaces for another language before selecting it, including offline. - `sources.ts` mirrors the session store's source actions (`addSource` with Undo's restore, `updateSource`, `removeSource`, `toggleSource` and `setFolderActive` as `setSourcesActive`, the folder actions and `clearLibrary`) and `src/lib/source-import.ts`. Switch changes run in one serial queue, so they reach the server in the order the student made them; a screen shows a change at once and puts it back when the write fails (`source-actions.ts`, with the website's messages). The Library always shows the Current Debate, also in one-off mode, as on the website. Its folder list, search and eight sort orders are the website's, in `packages/core/src/library.ts`. Scan pages is not in the app yet. - File import runs the website's reader (`packages/core/src/source-file-import.ts`, with its 8 MB and 120,000-character limits) inside an Expo DOM component, `components/source-file-reader.tsx`, because pdf.js and mammoth need a browser. `pick-source-file.ts` opens the Files picker, `source-file.tsx` checks size (the file's own when the picker gives none) and type before reading anything, then serves the picked copy in 768 KB slices that the reader pulls one at a time (`picked-file.ts` reads them natively as base64; the web preview slices the browser File); a read always ends, with a two-minute timeout for a reader that never loads, and `add-file.tsx` shows the draft. The DOM component imports pdf.js and mammoth statically, because Expo cannot export a DOM component that splits into chunks; its webview loads only while a file is read. It uses `@expo/dom-webview`, which Expo Go has. Never move the parsing into `import-source`: that would be an Edge Function deploy. - `toast.tsx` is the app's toast (the website's short messages, with Undo); a form sheet is drawn above it, so a sheet says its outcome inline and the toast shows once the sheet closes. `haptics.ts` is the one place for haptics (a tap for switches and chips, a tick for segmented controls and pickers, success when a result arrives or something is saved, a warning when a request fails, a tap when the turn changes); `haptics.web.ts` records them for the screenshot script. - Debates: `new-debate.tsx` and `edit-debate.tsx` share `components/debate-form.tsx`, the website's DebateForm plus the judge (`settings.judge`, which the website's prompts read but its form does not show). The switcher deletes any debate but the current one (the database cascades its sources and work). `use-inactive-debate-check.ts` opens `debate-check.tsx`, the website's five-day check; closing it counts as Continue. - Rows of saved work and sources are `Link`s with `Link.Preview` and `Link.Menu`: a long press shows the document and its actions (`work-actions.ts`: Discuss, Share, and Delete where the website's History deletes). - Ask mirrors `src/lib/ai/streaming.ts` and `askChat` in `src/lib/ai/client.ts`: `ask.ts` sends the requests (plain questions stream from `ai-stream` through `expo/fetch`; with Citations, Web search, Pros/Cons or Argument map on, the whole answer comes from `ai`, because `ai-stream` refuses those options), `ai-request.ts` holds the shared POST with the website's headers, timeout and error kinds, `feature-config.ts` reads each tool's model from `user_settings.feature_configs` (the model picker, `components/model-picker.tsx`, writes it back through `settings.tsx`'s serial queue in the website's canonical shape; tools read it through the same queue, so they run with the model just picked), `context-request.ts` builds the Current Debate context from `context-plan.ts`, which reads a debate's list of material when a form opens, and the text only of the items a request sends (kept in `context-texts.ts` under each row's version: a case's generatedAt, a chat's message count, a round's updated_at; source text is read again after each list read, and `updateResult` and `saveChat` forget what they change), and plans it with the website's planner (`packages/core/src/debate/context-plan.ts`), with the Review choices for one request (Crossfire keeps them for its round) and the pins in `debate_context_pins` (three at most); the line on each tool form, Crossfire, Sparring and Ask (`components/context-line.tsx`) and its Review sheet read the same plan, and `chats.ts` saves finished exchanges to `chats` and `messages`. The SSE parser is shared in `packages/core/src/ai/sse.ts`. `markdown.ts` reads answers, because react-markdown needs the DOM. -- Generate and Attack mirror `generateCase` and `attackCase` in `src/lib/ai/client.ts` and the session store's `recordResult`: `results.ts` sends the requests through `ai-request.ts`'s `postAI`, which streams them as the website does (`stream: true` with a `resultKey`; the reader is `packages/core/src/ai/live.ts`, shared with the website's `src/lib/ai/live.ts`) and reads a dropped stream's answer back from `ai_run_results` (`packages/core/src/ai/run-recovery.ts`). The four tool screens show the draft with `components/live-run.tsx` (the website's `LiveToolRun`; the sections come from `packages/core/src/ai/live-sections.ts`). `results.ts` also checks the output with the website's transform (moved to `packages/core/src/ai/generate-output.ts`), keeps researched sources in the debate's Auto-saved folder (`packages/core/src/ai/discovered-sources.ts`), saves the server's source rankings onto the Library sources a result used (`packages/core/src/ai/source-assessments.ts`, also for Ask's whole answers), says when the server shortened the input, and saves rows to `cases` and `attacks`. `motion.ts` and `motion-gate.ts` are the website's motion gate. `open-results.ts` holds results made in this session, so one-off work and failed saves still open as documents. It and the last Generate and Attack forms live in `session-memory.ts`, which the auth provider clears on every account change, including sign-out. `components/document.tsx` draws results as documents (mock 03); Brainstorm and Factoids reuse it. Export renders `export-html.ts` to a PDF with expo-print and shares it (`export.ts`); the web preview cannot print, so `export.web.ts` keeps the HTML and copied text on `window` for the screenshot script. Word export uses the website's builders, moved to `packages/core/src/export/docx.ts`: `export-docx.ts` packs them with `Packer.toBase64String` and shares the .docx (`export-docx.web.ts` keeps it on `window` for the screenshot script). Google Docs is not in the app. +- Generate offers Any role and the four speaker roles, and takes a debate's role when prefilling its motion. Generate and Attack mirror `generateCase` and `attackCase` in `src/lib/ai/client.ts` and the session store's `recordResult`: `results.ts` sends the requests through `ai-request.ts`'s `postAI`, which streams them as the website does (`stream: true` with a `resultKey`; the reader is `packages/core/src/ai/live.ts`, shared with the website's `src/lib/ai/live.ts`) and reads a dropped stream's answer back from `ai_run_results` (`packages/core/src/ai/run-recovery.ts`). The four tool screens show the draft with `components/live-run.tsx` (the website's `LiveToolRun`; the sections come from `packages/core/src/ai/live-sections.ts`). `results.ts` also checks the output with the website's transform (moved to `packages/core/src/ai/generate-output.ts`), keeps researched sources in the debate's Auto-saved folder (`packages/core/src/ai/discovered-sources.ts`), saves the server's source rankings onto the Library sources a result used (`packages/core/src/ai/source-assessments.ts`, also for Ask's whole answers), says when the server shortened the input, and saves rows to `cases` and `attacks`. `motion.ts` and `motion-gate.ts` are the website's motion gate. `open-results.ts` holds results made in this session, so one-off work and failed saves still open as documents. It and the last Generate and Attack forms live in `session-memory.ts`, which the auth provider clears on every account change, including sign-out. `components/document.tsx` draws results as documents (mock 03); Brainstorm and Factoids reuse it. Export renders `export-html.ts` to a PDF with expo-print and shares it (`export.ts`); the web preview cannot print, so `export.web.ts` keeps the HTML and copied text on `window` for the screenshot script. Word export uses the website's builders, moved to `packages/core/src/export/docx.ts`: `export-docx.ts` packs them with `Packer.toBase64String` and shares the .docx (`export-docx.web.ts` keeps it on `window` for the screenshot script). Google Docs is 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. - A case's Regenerate and Undo are `case-edits.ts`: one part is replaced from a new generation with the website's merge (`mergeRegeneratedSection` in `packages/core/src/ai/generate-output.ts`), earlier versions are kept for the session, and Undo writes the restored version back to the saved row (`updateResult`). Held results are observable (`subscribeOpenResults`), so the document shows a change made from its menu or its sheets. - Ask's Stop keeps the answer so far, marked as stopped. Its General and Clarify modes go in the request; Clarify and the options `ai-stream` refuses stream live from the `ai` function through `postAI`. An answer saves to the Current Debate's `Ask` folder. Attachments (`ask-attachments.ts`, expo-document-picker, in Expo Go) are read on the phone (`read-file.ts`) and sent in the website's format (`packages/core/src/ai/ask-files.ts`). - A case's and an attack's next steps are `result-links.ts`: "Open in Crossfire" fills the next Crossfire form through the one-shot `crossfirePrefill` slot (the website's route state; the builders are `packages/core/src/crossfire/prefill.ts`), and a case is built into an unsaved map with `flowchartFromCase` (`packages/core/src/flowchart.ts`, shared with the website), only in the debate the Flowchart is on, as on the website. - Limits mirror the website's subscription dialogs: `refusal.ts` turns a 402 or 403 refusal (with the limit details `errorFrom` keeps) into the `limit` sheet's parameters and holds the website's messages (`limit-message.ts`); the tools open the sheet instead of an inline error, and Ask, Crossfire and Sparring open it beside theirs. Where the website offers Pro, the sheet's next step is `plans.tsx` in a build with StoreKit, else the plan in You. `usage.ts` reads today's uses for the line under each tool's title (`DailyUsageLine`, the website's DailyUsage) and reads again after every AI request (`onAIRequestSettled`). `session-quota.ts` asks what a Crossfire or Sparring round costs (`quotaCheck`), shows it above Start (`SessionCostNote`) and opens the sheet before a round the day cannot cover. -- Timer mirrors the website's free round timer (`src/components/free-tools/DebateTimer.tsx`), not the in-app TimerPage: the seven formats with their speeches, points-of-information windows, prep banks, overtime and grace come from `packages/core/src/free-tools/` (moved there from `src/lib/free-tools/`, which re-exports them; the custom format's URL codec stays in the website). `timer.ts` holds the session (format choice, round, mute), its stored form and the Live Activity's times (`timer.test.ts`); `timer-store.ts` keeps it and the custom formats saved on the device in AsyncStorage, so a running speech survives closing the timer and restarting the app. `components/timer-runner.tsx`, in the root layout, sounds the cues (a knock when POIs open or close, a beep at one minute left, two at time; after a gap in the background only time up) and stops an exhausted prep bank, with signals mounted only while a clock runs, and keeps the Live Activity in step. `timer-signals.ts` plays the bundled sounds in `assets/sounds` (made by `scripts/render-timer-sounds.ts`) with expo-audio, taps with expo-haptics and keeps the screen awake while the timer screen shows a running clock; `timer-signals.web.ts` only records the signals for the screenshot script. +- Timer mirrors the website's free round timer (`src/components/free-tools/DebateTimer.tsx`), not the in-app TimerPage: the seven formats with their speeches, points-of-information windows, prep banks, overtime and grace come from `packages/core/src/free-tools/` (moved there from `src/lib/free-tools/`, which re-exports them, including the custom format URL codec). `timer.ts` holds the session (format choice, round, mute), its stored form and the Live Activity's times (`timer.test.ts`); `timer-store.ts` keeps it and the custom formats saved on the device in AsyncStorage, so a running speech survives closing the timer and restarting the app. `components/timer-runner.tsx`, in the root layout, sounds the cues (a knock when POIs open or close, a beep at one minute left, two at time; after a gap in the background only time up) and stops an exhausted prep bank, with signals mounted only while a clock runs, and keeps the Live Activity in step. `timer-signals.ts` plays the bundled sounds in `assets/sounds` (made by `scripts/render-timer-sounds.ts`) with expo-audio, taps with expo-haptics and keeps the screen awake while the timer screen shows a running clock; `timer-signals.web.ts` only records the signals for the screenshot script. +- Custom timer formats share as `https://debateowl.com/tools/debate-timer?custom=…` from `timer-format.tsx`. `deep-links.ts` validates the shared codec before carrying the first custom parameter into `/timer`; the timer imports it once as a fresh round, without overwriting saved formats. The website association configuration still controls which links iOS delivers. +- `flow-sheet.tsx` mirrors the website's FlowSheet controls and shared format data: paper, orientation, optional motion/round/judge and the question or notes strip. `flowSheetExportHtml` prints speech columns through `sharePdf`, with page dimensions matching the selected paper. Practice and the iPad sidebar open it. - The Lock Screen timer: `src/widgets/timer-widgets.tsx` holds the Live Activity (`DebateTimerActivity`: Lock Screen banner and Dynamic Island) and the lock-screen widget (`DebateTimerWidget`, the `widgets[]` entry in `app.config.ts`), drawn by iOS from SwiftUI timer texts so they count without the app. Their functions run in the extension's own runtime ('widget' directive): no imports or outside names, every string in the props. `live-activity.ts` loads them only when `nativeCapabilities().liveActivity` (a development build with `DEBATELAB_APP_GROUP`), takes over or ends an activity left by an earlier launch, and ends the activity when no clock has time on it; `live-activity.web.ts` records the state for the screenshot script. Round 4's Home Screen widget adds its own entry to the same `widgets[]` array. From iOS 17 the activity has Pause or Resume and Next speech buttons with fixed targets (`timer-toggle`, `timer-next`): iOS runs them in the app's process, `live-activity.ts` hears them through `addUserInteractionListener` and applies `applyTimerActivityAction` (`timer.ts`) to the stored round, and `TimerRunner` sends the new clock back. A prep bank paused while it ran stays on the Lock Screen as the session's `held` bank, so Resume restarts it; `settleHeld` keeps `held` in step on every change (`updateSession`), from either screen's buttons. `timer-alerts.ts` sets a local, time-sensitive notification for the moment the running clock runs out (`timerAlertAt`) whenever the app is off screen, because the app's own signals stop when iOS suspends it; it asks for permission the first time a clock starts. - The generating Live Activity (`widgets/generating-activity.tsx`, `generating-activity.ts`, the words in `generating-activity-state.ts`): `useLiveRun({ tool, parts })` in Generate, Attack, Brainstorm and Factoids starts it with the run's motion, updates the step and the parts written, `complete()` marks it ready and `end('failed')` failed. Ollie's poses are copied into the App Group folder by `widget-images.ts`, which the Current Debate widget uses too. When the app leaves the screen the activity is given a stale date 30 seconds later, and the layout then says to open DebateOwl, because the stream stops while iOS suspends the app; the answer comes back from `ai_run_results` when it opens. A finished or failed run stays on the Lock Screen for 15 minutes when the app is away. Every live run also sends `notifyWhenReady` with this install's id (`setReadyPushInstall`, set at launch), so the `ai` function pushes `result_ready` to this phone once the answer is stored; `push.ts` hides that push while the app is on screen. - Crossfire is the website's typed mode: `crossfire.ts` sends `crossfire` and its signal requests as `src/lib/ai/client.ts` does (Ask's model settings, 75 s and 5 s deadlines), with the round's id as `quotaSession` on turns and the debrief so the round is charged once, as the website does and saves a finished round to `crossfires` in its debate, as CrossfirePage's `recordResult` call; the reducer and schemas are `packages/core/src/crossfire`. The unfinished round is a session slot, so leaving the screen keeps it. A saved round opens read only from History and the Debate tab (`crossfire-round/[id].tsx`, `fetchCrossfireRound`, with an offline copy): debrief, spoken delivery and transcript. The website lists saved rounds but cannot open them. -- Sparring is the website's typed mode: `sparring.ts` sends the opponent's turns and the judge as `generateSparringTurn` and `judgeSparringSession` in `src/lib/ai/client.ts` do (the Sparring model settings, a transcript-only debate context), and the round's moves (turn order, the deadline clock, judging, saved rows) are `packages/core/src/sparring/session.ts`, shared with the website's sparring store. The server saves the judged round to `sparring_sessions` when it scores it; the app then files it in its debate with `attach_to_debate` and the student's side, as the store does. The side is asked when the round starts. The unfinished round is a session slot (`sparringDraft`), so leaving the screen keeps it, and a request cut off by leaving is offered again. The flow button opens the Flowchart on the Current Debate's map, above the round, which keeps running. Practice lists the Current Debate's judged rounds and the Debate tab lists them by side; both open `sparring/[id]`, which can delete them and exports the website's Sparring PDF (`sparringExportHtml`; Word export is not in the app). +- Sparring is the website's typed mode: `sparring.ts` sends the opponent's turns and the judge as `generateSparringTurn` and `judgeSparringSession` in `src/lib/ai/client.ts` do (the Sparring model settings, a transcript-only debate context), and the round's moves (turn order, the deadline clock, judging, saved rows) are `packages/core/src/sparring/session.ts`, shared with the website's sparring store. The server saves the judged round to `sparring_sessions` when it scores it; the app then files it in its debate with `attach_to_debate` and the student's side, as the store does. The side is asked when the round starts. The unfinished round is a session slot (`sparringDraft`), so leaving the screen keeps it, and a request cut off by leaving is offered again. The flow button opens the Flowchart on the Current Debate's map, above the round, which keeps running. Practice lists the Current Debate's judged rounds and the Debate tab lists them by side; both open `sparring/[id]`, which can delete them and exports the website's Sparring PDF (`sparringExportHtml`) and Word document (`buildSparringDocx`). The setup uses `ModelPicker`; turns and judging read the chosen model through settings' write queue. - Voice rounds mirror the website's (`src/hooks/use-speech-capture.ts`, `src/lib/voice/`): `speech-recognition.ts` reaches `expo-speech-recognition` only in a build that has it (never in Expo Go); `speech-capture.ts` is the capture state machine (restart when iOS ends a request, three quick failures end it, a stop waits up to 1.5 s for the last words, long pauses timed; `speech-capture.test.ts`), and `use-speech-capture.ts` its hook. Words fill the draft, and the turn's delivery is measured with `packages/core/src/voice/delivery.ts` on submit. Turns send `voice: true` beside `input` and read the signed `voice` segments (`parseVoicePayload`); `opponent-voice.ts` plays them from the `voice` function with expo-audio and falls back to expo-speech, and `voice-availability.ts` knows when voice cannot run and remembers the first-use notice. `components/voice.tsx` has the switch with the notice (the website's wording, with Apple's recognizer in place of the browser), the microphone control, the opponent bar, delivery facts and the dictation button that Generate's motion and Ask's question use. In Expo Go the switch stays off with the note and dictation does not show. The `.web.ts` versions fake the recognizer from `window.__debatelabFakeSpeech` and only record the opponent's replies, for the screenshot script. - Flowchart mirrors the website's `FlowchartPage.tsx` and the session store's `saveFlowchart` and `deleteSavedFlowchart` without React Flow: the saved `flowcharts` row keeps the website's React Flow node and edge JSON, and `packages/core/src/flowchart.ts` reads and edits it (starter map, edge kinds from the label or stroke the website writes, role columns, the tree, and edits that keep every field they do not touch). `flowcharts.ts` reads and writes the rows; Save over an opened map updates it, Save as inserts a row as the website's Save does. `flowchart-drafts.ts` holds each debate's open map and unsaved edits in session memory, shared by the screen and its sheets. Maps follow `activeDebateId`: one-off maps are saved with no debate, as on the website. The website's PNG export is a PDF of the Map tree here (`flowchartExportHtml`). -- History mirrors `src/pages/HistoryPage.tsx` and `UnsortedWork.tsx`: `history.ts` reads each table for one debate, or every kind plus Library sources outside all debates, and adds unsorted work to a debate through the website's `attach_to_debate` function. Chats reopen in Ask (`/ask?chat=`). A Sparring round opens `sparring/[id]` and swipes to delete as a case does (`deleteSparringRound`); an unsorted round is added to a debate with the side the student spoke, through `attachToDebate`, which also checks the signed-in account. +- History mirrors `src/pages/HistoryPage.tsx` and `UnsortedWork.tsx`: `history.ts` reads each table for one debate, or every kind plus Library sources outside all debates, and adds unsorted work to a debate through the website's `attach_to_debate` function. Chats reopen in Ask (`/ask?chat=`). Crossfire has its own tab; its rows and `crossfire-round/[id]` delete through `deleteCrossfireRound`, which drops the saved document and `deletedListScopes` after the server succeeds. A Sparring round opens `sparring/[id]` and swipes to delete as a case does (`deleteSparringRound`); an unsorted round is added to a debate with the side the student spoke, through `attachToDebate`, which also checks the signed-in account. - Announcements mirror the website's banner (`AppLayout.tsx`, `AnnouncementBanner.tsx`) at the top of the Debate tab: `announcements.ts` lists and dismisses through the `announcements` Edge Function, and `use-announcements.ts` keeps the website's startsAt/expiresAt filter (shared in `packages/core/src/announcements.ts`), its one-minute reload guard, and reloads on focus, foreground and account change. Dismissals are stored on the server, so they are shared with the website. -- You mirrors the website's Settings for what the app can do: Download my data (`exportAccountData` in `account.ts`, the `account` function's export written as `debatelab-data-.json` and shared through `shareJson`), Delete all my data (`delete-data.tsx`, the email-typed confirmation and `clear_current_user_data`; afterwards session memory is forgotten, one-off mode turns off and the debates reload, as the website clears its stores), and Terms and Privacy, opened in the in-app browser from `app.config.ts`'s `extra.websiteUrl` (`website.ts`). The plan card shows the website's billing states, dates and wording (`SubscriptionStatus.tsx`): `subscription.ts` reads `user_settings`, Stripe `subscriptions` and `app_store_subscriptions` through the shared reader (`packages/core/src/supabase/billing.ts`, via `billing-query.ts`) and resolves them with the resolver the AI functions use (`supabase/functions/_shared/billing-state.ts`); usage is the server's own counter (`ai_usage_today`). Where the build has StoreKit, the card's See plans opens `plans.tsx`. +- You mirrors the website's Settings for what the app can do: Download my data (`exportAccountData` in `account.ts`, the `account` function's export written as `debatelab-data-.json` and shared through `shareJson`), Delete all my data (`delete-data.tsx`, the email-typed confirmation and `clear_current_user_data`; afterwards session memory is forgotten, one-off mode turns off and the debates reload, as the website clears its stores), and Terms and Privacy, opened in the in-app browser from `app.config.ts`'s `extra.websiteUrl` (`website.ts`). The plan card shows the website's billing states, dates and wording (`SubscriptionStatus.tsx`): `subscription.ts` reads `user_settings`, Stripe `subscriptions` and `app_store_subscriptions` through the shared reader (`packages/core/src/supabase/billing.ts`, via `billing-query.ts`) and resolves them with the resolver the AI functions use (`supabase/functions/_shared/billing-state.ts`); usage is the server's own counter (`ai_usage_today`). The card shows the daily reset at the next midnight UTC on the student's local clock, as the website's Settings does; `DailyUsageLine` also accepts `showReset`. Open Stripe subscriptions get a neutral Manage link to `/app/settings` in the in-app browser. The account line shows the recorded sign-in methods, including linked Apple identities (`sign-in-method.ts`). Where the build has StoreKit, the card's See plans opens `plans.tsx`. - One-off mode is session-only and never written, as on the website; only maps saved in it are kept, with no debate, as the website keeps them. The switcher turns it on with or without a Current Debate, and `OneOffNotice` shows it on the Debate tab, Prepare, Ask, Library and the Generate, Attack, Brainstorm, Factoids, Crossfire and Sparring forms (through `WorkingIn`) and the Flowchart. The Debate tab, Prepare and Library still list the Current Debate's work in one-off mode, as the website's home does. Students prepare both sides of a motion, so a debate has no side: the Debate tab's In favor / Against toggle only chooses which side's work it shows, using the website's rule (`'both'` counts for either side, neutral and side-less work for neither). It starts on In favor and is never stored. -- Offline reading: `offline-cache.ts` keeps a copy of every read in `debates.ts`, `sources.ts`, `results.ts`, `flowcharts.ts`, `chats.ts`, `sparring.ts` and `history.ts` (the debates list, counts and recent work, Library sources with their text, saved results, maps, chats, judged Sparring rounds and History's lists) in AsyncStorage under the user id. A read goes to the network first and saves its answer; when no answer comes back (supabase-js status 0: no connection, DNS, timeout), or `network-status.ts` (expo-network) says the device is offline, the copy answers and `OfflineNotice` shows at the top of `Screen`, `DocumentScreen` (including its iPad outline rail), the Flowchart and the iPad Library list until each copy shown is read from the server again or the screen showing it closes, moves on or leaves view (a screen passes `useCopyHolder().mark()` with its reads). A server error (an expired session, a policy, a 5xx) is thrown, never answered from a copy; a read made of several queries runs them through `readAll` (or `pickFailure`), so one query's refusal wins over another's missing answer. A slower answer never replaces a newer copy, each group keeps its most recently fetched copies only (`LIMITS`), and the auth provider calls `setCacheOwner` before it adopts a session, so another account's copies are removed before its first read. AI and saving results fail offline with their network errors. New notes, source edits and Active switches go through `offline-queue.ts` instead (`use-offline-queue.ts` starts it in the root layout): kept on the phone under the user id, replayed in order on launch, reconnect, foreground and every 30 seconds while anything waits, merged per source, dropped and reported when the server refuses them, and shown as "Waiting to sync" meanwhile. A queued note has a `local:` id until it syncs. A read added to those modules goes through `cachedRead`, takes an optional `CopyMark`, and throws `readFailed(error, status)`. Every write that succeeds calls `afterWrite` with the copies it changed (`debateListScopes` names a debate's counts and lists; a delete uses `deletedListScopes`, which drops every debate's when no row came back), which drops them and stops older reads from saving over them, or `updateCopy` when it knows the new value. `debateListScopes` also names History's copy (`one-off` for work outside every debate); History's add-to-debate and filing a judged Sparring round into its debate both drop the item's copies on both sides, and Delete all my data drops every copy (`ALL_COPIES`). +- Offline reading: `offline-cache.ts` keeps a copy of every read in `debates.ts`, `sources.ts`, `results.ts`, `flowcharts.ts`, `chats.ts`, `sparring.ts` and `history.ts` (the debates list, counts and recent work, Library sources with their text, saved results, maps, chats, judged Sparring rounds and History's lists) in AsyncStorage under the user id. A read keeps its network promise, but the Debate tab, Library, Prepare and History pass `onCopy` to paint an existing account-owned copy while the refresh is in flight. That early copy does not raise `OfflineNotice`. Fresh data replaces it, and the caller must still await the promise and surface server errors. Other reads stay network-first. When no answer comes back (supabase-js status 0: no connection, DNS, timeout), or `network-status.ts` (expo-network) says the device is offline, the copy answers and `OfflineNotice` shows at the top of `Screen`, `DocumentScreen` (including its iPad outline rail), the Flowchart and the iPad Library list until each copy shown is read from the server again or the screen showing it closes, moves on or leaves view (a screen passes `useCopyHolder().mark()` with its reads). A server error (an expired session, a policy, a 5xx) is thrown, never answered from a copy; a read made of several queries runs them through `readAll` (or `pickFailure`), so one query's refusal wins over another's missing answer. Unchanged Library source payloads skip the full-text AsyncStorage write, using exact equality because sources have no update timestamp; the small index still records the fetch time. A slower answer never replaces a newer copy, each group keeps its most recently fetched copies only (`LIMITS`), and the auth provider calls `setCacheOwner` before it adopts a session, so another account's copies are removed before its first read. AI and saving results fail offline with their network errors. New notes, source edits and Active switches go through `offline-queue.ts` instead (`use-offline-queue.ts` starts it in the root layout): kept on the phone under the user id, replayed in order on launch, reconnect, foreground and every 30 seconds while anything waits, merged per source, dropped and reported when the server refuses them, and shown as "Waiting to sync" meanwhile. A queued note has a `local:` id until it syncs. A read added to those modules goes through `cachedRead`, takes an optional `CopyMark` and `onCopy` callback, and throws `readFailed(error, status)`. Every write that succeeds calls `afterWrite` with the copies it changed (`debateListScopes` names a debate's counts and lists; a delete uses `deletedListScopes`, which drops every debate's when no row came back), which drops them and stops older reads from saving over them, or `updateCopy` when it knows the new value. `debateListScopes` also names History's copy (`one-off` for work outside every debate); History's add-to-debate and filing a judged Sparring round into its debate both drop the item's copies on both sides, and Delete all my data drops every copy (`ALL_COPIES`). - Push: `push.ts` asks for permission and files the Expo push token under the signed-in user with the `register_push_token` RPC; the You tab's Notifications switch turns it on and off, the root layout registers again on launch and when the language changes, and sign-out releases the registration with the account's own access token, after any registration still in flight. Every registration carries the ID of the local record, and `release_push_token` withdraws that ID and deletes only the rows it wrote, so a registration the app gave up on is refused if it reaches the database after the release. Once a release is sent for an ID, the record is marked released and the next registration gets a new ID, so a late release cannot delete it. The local record is cleared only once a release succeeds; otherwise the next change on the phone, from any account, releases it first. The server side is in [supabase/README.md](../../supabase/README.md#push-notifications). `push.web.ts` registers nothing and only flips the switch for the screenshot script. - A token needs `app.config.ts`'s `extra.eas.projectId`, which `eas init` set to the `@pi-stack/debateowl` project (see [docs/development.md](../../docs/development.md#build-day)); without it the switch shows as unavailable, on Expo Go too. Expo Go on Android has no remote push since SDK 53, and Expo recommends a development build for remote push on iOS as well. - Prebuild applies the `expo-notifications` config plugin automatically once the package is installed, even though `app.config.ts` does not list it, and the plugin sets the `aps-environment` entitlement. `DEBATELAB_BUILD=dev` removes it again (`withoutAccountEntitlements`, which also drops the Sign in with Apple entitlement `expo-apple-authentication`'s plugin adds), because the borrowed Signulous profile has no push, so a development build cannot get a token even with a project ID: turning the switch on there fails. A build signed by DebateOwl's own account sets `DEBATELAB_APPLE_ACCOUNT=1` and keeps both; it also needs an APNs key uploaded to EAS. @@ -59,14 +62,16 @@ The iOS app DebateOwl (`@debatelab/mobile`; bundle id `com.pistack.debateowl`, E - Accessibility: every text field is `AppTextInput` (`components/app-text-input.tsx`), with the one Done bar above the keyboard (`KeyboardDoneBar` in the root layout); `Screen`, `DocumentScreen` and sheets scroll fields clear of the keyboard. Text grows with Dynamic Type; only fixed chrome sets `maxFontSizeMultiplier` (floating action-bar buttons and segmented controls 1.6, the sidebar 1.35). The web preview draws a text size from `window.__debateowlFontScale` (`theme/font-scale.web.ts`, the script's `FONT_SCALE`). `a11y.ts` announces a finished Ask answer, Crossfire reply, Sparring speech or tool result once; never make streamed text a live region. A list row's long-press menu is also its VoiceOver actions (`accessibilityActions`): saved work, History, Library sources, recent sources and the switcher. iPad shortcuts (⌘N new debate, ⌘K switcher, ⌘↩ send) go through `key-commands.ts`: one registry (`key-command-registry.ts`) gives a press to the newest screen listening, screens listen only while in view (`useFocusedKeyCommand`), and the development build delivers them from `modules/key-commands`, with titles in the ⌘ overlay; the web preview listens to `keydown` (`key-commands.web.ts`). The timer's space bar belongs to the timer work. - The Current Debate widget (`widgets/current-debate.tsx`, published by `debate-widget.ts` from the debate context and the Debate tab) needs a development build with `DEBATELAB_APP_GROUP`. Quick actions (`quick-actions.ts`, `expo-quick-actions`) and Spotlight (`spotlight.ts`, `expo-core-spotlight` 2.x, whose config plugin 3.0 dropped) are in development builds; `app.config.ts`'s round-4 section adds their native config. Spotlight indexes the Current Debate's documents (`spotlight-items.ts`) as `debatelab:///` links, and empties when the Current Debate changes, on sign-out and after Delete all my data. - Sharing into the app (`share-intent.ts`, `expo-share-intent`): with the App Group, development builds get the `DebateOwlShare` extension. A shared page opens Add link and starts its import; other text opens New note filled in (`share-intent-parse.ts`). `+native-intent.tsx` hands the extension's `debatelab://dataUrl=…` to `share-intent.ts`, and the sheets take what was shared only from its one-shot hand-off, never from route parameters, so no other link can start an import. -- Sign in with Apple and Google (`social-sign-in.ts`, on `sign-in.tsx` below the email and demo forms, Apple's own button first): Apple's identity token goes to `signInWithIdToken` with a nonce, and the name Apple gives on the first sign-in is saved to the account; Google is the website's OAuth flow in `openAuthSessionAsync` with PKCE (`flowType: 'pkce'` in `supabase.ts`, as the website's client has), and `oauth-callback.ts` reads the redirect (`oauth-callback.test.ts`). `app.config.ts`'s `extra.googleSignIn` and `extra.signInWithApple` turn the buttons on; the Supabase providers and the `debatelab://auth/callback` redirect are in the account checklist. +- Sign in with Apple and Google (`social-sign-in.ts`, on `sign-in.tsx` below the email and demo forms, Apple's own button first): Apple's identity token goes to `signInWithIdToken` with a nonce, and the name Apple gives on the first sign-in is saved to the account; Google is the website's OAuth flow in `openAuthSessionAsync` with PKCE (`flowType: 'pkce'` in `supabase.ts`, as the website's client has), and `oauth-callback.ts` reads the redirect (`oauth-callback.test.ts`). Sign-in's language menu calls i18n's language setter before an account is available; the account's saved language wins after sign-in. `app.config.ts`'s `extra.googleSignIn` and `extra.signInWithApple` turn the buttons on; the Supabase providers and the `debatelab://auth/callback` redirect are in the account checklist. - In-app purchase: `modules/store-kit` is the app's own StoreKit 2 module (`StoreKitLogic.swift` holds the StoreKit work and typechecks alone; `StoreKitModule.swift` is the Expo glue), loaded by `store-kit.ts` with `requireOptionalNativeModule`, so Expo Go simply has none. `purchases.ts` buys with the account's id as `appAccountToken`, posts the signed transaction to `app-store-purchase`, and finishes it only after a 2xx, one at a time through a serial queue; an unfinished one is sent again at launch, on sign-in and in the foreground, each `Transaction.updates` version is sent as it comes (a refund is a new signed version of the same id) (`use-apple-platform.ts`), and nothing is sent under another account (`purchases.test.ts`). Offer codes are not offered: a code redeemed through Apple has no `appAccountToken`. The product ids are `PRODUCT_IDS`, registered in `20261029120000_app_store_debateowl_products.sql`. `store-kit.web.ts` is a placeholder store for the screenshot script. - Siri, Shortcuts and Spotlight actions: `plugins/app-intents/DebateOwlAppIntents.swift` has six App Intents (start the timer, ask the coach, a new debate, write a case, open the Library, spar) and their App Shortcuts phrases, translated in `plugins/app-intents/.lproj/AppShortcuts.strings`. Xcode extracts App Intents metadata only from the target that compiles them, so `plugins/with-app-intents.js` (CommonJS, because Node loads what `app.config.ts` imports as it is) copies them into the app target at prebuild, writes the Spanish and Hungarian intent titles from the catalogs, and fails prebuild when a title drifts from `en.json`. Each intent opens the app on a `debatelab://` link: Ask reads `q` into the question without sending it, Generate reads `motion` into the form (the first value when a link repeats it). - Handoff: `modules/handoff` (the app's own module) offers the open screen as an NSUserActivity; `handoff.ts` (`useHandoff` in the root layout) maps the route to its debateowl.com page with `handoffTarget` (`app-intent-links.ts`, tested), so a Mac continues in Safari, and opens a route an iPad or iPhone hands over (`continuedRoute`; a result only the other device holds opens History). `deep-links.ts` maps the website's free tools back to their screens for Handoff from Safari. Handing over from Safari to the app needs `activitycontinuation:debateowl.com`, which `DEBATELAB_APPLE_ACCOUNT=1` adds, and the website's association file. - `review-prompt.ts` offers Apple's rating prompt a few seconds after the third finished result (a tool's `complete()`, a judged Sparring round), at most every 120 days. - `src/theme/`: the website's DebateOwl design tokens (`src/styles/tokens.css` and the roles in `src/index.css`) in light and dark. `primary` is the blue that passes as text; `primaryFill` with `primaryLedge` is the button face and the ledge it sits on. Success is mint and always comes with a check mark. Fredoka (`weight="display"`) is for titles, the motion and button labels, Nunito for everything else, loaded with expo-font from `@expo-google-fonts`. Buttons are the website's tactile ones (`components/buttons.tsx`), which sink onto their ledge while pressed. Cards and fields have 2pt edges. +- Animation feedback: `PressScale` and `usePressScale` spring chips, rows, segmented controls and icons to 0.97; ledge buttons keep only their tactile press. `SessionEntrance` staggers Debate cards and Prepare/Practice tools once per app session. Every motion follows the live Reduce Motion setting. Fresh Generate, Attack, Brainstorm and Factoids results consume account-scoped celebration marks; reopened work and questions-only brainstorms do not celebrate. Saving a case shows the check toast and success haptic after the sheet closes. A new debate consumes its welcome wave and motion pop on the Debate tab. Sparring scores of at least 75/100 get the cheer and confetti; lower scores get a coach wave. Request errors show Ollie's small shake. Live tool phases select poses through the public phase prop, without changing streaming state. +- Timer animation only reads its engine. The 48-point phone owl sits absolutely at the ring's lower right, so the existing ring and controls keep their size and position. It hops on fresh speech starts and briefly looks surprised at one minute left and time up. The SVG sweep interpolates between 250 ms ticks on the UI thread. The last speech reaching its limit celebrates once. `RoundFeedbackObserver` counts actual timed speeches and overtime during this app session, including switches while the screen is closed; speech history does not survive a process restart. - `components/owl.tsx`: Ollie, from PNGs of the website's owl SVGs (`src/assets/owl`, `public/owl-mark.svg`), in the pose the website uses for each place: `point` for an empty start, `coach` beside Ask's answers, `arguing` for the Sparring opponent, `reading`, `surprised` and `sleepy` in empty states (`components/empty-state.tsx`), and a tool's pose while it works (`ToolProgress`). `scripts/render-owls.ts` renders them, the app icon, the splash image and the Android icons again after the website's owl changes. -- `scripts/screenshots.ts`: web-export screenshots. `scripts/render-tab-icons.ts` redraws the tab bar PNGs. +- `scripts/patch-expo-widgets-bundle.mjs` and `scripts/patch-app-group-lookup.mjs` patch native modules in node_modules on EAS (`eas-build-post-install`); the second makes expo-widgets and expo-share-intent prefer AltStore's renamed App Group (`ALTAppGroups`). `scripts/screenshots.ts`: web-export screenshots. `scripts/render-tab-icons.ts` redraws the tab bar PNGs. ## Checks diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 7a6039ca3..56a4ff444 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -9,10 +9,12 @@ import { type ConfigPlugin, withEntitlementsPlist } from 'expo/config-plugins'; import { withAppIntents } from './plugins/with-app-intents'; -// One config for Expo Go, the web export and the development build. -// DEBATELAB_BUILD=dev (set by the `development` profile in eas.json) adds the -// native modules Expo Go lacks. Without it the config is the one Expo Go has -// always used. DEBATELAB_APP_GROUP adds the timer's widget extension (its Live +// One config for Expo Go, the web export, the development build and the +// release build. DEBATELAB_BUILD=dev (set by the `development` profiles in +// eas.json) adds the native modules Expo Go lacks; DEBATELAB_BUILD=release +// (the `release-simulator` and `sideload` profiles) is the same app without +// the development client, with its JavaScript built in. Without either the +// config is the one Expo Go has always used. DEBATELAB_APP_GROUP adds the timer's widget extension (its Live // Activity, Dynamic Island clock and lock-screen widget) and the share // extension, which need the App Group `group.com.pistack.debateowl`, registered // for `com.pistack.debateowl`, `com.pistack.debateowl.ExpoWidgetsTarget` and @@ -24,6 +26,8 @@ import { withAppIntents } from './plugins/with-app-intents'; type PermissionStrings = { speechRecognition: string; microphone: string }; const build = process.env.DEBATELAB_BUILD; +/** A native build of the app's own: a development build or a release build. */ +const native = build === 'dev' || build === 'release'; const appGroup = process.env.DEBATELAB_APP_GROUP; // DEBATELAB_APPLE_ACCOUNT=1: the build is signed by DebateOwl's own Apple // Developer team, whose App IDs carry the capabilities in docs/apple-platform.md @@ -31,17 +35,17 @@ const appGroup = process.env.DEBATELAB_APP_GROUP; // In-App Purchase). Without it a development build keeps to what the borrowed // signing profile allows. const appleAccount = process.env.DEBATELAB_APPLE_ACCOUNT; -if (build !== undefined && build !== '' && build !== 'dev') { - throw new Error(`DEBATELAB_BUILD must be "dev" or unset, not "${build}".`); +if (build !== undefined && build !== '' && !native) { + throw new Error(`DEBATELAB_BUILD must be "dev", "release" or unset, not "${build}".`); } if (appleAccount !== undefined && appleAccount !== '' && appleAccount !== '1') { throw new Error(`DEBATELAB_APPLE_ACCOUNT must be "1" or unset, not "${appleAccount}".`); } -if (appleAccount === '1' && build !== 'dev') { - throw new Error('DEBATELAB_APPLE_ACCOUNT only applies to a development build (DEBATELAB_BUILD=dev).'); +if (appleAccount === '1' && !native) { + throw new Error('DEBATELAB_APPLE_ACCOUNT only applies to a native build (DEBATELAB_BUILD=dev or release).'); } -if (appGroup && build !== 'dev') { - throw new Error('DEBATELAB_APP_GROUP only applies to a development build (DEBATELAB_BUILD=dev).'); +if (appGroup && !native) { + throw new Error('DEBATELAB_APP_GROUP only applies to a native build (DEBATELAB_BUILD=dev or release).'); } const bundleIdentifier = 'com.pistack.debateowl'; @@ -72,16 +76,21 @@ function iosLocale(strings: PermissionStrings) { // installed, which adds it too; expo-apple-authentication's plugin, also // applied on its own, adds Sign in with Apple. The borrowed signing profile // has neither, so a development build drops both unless it is signed by -// DebateOwl's own account (DEBATELAB_APPLE_ACCOUNT). -const withoutAccountEntitlements: ConfigPlugin = (config) => - withEntitlementsPlist(config, (mod) => { - delete mod.modResults['aps-environment']; - delete mod.modResults['com.apple.developer.applesignin']; - return mod; - }); +// DebateOwl's own account (DEBATELAB_APPLE_ACCOUNT). A release build without +// that account is the sideloaded IPA, which a free Apple ID re-signs: a free +// team cannot have Associated Domains either, so it keeps only the App Group. +const withoutAccountEntitlements = + (opts: { associatedDomains: boolean }): ConfigPlugin => + (config) => + withEntitlementsPlist(config, (mod) => { + delete mod.modResults['aps-environment']; + delete mod.modResults['com.apple.developer.applesignin']; + if (!opts.associatedDomains) delete mod.modResults['com.apple.developer.associated-domains']; + return mod; + }); // Native config for the Current Debate widget, Home Screen quick actions and -// Spotlight. Called only for a development build (DEBATELAB_BUILD=dev), after the +// Spotlight. Called only for a native build (DEBATELAB_BUILD=dev or release), after the // expo-widgets entry, so the Expo Go // config never changes. Each part turns on only when it can work: // - the widget needs the App Group, which is when app.config.ts adds expo-widgets; @@ -182,7 +191,7 @@ export function withRound4(config: ExpoConfig, opts: { appGroup?: string; projec } // Round 6 (docs/apple-platform.md): Sign in with Apple and Google, the -// time-sensitive timer alert, Handoff and push, for development builds only, +// time-sensitive timer alert, Handoff and push, for native builds only, // so the Expo Go config never changes. What needs DebateOwl's own Apple // account waits for DEBATELAB_APPLE_ACCOUNT. export function withApplePlatform(config: ExpoConfig, opts: { appleAccount: boolean }): ExpoConfig { @@ -196,9 +205,11 @@ export function withApplePlatform(config: ExpoConfig, opts: { appleAccount: bool }; config.extra = { ...config.extra, - // Google sign-in runs in any development build: the redirect comes back on the app's own scheme. + // Google sign-in runs in any native build: the redirect comes back on the app's own scheme. googleSignIn: true, signInWithApple: opts.appleAccount, + // Without the push entitlement iOS hands out no token, so the switch says so (src/lib/push.ts). + remotePush: opts.appleAccount, }; if (!opts.appleAccount) return config; config.ios = { ...config.ios, usesAppleSignIn: true }; @@ -268,7 +279,7 @@ export default ({ projectRoot }: ConfigContext): ExpoConfig => { }, }; - if (build !== 'dev') return config; + if (!native) return config; const en = permissionStrings(projectRoot, 'en'); config.ios!.infoPlist!.CFBundleAllowMixedLocalizations = true; @@ -281,8 +292,9 @@ export default ({ projectRoot }: ConfigContext): ExpoConfig => { // once the provisioning profile has Associated Domains and the website serves // apple-app-site-association; Expo Go cannot have them. config.ios!.associatedDomains = ['applinks:debateowl.com', 'applinks:www.debateowl.com']; + // The development client only in a development build; a release build runs its own bundle. + if (build === 'dev') config.plugins!.push('expo-dev-client'); config.plugins!.push( - 'expo-dev-client', [ 'expo-speech-recognition', { speechRecognitionPermission: en.speechRecognition, microphonePermission: en.microphone }, @@ -337,5 +349,5 @@ export default ({ projectRoot }: ConfigContext): ExpoConfig => { if (appleAccount === '1') return withIntents; // Registered before Expo applies the plugins list. Mods registered later act // first, so this one runs after the plugins have written their entitlements. - return withoutAccountEntitlements(withIntents); + return withoutAccountEntitlements({ associatedDomains: build === 'dev' })(withIntents); }; diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index d77718c80..789ee818f 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -24,6 +24,34 @@ "DEBATELAB_BUILD": "dev", "DEBATELAB_APP_GROUP": "group.com.pistack.debateowl" } + }, + "release-simulator": { + "distribution": "internal", + "node": "22.23.2", + "ios": { + "simulator": true, + "buildConfiguration": "Release" + }, + "env": { + "DEBATELAB_BUILD": "release", + "DEBATELAB_APP_GROUP": "group.com.pistack.debateowl" + } + }, + "sideload": { + "distribution": "internal", + "withoutCredentials": true, + "node": "22.23.2", + "buildArtifactPaths": ["ios/build/logs/*"], + "ios": { + "config": "sideload-ios.yml", + "buildConfiguration": "Release", + "applicationArchivePath": "ios/build/*.ipa" + }, + "env": { + "DEBATELAB_BUILD": "release", + "DEBATELAB_APP_GROUP": "group.com.pistack.debateowl", + "EXPO_PUBLIC_SUPABASE_ENV": "production" + } } } } diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 47e36671a..b9d9d0262 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -7,7 +7,7 @@ "start": "expo start --go", "export:web": "expo export --platform web", "typecheck": "tsc --noEmit && tsc --noEmit -p scripts", - "eas-build-post-install": "node scripts/patch-expo-widgets-bundle.mjs" + "eas-build-post-install": "node scripts/patch-expo-widgets-bundle.mjs && node scripts/patch-app-group-lookup.mjs" }, "dependencies": { "@debatelab/core": "*", diff --git a/apps/mobile/scripts/compiler-audit.mjs b/apps/mobile/scripts/compiler-audit.mjs new file mode 100644 index 000000000..5189cb1b1 --- /dev/null +++ b/apps/mobile/scripts/compiler-audit.mjs @@ -0,0 +1,38 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; + +const root = resolve(import.meta.dirname, '../../..'); +const require = createRequire(resolve(root, 'package.json')); +const babel = require('@babel/core'); +const compiler = require('babel-plugin-react-compiler'); +const source = resolve(root, 'apps/mobile/src'); +const files = readdirSync(source, { recursive: true }).filter((file) => file.endsWith('.tsx') && !file.endsWith('.web.tsx')).sort(); +const skips = []; +let successes = 0; +for (const file of files) { + const filename = `apps/mobile/src/${file}`; + babel.transformSync(readFileSync(resolve(source, file), 'utf8'), { + filename, + configFile: false, + babelrc: false, + parserOpts: { sourceType: 'module', plugins: ['typescript', 'jsx'] }, + plugins: [[compiler, { + target: '19', + logger: { + logEvent(_file, event) { + if (event.kind === 'CompileSuccess') successes += 1; + else if (event.kind === 'CompileError' || event.kind === 'CompileSkip') { + const detail = event.detail?.options ?? event.detail; + skips.push({ + file: filename, + line: detail?.loc?.start?.line ?? detail?.details?.[0]?.loc?.start?.line ?? event.loc?.start?.line, + reason: detail?.reason ?? event.reason, + }); + } + }, + }, + }]], + }); +} +process.stdout.write(`${JSON.stringify({ files: files.length, successes, skips }, null, 2)}\n`); diff --git a/apps/mobile/scripts/patch-app-group-lookup.mjs b/apps/mobile/scripts/patch-app-group-lookup.mjs new file mode 100644 index 000000000..eaab71c01 --- /dev/null +++ b/apps/mobile/scripts/patch-app-group-lookup.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +/** + * Lets the widgets, Live Activities and the share extension find their App + * Group in an IPA that AltStore re-signed with a free Apple ID. AltStore + * registers `group.com.pistack.debateowl` as `group.com.pistack.debateowl.` + * and writes the new name into each bundle's Info.plist as `ALTAppGroups`; + * expo-widgets and expo-share-intent read only their own key, so they would + * look for a group the signature does not carry and the app would run without + * them. With this patch each lookup takes the first `ALTAppGroups` entry when + * there is one and its own key otherwise, so every other build is unchanged. + * + * EAS runs it after installing (`eas-build-post-install` in package.json; the + * `sideload` custom build calls that script itself, before and after + * prebuild, because prebuild copies the share extension's template). It is + * safe to run again. See docs/development.md#sideloaded-ipa. + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; + +const require = createRequire(import.meta.url); +const dir = (name) => path.dirname(require.resolve(`${name}/package.json`)); +const altGroup = '(Bundle.main.object(forInfoDictionaryKey: "ALTAppGroups") as? [String])?.first'; + +const patches = [ + { + file: path.join(dir('expo-widgets'), 'ios/WidgetsStorage.swift'), + from: 'Bundle.main.object(forInfoDictionaryKey: "ExpoWidgetsAppGroupIdentifier") as? String', + to: `${altGroup} ?? (Bundle.main.object(forInfoDictionaryKey: "ExpoWidgetsAppGroupIdentifier") as? String)`, + count: 1, + }, + { + file: path.join(dir('expo-widgets'), 'ios/Widgets/TimelineProvider.swift'), + from: 'Bundle.main.object(forInfoDictionaryKey: "ExpoWidgetsAppGroupIdentifier") as? String', + to: `${altGroup} ?? (Bundle.main.object(forInfoDictionaryKey: "ExpoWidgetsAppGroupIdentifier") as? String)`, + count: 2, + }, + { + file: path.join(dir('expo-share-intent'), 'ios/ExpoShareIntentModule.swift'), + from: 'Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier")\n as? String', + to: `${altGroup} ?? (Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier")\n as? String)`, + count: 1, + }, + { + file: path.join(dir('expo-share-intent'), 'plugin/build/ios/ShareExtensionViewController.swift'), + from: 'let hostAppGroupIdentifier: String = ""', + to: `let hostAppGroupIdentifier: String = ${altGroup} ?? ""`, + count: 1, + }, +]; + +for (const { file, from, to, count } of patches) { + const source = readFileSync(file, 'utf8'); + const name = path.relative(path.dirname(path.dirname(file)), file); + if (source.includes('"ALTAppGroups"')) { + console.log(`${name}: already reads ALTAppGroups.`); + continue; + } + const found = source.split(from).length - 1; + if (found !== count) { + throw new Error(`${file}: expected ${count} App Group lookup(s), found ${found}; check whether this patch still fits.`); + } + writeFileSync(file, source.split(from).join(to)); + console.log(`${name}: reads ALTAppGroups first.`); +} diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index 891da9c8f..a4b91ecbe 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -276,7 +276,7 @@ async function shoot(page: Page, name: string) { } /** - * A finished round or case opens under its celebration: shoot it as `name`, then tap it away + * A finished round or a new case, attack, brainstorm or factoids result opens under its celebration: shoot it as `name`, then tap it away * (unless it already went on its own) and wait until the screen underneath is clear. */ async function shootCelebration(page: Page, name: string) { @@ -744,13 +744,14 @@ async function shootResultScreens(context: BrowserContext, prefix: string, langu // A wide iPad shows the outline rail: jumping to a part scrolls that part's heading to the top of the case. const outline = page.getByTestId('document-outline'); if (await outline.isVisible()) { - const part = /^(Case structure|Esetstruktúra|Estructura del caso)$/i; + // A part far enough from the end of the case to reach the top. + const part = /^(If they say…|Ha azt mondják…|Si dicen…)$/; const heading = page.getByTestId('case-document').getByRole('heading', { name: part }); const before = (await heading.boundingBox())?.y ?? 0; await outline.getByRole('menuitem').filter({ hasText: part }).click(); await page.waitForTimeout(800); const after = (await heading.boundingBox())?.y ?? 0; - if (!(after < before && after < 200)) throw new Error(`The outline did not scroll to Case Structure (heading at ${before}, then ${after})`); + if (!(after < before && after < 200)) throw new Error(`The outline did not scroll to If they say (heading at ${before}, then ${after})`); await shoot(page, `${prefix}-case-outline`); } // Close the open argument, so every argument shows collapsed. @@ -801,6 +802,7 @@ async function shootResultScreens(context: BrowserContext, prefix: string, langu await shoot(page, `${prefix}-attack-form`); await page.getByRole('button', { name: /^(Find weaknesses|Gyengeségek keresése|Encontrar debilidades)$/i }).click(); await page.getByTestId('attack-document').waitFor({ timeout: 30_000 }); + await shootCelebration(page, `${prefix}-attack-celebration`); await page.waitForTimeout(400); await shoot(page, `${prefix}-attack-result`); // The case stays mounted under the attack, so its menu is looked up inside the attack. @@ -857,6 +859,7 @@ async function shootTool(context: BrowserContext, prefix: string, language: stri const page = await open(false); await page.getByRole('button', { name: spec.submit }).click(); await page.getByTestId(spec.document).waitFor({ timeout: 30_000 }); + await shootCelebration(page, `${prefix}-${tool}-celebration`); await page.waitForTimeout(400); await shoot(page, `${prefix}-${tool}`); // Close the open item, so every item shows collapsed. @@ -1745,6 +1748,8 @@ async function shootVoiceScreens(context: BrowserContext, prefix: string, langua await page.getByTestId('sparring-input').waitFor({ timeout: 15_000 }); await page.getByTestId('sparring-input').fill(text.speeches[1]); await page.getByTestId('sparring-main').click(); + // A strong score keeps its celebration up until it is tapped away. + await shootCelebration(page, `${prefix}-voice-celebration`); await page.getByTestId('spoken-delivery').waitFor({ timeout: 15_000 }); await page.getByTestId('spoken-delivery').scrollIntoViewIfNeeded(); await page.waitForTimeout(400); @@ -2768,7 +2773,8 @@ async function main() { await page.goto(`${ORIGIN}/sign-in`); await page.getByRole('tablist').waitFor(); await shoot(page, `${prefix}-sign-in`); - await page.getByRole('button').first().click(); + // The language menu sits above the form, so press Send code by name. + await page.getByRole('button', { name: /^(Send code|Kód küldése|Enviar código)$/ }).click(); await page.getByRole('alert').waitFor(); await shoot(page, `${prefix}-sign-in-error`); await page.getByRole('tab').nth(1).click(); diff --git a/apps/mobile/src/app/(tabs)/(debate)/index.tsx b/apps/mobile/src/app/(tabs)/(debate)/index.tsx index 78772f15a..f666ed493 100644 --- a/apps/mobile/src/app/(tabs)/(debate)/index.tsx +++ b/apps/mobile/src/app/(tabs)/(debate)/index.tsx @@ -4,10 +4,12 @@ import { useTranslation } from 'react-i18next'; import { Pressable, StyleSheet, View } from 'react-native'; import Animated from 'react-native-reanimated'; +import { PressScale as BouncePressable } from '@/components/press-scale'; +import { runRequest } from '@/lib/run-request'; import { AnnouncementBanner } from '@/components/announcement-banner'; import { TrialBanner } from '@/components/trial-banner'; import { MainActionButton, TextButton } from '@/components/buttons'; -import { Card, IconTile, ListItemIn, usePressScale } from '@/components/card'; +import { Card, IconTile, ListItemIn, SessionEntrance, usePressScale } from '@/components/card'; import { Icon } from '@/components/icon'; import type { IconName } from '@/components/icon-paths'; import { OneOffNotice } from '@/components/one-off-notice'; @@ -18,9 +20,12 @@ import { Skeleton, SkeletonRows } from '@/components/skeleton'; import { SOURCE_ICONS, sourceMeta } from '@/components/source-display'; import { Text } from '@/components/text'; import { WorkRow } from '@/components/work-row'; +import { takeNewDebate } from '@/lib/debate-arrival'; +import { PopIn } from '@/components/celebration'; import { publishCurrentDebate } from '@/lib/debate-widget'; import { indexForSpotlight, spotlightAvailable } from '@/lib/spotlight'; import { SPOTLIGHT_LIMIT, spotlightWorkItems } from '@/lib/spotlight-items'; +import { useLatest } from '@/lib/live-value'; import { useAuth } from '@/lib/auth'; import { useDebates } from '@/lib/debate-context'; import { @@ -120,10 +125,8 @@ export default function DebateScreen() { - - {current.motion} - - + + ); @@ -135,15 +138,37 @@ export default function DebateScreen() { - - {current.motion} - - + + ); } +/** A newly created debate greets once on focus, after its sheet has closed. */ +function DebateMotion({ debate, wide = false }: { debate: Debate; wide?: boolean }) { + const [welcome, setWelcome] = useState(false); + useFocusEffect(useCallback(() => { + setWelcome(takeNewDebate(debate.id)); + return () => setWelcome(false); + }, [debate.id])); + const motion = ( + + {debate.motion} + + ); + return ( + + {welcome ? {motion} : motion} + {welcome ? ( + + + + ) : null} + + ); +} + /** * The website's settings card under the motion (format and instructions), with * the role and judge the app's form adds, and Edit settings. @@ -172,17 +197,17 @@ function DebateSettingsCard({ debate }: { debate: Debate }) { ) : null} - router.push('/edit-debate')} testID="edit-debate" hitSlop={6} - style={({ pressed }) => [styles.editButton, { backgroundColor: colors.tile }, pressed && styles.pressed]}> + style={[styles.editButton, { backgroundColor: colors.tile }]}> {t('debate.editSettings')} - + ); } @@ -191,17 +216,17 @@ function SwitcherPill({ onPress }: { onPress: () => void }) { const { t } = useTranslation(); const { colors } = useTheme(); return ( - [styles.pill, { backgroundColor: colors.tile }, pressed && styles.pressed]}> + style={[styles.pill, { backgroundColor: colors.tile }]}> {t('nav.currentDebate')} - + ); } @@ -218,8 +243,7 @@ function DebateDetails({ debateId, dashboard = false }: { debateId: string; dash const userId = session?.user.id ?? null; // Read when counts land, for the Home Screen widget; not a reason to load again. const { current } = useDebates(); - const currentRef = useRef(current); - currentRef.current = current; + const currentRef = useLatest(current); // Retry and the reload after a delete run with no focus guard; a component gone since then keeps an old // currentRef, so Spotlight also asks whether this one is still on screen. const mounted = useRef(true); @@ -242,12 +266,12 @@ function DebateDetails({ debateId, dashboard = false }: { debateId: string; dash if (!userId) return; const limit = dashboard ? DASHBOARD_RECENT_LIMIT : RECENT_LIMIT; const mark = copies.mark(); - try { + return await runRequest(async () => { const [nextCounts, pro, con, nextSources] = await Promise.all([ - fetchDebateCounts(userId, debateId, mark), - fetchRecentWork(userId, debateId, 'pro', limit, mark), - fetchRecentWork(userId, debateId, 'con', limit, mark), - dashboard ? fetchSources(userId, debateId, mark) : null, + fetchDebateCounts(userId, debateId, mark, (copy) => { if (isCurrent()) setCounts(copy); }), + fetchRecentWork(userId, debateId, 'pro', limit, mark, (copy) => { if (isCurrent()) setRecent((previous) => ({ pro: copy, con: previous?.con ?? [] })); }), + fetchRecentWork(userId, debateId, 'con', limit, mark, (copy) => { if (isCurrent()) setRecent((previous) => ({ pro: previous?.pro ?? [], con: copy })); }), + dashboard ? fetchSources(userId, debateId, mark, (copy) => { if (isCurrent()) setSources(copy.slice(0, DASHBOARD_SOURCES)); }) : null, ]); if (!isCurrent()) return; setCounts(nextCounts); @@ -268,12 +292,12 @@ function DebateDetails({ debateId, dashboard = false }: { debateId: string; dash setRecent({ pro, con }); setSources(nextSources?.slice(0, DASHBOARD_SOURCES) ?? null); setFailed(false); - } catch (cause) { + }, async (cause) => { if (!isCurrent()) return; console.warn('Could not load the debate:', cause instanceof Error ? cause.message : cause); setFailed(true); - } - }, [userId, debateId, dashboard, copies, t]); + }); + }, [userId, debateId, dashboard, copies, t, currentRef]); // Reload on focus, so work saved on the website shows up. Out of view, its saved copies no longer count. useFocusEffect( @@ -318,7 +342,7 @@ function DebateDetails({ debateId, dashboard = false }: { debateId: string; dash ); - const spar = router.push({ pathname: '/sparring', params: { from: 'debate' } })} />; + const spar = router.push({ pathname: '/sparring', params: { from: 'debate' } })} />; const prepare = ( <> @@ -326,9 +350,9 @@ function DebateDetails({ debateId, dashboard = false }: { debateId: string; dash {PREPARE_TOOLS.map((tool, index) => ( - + router.push(prepareToolHref(tool.key, 'debate'))} /> - + ))} @@ -467,8 +491,8 @@ function RecentSourceRow({ source, first, onPress }: { source: Source; first: bo onPressOut={press.onPressOut} testID="recent-source" style={!first ? { borderTopWidth: 1, borderTopColor: colors.line } : undefined}> - {({ pressed }) => ( - + {() => ( + @@ -579,7 +603,6 @@ function DebateSkeleton() { const styles = StyleSheet.create({ body: { fontSize: 16, lineHeight: 22 }, - pressed: { opacity: 0.7 }, pill: { alignSelf: 'flex-start', flexDirection: 'row', diff --git a/apps/mobile/src/app/(tabs)/(library)/library.tsx b/apps/mobile/src/app/(tabs)/(library)/library.tsx index 31a074c7c..998fa77ba 100644 --- a/apps/mobile/src/app/(tabs)/(library)/library.tsx +++ b/apps/mobile/src/app/(tabs)/(library)/library.tsx @@ -22,6 +22,7 @@ import { SourceRow } from '@/components/source-row'; import { SourceView } from '@/components/source-view'; import { Text } from '@/components/text'; import { useToast } from '@/components/toast'; +import { useLatest } from '@/lib/live-value'; import { useAuth } from '@/lib/auth'; import { confirmDestructive, promptText } from '@/lib/confirm'; import { useDebates } from '@/lib/debate-context'; @@ -191,11 +192,11 @@ function LibraryView({ debateId, layout }: { debateId: string | null; layout: Re )} {shown && selected && userId ? ( - + <> {/* Keyed by the id that was opened: a note that syncs swaps its local id for the row's, and the view follows that itself, so an edit in progress survives the sync. */} - setSelected((current) => (current?.id === selected.id ? null : current))} /> - + setSelected((current) => (current?.id === selected.id ? null : current))} /> + ) : null} ); @@ -222,13 +223,18 @@ function useLibrary(debateId: string | null) { if (!userId || !debateId) return; const load = ++loads.current; try { - const next = await fetchSources(userId, debateId, copies.mark()); + const next = await fetchSources(userId, debateId, copies.mark(), (copy) => { + if (load !== loads.current) return; + setSources(copy); + setFailed(false); + }); if (load !== loads.current) return; setSources(next); setFailed(false); } catch (cause) { if (load !== loads.current) return; console.warn('Could not load sources:', cause instanceof Error ? cause.message : cause); + setSources(null); setFailed(true); } }, [userId, debateId, copies]); @@ -238,7 +244,7 @@ function useLibrary(debateId: string | null) { useFocusEffect( useCallback(() => { void load(); - return () => copies.release(); + return () => { loads.current += 1; copies.release(); }; }, [load, copies]), ); useEffect(() => subscribeSourcesChanged(() => void load()), [load]); @@ -261,11 +267,10 @@ function useLibrary(debateId: string | null) { // 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; + const chosen = useLatest(folder); useEffect(() => { if (chosen.current !== ALL && !folders.includes(chosen.current)) setFolder(ALL); - }, [folders]); + }, [folders, chosen]); const inFolder = useMemo( () => (sources ?? []).filter((source) => folder === ALL || normalizeFolder(source.folder) === folder), @@ -499,5 +504,5 @@ const styles = StyleSheet.create({ listContent: { paddingHorizontal: 20, paddingBottom: 32 }, listGap: { gap: 16 }, detailPane: { flex: 1 }, - detailContent: { width: '100%', maxWidth: 700, alignSelf: 'center', paddingHorizontal: 36, paddingTop: 14, paddingBottom: 48, gap: 14 }, + detailContent: { width: '100%', maxWidth: 700, alignSelf: 'center', paddingHorizontal: 36, paddingTop: 14, paddingBottom: 48 }, }); diff --git a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx index 4c03bdce9..f482c3a4f 100644 --- a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx +++ b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx @@ -3,7 +3,7 @@ import { useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { StyleSheet, View } from 'react-native'; -import { Card, IconTile, ListItemIn } from '@/components/card'; +import { Card, IconTile, ListItemIn, SessionEntrance } from '@/components/card'; import { EmptyState } from '@/components/empty-state'; import { Icon } from '@/components/icon'; import { PressScale } from '@/components/press-scale'; @@ -28,6 +28,7 @@ const TOOL_ROUTES: Record = { sparring: { pathname: '/sparring', params: { from: 'practice' } }, timer: '/timer', 'speech-time': { pathname: '/speech-time', params: { from: 'practice' } }, + 'flow-sheet': { pathname: '/flow-sheet', params: { from: 'practice' } }, motions: { pathname: '/motions', params: { from: 'practice' } }, }; @@ -85,7 +86,7 @@ export default function PracticeScreen() { {PRACTICE_TOOLS.map((tool, index) => ( - 0 && { borderTopWidth: 1, borderTopColor: colors.line }}> + 0 && { borderTopWidth: 1, borderTopColor: colors.line }}> router.push(TOOL_ROUTES[tool.key])} testID={`practice-tool-${tool.key}`} style={styles.row}> @@ -98,7 +99,7 @@ export default function PracticeScreen() { - + ))} diff --git a/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx b/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx index 5a2cde705..6cf9d7505 100644 --- a/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx +++ b/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { StyleSheet, View } from 'react-native'; import { TextButton } from '@/components/buttons'; -import { Card, IconTile, ListItemIn } from '@/components/card'; +import { Card, IconTile, ListItemIn, SessionEntrance } from '@/components/card'; import { Icon } from '@/components/icon'; import { OneOffNotice } from '@/components/one-off-notice'; import { PressScale } from '@/components/press-scale'; @@ -48,7 +48,9 @@ export default function PrepareScreen() { const request = ++loads.current; const isCurrent = () => loads.current === request; try { - const next = await fetchPrepareWork(userId, forDebate, SAVED_LIMIT, copies.mark()); + const next = await fetchPrepareWork(userId, forDebate, SAVED_LIMIT, copies.mark(), (copy) => { + if (isCurrent()) setLoaded({ debateId: forDebate, items: copy, failed: false }); + }); if (!isCurrent()) return; // Every focus rereads the list; an unchanged one keeps its identity, so the rows do not re-render. setLoaded((previous) => keepIfSame(previous, { debateId: forDebate, items: next, failed: false })); @@ -76,7 +78,7 @@ export default function PrepareScreen() { {PREPARE_TOOLS.map((tool, index) => ( - 0 && { borderTopWidth: 1, borderTopColor: colors.line }}> + 0 && { borderTopWidth: 1, borderTopColor: colors.line }}> openTool(tool.key)} testID={`prepare-tool-${tool.key}`} style={styles.row}> @@ -89,7 +91,7 @@ export default function PrepareScreen() { - + ))} diff --git a/apps/mobile/src/app/(tabs)/(you)/you.tsx b/apps/mobile/src/app/(tabs)/(you)/you.tsx index c815b29bb..a5b2865cb 100644 --- a/apps/mobile/src/app/(tabs)/(you)/you.tsx +++ b/apps/mobile/src/app/(tabs)/(you)/you.tsx @@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next'; import { ActivityIndicator, AppState, Linking, Pressable, StyleSheet, View, type PressableProps } from 'react-native'; import Animated from 'react-native-reanimated'; +import { runRequest } from '@/lib/run-request'; import { TextButton } from '@/components/buttons'; import { Card, IconTile, usePressScale } from '@/components/card'; import { SwitchRow } from '@/components/form-rows'; @@ -31,6 +32,8 @@ import { fetchSubscription, type Subscription } from '@/lib/subscription'; import { supabaseEnv } from '@/lib/supabase-config'; import { websitePage } from '@/lib/website'; import { useTheme } from '@/theme/use-theme'; +import { dailyResetTime } from '@/lib/usage'; +import { signInMethodKey } from '@/lib/sign-in-method'; type BadgeTone = 'plan' | 'pro' | 'good' | 'bad'; @@ -84,6 +87,7 @@ export default function YouScreen() { const { colors } = useTheme(); const { session, signOut } = useAuth(); const settings = useSettings(); + const signInMethod = signInMethodKey(session?.user ?? null); const [saveFailed, setSaveFailed] = useState(false); const [exporting, setExporting] = useState(false); @@ -102,16 +106,16 @@ export default function YouScreen() { const forUser = owner.current; if (!forUser || exporting) return; setExporting(true); - try { + return await runRequest(async () => { const { fileName, json } = await exportAccountData(); if (!live.current || owner.current !== forUser) return; await shareJson(json, fileName, t('settings.downloadData')); - } catch (cause) { + }, async (cause) => { console.warn('Could not download the data:', cause instanceof Error ? cause.message : cause); if (live.current && owner.current === forUser) showNotice(t('settings.downloadDataFailed')); - } finally { + }, () => { if (live.current) setExporting(false); - } + }); }; const save = (change: () => Promise) => { @@ -126,7 +130,7 @@ export default function YouScreen() { - + {session?.user.email ?? ''} + {signInMethod ? {t(signInMethod)} : null} {supabaseEnv === 'staging' ? ( @@ -285,7 +290,7 @@ function PressRow({ divider = false, children, ...props }: Omit - {({ pressed }) => {children}} + {() => {children}} ); } @@ -383,7 +388,7 @@ function PlanBadge({ state }: { state: BillingStateName }) { } function SubscriptionCard({ userId }: { userId: string | undefined }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { colors } = useTheme(); const router = useRouter(); const canBuy = storeKit() !== null; @@ -392,16 +397,16 @@ function SubscriptionCard({ userId }: { userId: string | undefined }) { const load = useCallback(async (isCurrent: () => boolean) => { if (!userId) return; - try { + return await runRequest(async () => { const next = await fetchSubscription(userId); if (!isCurrent()) return; setSubscription(next); setFailed(false); - } catch (cause) { + }, async (cause) => { if (!isCurrent()) return; console.warn('Could not load the subscription:', cause instanceof Error ? cause.message : cause); setFailed(true); - } + }); }, [userId]); // Usage changes as the student works, so read it again whenever You comes into view. @@ -444,14 +449,17 @@ function SubscriptionCard({ userId }: { userId: string | undefined }) { {t(DATE_LABELS[subscription.billingDateKind]!, { date: formatDate(subscription.billingDate) })} ) : null} - {subscription.dailyUsed !== null ? ( + {subscription.dailyUsed !== null && subscription.dailyLimit > 0 ? ( - {t('mobile.you.usage', { used: subscription.dailyUsed, limit: subscription.dailyLimit })} + {t('dailyLimit.remainingWithReset', { count: Math.max(0, subscription.dailyLimit - subscription.dailyUsed), limit: subscription.dailyLimit, time: dailyResetTime(i18n.language) })} ) : null} {planDescription(t, subscription)} + {subscription.hasOpenStripeSubscription ? ( + void WebBrowser.openBrowserAsync(websitePage('/app/settings')).catch(() => showNotice(t('common.error')))} /> + ) : null} {/* Basic and Pro through the App Store, where the build has StoreKit (plans.tsx). */} {canBuy ? router.push('/plans')} /> : null} @@ -478,6 +486,5 @@ const styles = StyleSheet.create({ rowText: { flex: 1, gap: 3 }, rowTitle: { fontSize: 16 }, email: { fontSize: 17 }, - pressed: { opacity: 0.6 }, inset: { paddingHorizontal: 16, paddingBottom: 12 }, }); diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 6bfb31cb4..25bacf621 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -11,6 +11,7 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { AppSidebar } from '@/components/app-sidebar'; import { KeyboardDoneBar } from '@/components/app-text-input'; +import { RoundFeedbackObserver } from '@/components/round-feedback-observer'; import { TimerRunner } from '@/components/timer-runner'; import { ToastHost, ToastProvider } from '@/components/toast'; import { AuthProvider, useAuth } from '@/lib/auth'; @@ -59,10 +60,6 @@ function Root() { // Settings come from the device here, so the app opens in the account's language and theme. const ready = (fontsLoaded || fontError !== null) && !loading && settingsReady; - useEffect(() => { - if (ready) void SplashScreen.hideAsync(); - }, [ready]); - // Offline, reads answer from saved copies and the screens say so. useEffect(() => watchNetwork(), []); // Notes, source edits and Active switches made offline sync when the connection returns. @@ -118,7 +115,7 @@ function Root() { {/* The screenshot script waits for auth-settled before it checks an offline cold start. Swipeable rows need the gesture root. */} - + { void SplashScreen.hideAsync(); }} style={styles.root} testID={settled ? 'auth-settled' : 'auth-pending'}> {session !== null && sidebarWidth > 0 ? : null} @@ -165,6 +162,7 @@ function Root() { + @@ -182,6 +180,7 @@ function Root() { {/* The timer's signals and Live Activity keep going while the timer screen is closed. */} + diff --git a/apps/mobile/src/app/add-file.tsx b/apps/mobile/src/app/add-file.tsx index b6b8177d7..7bf2374ac 100644 --- a/apps/mobile/src/app/add-file.tsx +++ b/apps/mobile/src/app/add-file.tsx @@ -6,6 +6,8 @@ import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { StyleSheet, View } from 'react-native'; +import { useLatest } from '@/lib/live-value'; +import { runRequest } from '@/lib/run-request'; import { MainActionButton, SecondaryButton, TextButton } from '@/components/buttons'; import { Card } from '@/components/card'; import { Field } from '@/components/field'; @@ -109,14 +111,14 @@ export default function AddFileSheet() { setContent(outcome.result.content); }; + const readAssetOnArrival = useLatest(readAsset); useEffect(() => { if (started.current) return; started.current = true; const asset = pickedFile(fileId); - if (asset) void readAsset(asset); + if (asset) void readAssetOnArrival.current(asset); // Reads the file it was opened with, once. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fileId]); + }, [fileId, readAssetOnArrival]); const chooseAnother = async () => { const asset = await pickSourceFile(); @@ -130,7 +132,7 @@ export default function AddFileSheet() { if (!draft || !ready || !userId || !current || saving) return; setSaving(true); setSaveError(''); - try { + return await runRequest(async () => { // The website matches a file by its name within the debate. const name = draft.origin.trim().toLowerCase(); const saved = await fetchSources(userId, current.id); @@ -138,13 +140,13 @@ export default function AddFileSheet() { 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) { + }, async (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() : ''; diff --git a/apps/mobile/src/app/add-link.tsx b/apps/mobile/src/app/add-link.tsx index 2ce3d37ed..d4291e46d 100644 --- a/apps/mobile/src/app/add-link.tsx +++ b/apps/mobile/src/app/add-link.tsx @@ -4,6 +4,8 @@ import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { StyleSheet, View } from 'react-native'; +import { useLatest } from '@/lib/live-value'; +import { runRequest } from '@/lib/run-request'; import { MainActionButton, PrimaryButton, TextButton } from '@/components/buttons'; import { Card } from '@/components/card'; import { Field } from '@/components/field'; @@ -53,19 +55,20 @@ export default function AddLinkSheet() { } setBusy('import'); setError(''); - try { + return await runRequest(async () => { const imported = await importSourceFromUrl(trimmed); setDraft(imported); setTitle(imported.title || shared?.title || ''); - } catch (cause) { + }, async (cause) => { console.warn('Could not import the source:', cause instanceof Error ? cause.message : cause); setError(`${t('libraryPage.importFailed')}. ${t('libraryPage.importFailedDesc')}`); - } finally { + }, () => { setBusy(null); - } + }); }; // A shared page is read once the Current Debate is known; the student still checks and saves it. + const runImportOnArrival = useLatest(runImport); const autoImported = useRef(false); useEffect(() => { if (fromShare) forgetShared(); @@ -73,17 +76,16 @@ export default function AddLinkSheet() { useEffect(() => { if (autoImported.current || !shared || !current) return; autoImported.current = true; - void runImport(); + void runImportOnArrival.current(); // runImport reads the address the hand-off filled in; it runs once per opening. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [shared, current]); + }, [shared, current, runImportOnArrival]); const save = async () => { const userId = session?.user.id; if (!draft || !userId || !current || !title.trim()) return; setBusy('save'); setError(''); - try { + return await runRequest(async () => { if (isDuplicateOrigin(await fetchSources(userId, current.id, copies.mark()), draft.url)) { setError(`${t('libraryPage.duplicateSource')}. ${t('libraryPage.duplicateSourceDesc')}`); setBusy(null); @@ -92,12 +94,12 @@ export default function AddLinkSheet() { 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) { + }, async (cause) => { console.warn('Could not save the source:', cause instanceof Error ? cause.message : cause); haptics.warning(); setError(t('mobile.library.saveFailed')); setBusy(null); - } + }); }; return ( diff --git a/apps/mobile/src/app/ask.tsx b/apps/mobile/src/app/ask.tsx index ab44b88df..3742b9e15 100644 --- a/apps/mobile/src/app/ask.tsx +++ b/apps/mobile/src/app/ask.tsx @@ -3,9 +3,11 @@ import type { AskOutput } from '@debatelab/core/ai/schemas/ask'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { ActivityIndicator, KeyboardAvoidingView, Platform, ScrollView, StyleSheet, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { PressScale as BouncePressable } from '@/components/press-scale'; +import { runRequest } from '@/lib/run-request'; import { AppTextInput } from '@/components/app-text-input'; import { AskAnswer } from '@/components/ask-answer'; import { AskChatPane } from '@/components/ask-chat-list'; @@ -213,23 +215,18 @@ export default function AskScreen() { const owner = conversation.current; saves.current = saves.current.then(async () => { if (owner !== conversation.current || recordRef.current.persisted >= snapshot.length) return; - try { + return await runRequest(async () => { const next = await saveChat(userId, recordRef.current, snapshot, activeDebateRef.current); if (next && owner === conversation.current) setRecord(next); - } catch (cause) { + }, async (cause) => { console.warn('Could not save the chat:', cause instanceof Error ? cause.message : cause); if (owner !== conversation.current) return; if (cause instanceof ChatSaveError) setRecord(cause.record); setNotice(t('debate.recordFailed')); - } + }); }); }; - // ⌘↩ sends, as the send button does. - useFocusedKeyCommand('cmd+enter', () => { - if (!busy && input.trim()) void send(input, messages, attachments); - }); - const send = async (text: string, history: ChatMessage[], files: AskAttachment[] = []) => { const question = text.trim(); if (!question || !userId) return; @@ -256,7 +253,7 @@ export default function AskScreen() { setLoading(true); const started = Date.now(); - try { + return await runRequest(async () => { const open = recordRef.current; const debateId = open.chatId ? open.debateId : activeDebateRef.current; const [askSettings, debateContext] = await Promise.all([ @@ -291,8 +288,8 @@ export default function AskScreen() { let pending = ''; let flush: ReturnType | null = null; setStreaming(''); - try { - result = await streamAsk(askRequest, { + result = await runRequest(async () => { + return await streamAsk(askRequest, { signal: controller.signal, onDelta: (delta) => { if (!isCurrent()) return; @@ -300,7 +297,7 @@ export default function AskScreen() { partial.current = received; pending += delta; // Tokens arrive faster than the screen needs; paint every 30 ms like the website. - flush ??= setTimeout(() => { + if (flush === null) flush = setTimeout(() => { flush = null; const batch = pending; pending = ''; @@ -308,23 +305,23 @@ export default function AskScreen() { }, 30); }, }); - } catch (streamError) { + }, async (streamError) => { if (isAbortError(streamError) || !isCurrent()) throw streamError; // Whitespace alone is not an answer (main #187): a model that spent its cap on // reasoning may send a newline first. Ask the structured endpoint then. if (received.trim()) { setNotice(`${t('ask.streamInterrupted')} ${t('ask.streamInterruptedDesc')}`); - result = { response: received.trim(), followUpQuestions: [], uncertainties: [t('ask.streamInterrupted')] }; + return { response: received.trim(), followUpQuestions: [], uncertainties: [t('ask.streamInterrupted')] }; } else if (isFinalAskError(streamError)) { throw streamError; } else { // Nothing usable arrived: ask once more without streaming, as the website does. setStreaming(null); - result = await askOnce(askRequest, controller.signal, keepExtras, showText); + return await askOnce(askRequest, controller.signal, keepExtras, showText); } - } finally { + }, () => { if (flush) clearTimeout(flush); - } + }); } else { setStreaming(''); result = await askOnce(askRequest, controller.signal, keepExtras, showText); @@ -355,7 +352,7 @@ export default function AskScreen() { partial.current = ''; setMessages((previous) => [...previous, { id: nextId(), role: 'assistant', content: result.response, timestamp: Date.now(), output }]); announceAnswer(result.response); - } catch (cause) { + }, async (cause) => { if (!isCurrent() || isAbortError(cause)) return; setStreaming(null); const kind = cause instanceof AIError ? cause.kind : 'failed'; @@ -365,13 +362,13 @@ export default function AskScreen() { setError({ kind, question, files }); // The question stays as not sent, and the website's dialog opens too. openRefusal(router, cause, 'ask'); - } finally { + }, () => { if (isCurrent()) { setLoading(false); setStreaming(null); abort.current = null; } - } + }); }; // Discuss on a case or an attack opens Ask with the website's message and sends it once. @@ -420,15 +417,15 @@ export default function AskScreen() { setNotice(`${t('ask.saveFailed')}. ${t('debate.sourceNeedsDebate')}`); return false; } - try { + return await runRequest(async () => { await addSource(userId, { title: content.length > 30 ? `${content.slice(0, 30)}...` : content, content, type: 'text', folder: 'Ask', debateId }); setNotice(`${t('ask.saved')}. ${t('ask.savedDesc')}`); return true; - } catch (cause) { + }, async (cause) => { console.warn('Could not save the answer to the Library:', cause instanceof Error ? cause.message : cause); setNotice(`${t('ask.saveFailed')}. ${t('ask.sourceSaveFailedDesc')}`); return false; - } + }); }; // Stable, so the saved messages do not render again for a keystroke or a token. @@ -436,15 +433,15 @@ export default function AskScreen() { const onSaveAnswer = useCallback((content: string) => saveAnswerRef.current(content), [saveAnswerRef]); const attach = async () => { - try { + return await runRequest(async () => { const picked = await pickAskFiles(attachments); if (!picked) return; setAttachments(picked.files); if (picked.problem?.kind === 'tooMany') setNotice(`${t('askChat.tooManyFiles')}. ${t('askChat.tooManyFilesDesc', { max: 5 })}`); else if (picked.problem) setNotice(`${t('askChat.fileValidationFailed')}. ${t(picked.problem.kind === 'tooLarge' ? 'mobile.ask.fileTooLarge' : 'mobile.ask.fileType', { name: picked.problem.name })}`); - } catch (cause) { + }, async (cause) => { console.warn('Could not pick files:', cause instanceof Error ? cause.message : cause); - } + }); }; const openChat = async (chatId: string) => { @@ -453,17 +450,17 @@ export default function AskScreen() { startConversation([], NEW_CHAT); const owner = conversation.current; setOpening(true); - try { + return await runRequest(async () => { const chat = await fetchChat(userId, chatId, chatCopies.mark()); if (owner !== conversation.current) return; setMessages(chat.messages); setRecord({ chatId, debateId: chat.debateId, persisted: chat.messages.length }); - } catch (cause) { + }, async (cause) => { console.warn('Could not open the chat:', cause instanceof Error ? cause.message : cause); if (owner === conversation.current) setNotice(t('mobile.ask.openFailed')); - } finally { + }, () => { if (owner === conversation.current) setOpening(false); - } + }); }; // A chat picked in History opens here, as the website's Resume loads it into Ask. Once per opening. @@ -477,6 +474,11 @@ export default function AskScreen() { const subtitle = oneOff ? t('debate.oneOffActive') : current?.motion ?? ''; const busy = loading || opening; + + // ⌘↩ sends, as the send button does. + useFocusedKeyCommand('cmd+enter', () => { + if (!busy && input.trim()) void send(input, messages, attachments); + }); // Beside the list, the chat is titled by its first question, as its saved row is. const question = messages.find((message) => message.role === 'user')?.content; const title = split ? question ?? t('askChat.newChat') : t('nav.ask'); @@ -515,9 +517,9 @@ export default function AskScreen() { {split ? ( ) : ( - router.back()} style={styles.headerButton}> + router.back()} style={styles.headerButton}> - + )} @@ -532,7 +534,7 @@ export default function AskScreen() { {split ? ( ) : ( - - + )} @@ -583,6 +585,7 @@ export default function AskScreen() { {error ? ( + {t('ask.failed')} @@ -625,7 +628,7 @@ export default function AskScreen() { ) : null} - - + {loading ? ( // The website's Stop: the answer so far stays in the chat. - - + ) : ( - {busy ? : } - + )} diff --git a/apps/mobile/src/app/attack/[id].tsx b/apps/mobile/src/app/attack/[id].tsx index ef69876e9..60f9d8b79 100644 --- a/apps/mobile/src/app/attack/[id].tsx +++ b/apps/mobile/src/app/attack/[id].tsx @@ -1,6 +1,9 @@ import { useLocalSearchParams, useRouter } from 'expo-router'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { View } from 'react-native'; + +import { Celebration, clearCelebration, hasCelebration } from '@/components/celebration'; import { ActionMenu } from '@/components/action-menu'; import { AttackDocument } from '@/components/attack-document'; @@ -29,6 +32,8 @@ export default function AttackResultScreen() { const { id, from, notice: arrival, failed } = useLocalSearchParams<{ id: string; from?: string; notice?: string; failed?: string }>(); const doc = useResultDocument('attack', id); const [exportOpen, setExportOpen] = useState(false); + const [celebrate, setCelebrate] = useState(() => hasCelebration(id)); + useEffect(() => clearCelebration(id), [id]); const { setNotice } = doc; const shareLink = useShareLink(setNotice); @@ -79,41 +84,51 @@ export default function AttackResultScreen() { ]; return ( - run(() => shareText(title, text()))} actions={actions} />} - actions={ - <> - - setExportOpen(true)} disabled={doc.busy} testID="export-button" /> - - }> - - - setExportOpen(false)} - onSelect={(choice) => { - if (choice === 'pdf') run(() => sharePdf(attackExportHtml(output, t), attackExportFileName(output), title)); - if (choice === 'docx') run(() => shareAttackDocx(output, t, title)); - if (choice === 'copy') run(async () => { - await copyText(text()); - setNotice(t('generateResults.copiedToClipboard')); - }); - }} - /> - + + run(() => shareText(title, text()))} actions={actions} />} + actions={ + <> + + setExportOpen(true)} disabled={doc.busy} testID="export-button" /> + + }> + + + setExportOpen(false)} + onSelect={(choice) => { + if (choice === 'pdf') run(() => sharePdf(attackExportHtml(output, t), attackExportFileName(output), title)); + if (choice === 'docx') run(() => shareAttackDocx(output, t, title)); + if (choice === 'copy') run(async () => { + await copyText(text()); + setNotice(t('generateResults.copiedToClipboard')); + }); + }} + /> + + {celebrate ? ( + setCelebrate(false)} + /> + ) : null} + ); } diff --git a/apps/mobile/src/app/attack/index.tsx b/apps/mobile/src/app/attack/index.tsx index 4479c3d1d..fb590d224 100644 --- a/apps/mobile/src/app/attack/index.tsx +++ b/apps/mobile/src/app/attack/index.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { StyleSheet, View } from 'react-native'; +import { runRequest } from '@/lib/run-request'; import { MainActionButton } from '@/components/buttons'; import { DocumentScreen } from '@/components/document'; import { DocumentHeader } from '@/components/document-header'; @@ -13,6 +14,7 @@ import { FormGroup, SelectRow, SwitchRow } from '@/components/form-rows'; import { ModelPicker } from '@/components/model-picker'; import { Text } from '@/components/text'; import { ContextLine } from '@/components/context-line'; +import { markCelebration } from '@/components/celebration'; import { LiveToolRun, useLiveRun } from '@/components/live-run'; import { ToolError, ToolTitle, WorkingIn } from '@/components/tool-form'; import { AIError, isAbortError, type AIErrorKind } from '@/lib/ai-request'; @@ -121,7 +123,7 @@ export default function AttackScreen() { abort.current = controller; setError(null); setPhase('attacking'); - try { + return await runRequest(async () => { const [config, debateContext] = await Promise.all([ readFeatureConfig(forUser, 'attack'), debateId ? buildDebateContext(forUser, debateId, 'attack', 'both') : Promise.resolve(undefined), @@ -145,16 +147,17 @@ export default function AttackScreen() { let savedId: string | null = null; let recordFailed = false; if (debateId) { - try { + await runRequest(async () => { savedId = await recordResult(forUser, 'attack', { title, input, output: result, debateId, side: 'neutral' }); - } catch (cause) { + }, async (cause) => { console.warn('Could not save the attack to the debate:', cause instanceof Error ? cause.message : cause); recordFailed = true; - } + }); } if (!isCurrent()) return; const key = keepOpenResult({ kind: 'attack', savedId, title, side: 'neutral', debateId, createdAt: Date.now(), input, output: result, sources: discoveredSources }); const notice = arrivalNotices({ recordFailed, sourcesFailed: sources.failed, assessed, truncated }); + markCelebration(key); liveRun.complete(); router.replace({ pathname: '/attack/[id]', @@ -165,7 +168,7 @@ export default function AttackScreen() { ...(sources.failed > 0 ? { failed: String(sources.failed) } : {}), }, }); - } catch (cause) { + }, async (cause) => { if (!isCurrent() || isAbortError(cause)) return; const kind = cause instanceof AIError ? cause.kind : 'failed'; if (!(cause instanceof AIError)) console.warn('Attack failed:', cause instanceof Error ? cause.message : cause); @@ -175,9 +178,9 @@ export default function AttackScreen() { // A limit or model refusal opens the website's dialog as a sheet, not an inline error. if (openRefusal(router, cause, 'attack')) return; setError(kind); - } finally { + }, () => { if (requests.current === request) abort.current = null; - } + }); }; const attacking = phase === 'attacking'; @@ -193,7 +196,7 @@ export default function AttackScreen() { { abortActive(); setPhase('form'); diff --git a/apps/mobile/src/app/brainstorm/[id].tsx b/apps/mobile/src/app/brainstorm/[id].tsx index 2402109f3..a9f6148f5 100644 --- a/apps/mobile/src/app/brainstorm/[id].tsx +++ b/apps/mobile/src/app/brainstorm/[id].tsx @@ -2,6 +2,9 @@ import type { BrainstormInput } from '@debatelab/core/ai/schemas/brainstorm'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { View } from 'react-native'; + +import { Celebration, clearCelebration, hasCelebration } from '@/components/celebration'; import { ActionMenu } from '@/components/action-menu'; import { BrainstormDocument } from '@/components/brainstorm-document'; @@ -29,6 +32,8 @@ export default function BrainstormResultScreen() { const params = useLocalSearchParams<{ id: string; from?: string; notice?: string; failed?: string }>(); const doc = useResultDocument('brainstorm', params.id); const [exportOpen, setExportOpen] = useState(false); + const [celebrate, setCelebrate] = useState(() => hasCelebration(params.id)); + useEffect(() => clearCelebration(params.id), [params.id]); const { setNotice } = doc; const shareLink = useShareLink(setNotice); const { notice: arrival, failed } = params; @@ -56,6 +61,11 @@ export default function BrainstormResultScreen() { ); } + const ideaCount = new Set([ + ...(output.topFive ?? []).map((item) => item.idea.id), + ...(output.buckets ?? []).flatMap((bucket) => bucket.ideas.map((idea) => idea.id)), + ...(output.mindmap ?? []).flatMap((group) => group.items.map((idea) => idea.id)), + ]).size; const title = result.title || t('export.brainstormResults'); const questionsOnly = Boolean(output.needsMoreInfo && output.clarifyingQuestions?.length); const run = (work: () => Promise) => { @@ -76,48 +86,58 @@ export default function BrainstormResultScreen() { ]; return ( - run(() => shareText(title, brainstormToText(output, t)))} actions={actions} />} - // The website shows only the questions when the model needs more, with no export or discussion. - actions={questionsOnly ? undefined : ( - <> - router.push({ pathname: '/ask', params: { prompt: discussBrainstormMessage(output, t) } })} - testID="discuss-button" - /> - setExportOpen(true)} disabled={doc.busy} testID="export-button" /> - - )}> - - } sources={result.sources} /> - setExportOpen(false)} - onSelect={(choice) => { - if (choice === 'pdf') run(() => sharePdf(brainstormExportHtml(output, t), BRAINSTORM_EXPORT_FILE_NAME, title)); - if (choice === 'docx') run(() => shareBrainstormDocx(output, t, title)); - if (choice === 'copy') run(async () => { - await copyText(brainstormToText(output, t)); - setNotice(t('generateResults.copiedToClipboard')); - }); - }} - /> - + + run(() => shareText(title, brainstormToText(output, t)))} actions={actions} />} + // The website shows only the questions when the model needs more, with no export or discussion. + actions={questionsOnly ? undefined : ( + <> + router.push({ pathname: '/ask', params: { prompt: discussBrainstormMessage(output, t) } })} + testID="discuss-button" + /> + setExportOpen(true)} disabled={doc.busy} testID="export-button" /> + + )}> + + } sources={result.sources} /> + setExportOpen(false)} + onSelect={(choice) => { + if (choice === 'pdf') run(() => sharePdf(brainstormExportHtml(output, t), BRAINSTORM_EXPORT_FILE_NAME, title)); + if (choice === 'docx') run(() => shareBrainstormDocx(output, t, title)); + if (choice === 'copy') run(async () => { + await copyText(brainstormToText(output, t)); + setNotice(t('generateResults.copiedToClipboard')); + }); + }} + /> + + {celebrate ? ( + setCelebrate(false)} + /> + ) : null} + ); } diff --git a/apps/mobile/src/app/brainstorm/index.tsx b/apps/mobile/src/app/brainstorm/index.tsx index 08344fecb..19e807ec2 100644 --- a/apps/mobile/src/app/brainstorm/index.tsx +++ b/apps/mobile/src/app/brainstorm/index.tsx @@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { StyleSheet } from 'react-native'; +import { runRequest } from '@/lib/run-request'; import { MainActionButton } from '@/components/buttons'; import { DocumentScreen } from '@/components/document'; import { DocumentHeader } from '@/components/document-header'; @@ -14,6 +15,7 @@ import { FormGroup, SelectRow } from '@/components/form-rows'; import { ModelPicker } from '@/components/model-picker'; import { Text } from '@/components/text'; import { ContextLine } from '@/components/context-line'; +import { markCelebration } from '@/components/celebration'; import { LiveToolRun, useLiveRun } from '@/components/live-run'; import { ToolError, ToolTitle, WorkingIn } from '@/components/tool-form'; import { AIError, isAbortError, type AIErrorKind } from '@/lib/ai-request'; @@ -102,7 +104,7 @@ export default function BrainstormScreen() { abort.current = controller; setError(null); setPhase('working'); - try { + return await runRequest(async () => { const [config, debateContext] = await Promise.all([ readFeatureConfig(forUser, 'brainstorm'), debateId ? buildDebateContext(forUser, debateId, 'brainstorm', input.side) : Promise.resolve(undefined), @@ -125,12 +127,12 @@ export default function BrainstormScreen() { let savedId: string | null = null; let recordFailed = false; if (debateId) { - try { + await runRequest(async () => { savedId = await recordResult(forUser, 'brainstorm', { title: input.motion, input, output: result, debateId, side: input.side }); - } catch (cause) { + }, async (cause) => { console.warn('Could not save the brainstorm to the debate:', cause instanceof Error ? cause.message : cause); recordFailed = true; - } + }); } if (!isCurrent()) return; const key = keepOpenResult({ @@ -145,6 +147,7 @@ export default function BrainstormScreen() { sources: discoveredSources, }); const notice = arrivalNotices({ recordFailed, sourcesFailed: sources.failed, assessed, truncated }); + if (!result.needsMoreInfo) markCelebration(key); liveRun.complete(); router.replace({ pathname: '/brainstorm/[id]', @@ -155,7 +158,7 @@ export default function BrainstormScreen() { ...(sources.failed > 0 ? { failed: String(sources.failed) } : {}), }, }); - } catch (cause) { + }, async (cause) => { if (!isCurrent() || isAbortError(cause)) return; const kind = cause instanceof AIError ? cause.kind : 'failed'; if (!(cause instanceof AIError)) console.warn('Brainstorm failed:', cause instanceof Error ? cause.message : cause); @@ -166,9 +169,9 @@ export default function BrainstormScreen() { // A limit or model refusal opens the website's dialog as a sheet, not an inline error. if (openRefusal(router, cause, 'brainstorm')) return; setError(kind); - } finally { + }, () => { if (requests.current === request) abort.current = null; - } + }); }; const submit = async () => { @@ -195,18 +198,20 @@ export default function BrainstormScreen() { }); if (!choice || !live.current || owner.current !== forUser) return; let debateId: string | null = null; - try { + const selected = await runRequest(async () => { if (choice.kind === 'switch') { await switchTo(choice.debateId); debateId = choice.debateId; } else if (choice.kind === 'create') { debateId = (await create({ motion: input.motion, settings: {} })).id; } - } catch (cause) { + return true; + }, async (cause) => { console.warn('Could not save the debate:', cause instanceof Error ? cause.message : cause); if (live.current) setError('debate'); - return; - } + return false; + }); + if (!selected) return; if (live.current && owner.current === forUser) void start(input, debateId, forUser); }; @@ -228,7 +233,7 @@ export default function BrainstormScreen() { { await togglePin(userId, plan.debateId, output.ref, plan.resultKeys); - } catch (cause) { + }, async (cause) => { if (!(cause instanceof PinLimitError)) console.warn('Could not save the pin:', cause instanceof Error ? cause.message : cause); setPinError(cause instanceof PinLimitError ? t('debateContext.pinLimit') : t('debateContext.pinFailed')); - } finally { + }, () => { setPinning(null); - } + }); }; return ( @@ -170,7 +172,7 @@ function CheckRow({ checked, label, meta, note, first, onChange, trailing }: { const { colors } = useTheme(); return ( - [styles.rowMain, pressed && styles.pressed]}> + style={[styles.rowMain]}> {checked ? : null} @@ -195,7 +197,7 @@ function CheckRow({ checked, label, meta, note, first, onChange, trailing }: { ) : null} - + {trailing} ); @@ -228,7 +230,7 @@ function OutputRow({ output, first, pinning, onChange, onPin }: { first={first} onChange={onChange} trailing={ - [styles.pin, output.pinned && { backgroundColor: colors.tile }, pressed && styles.pressed]} + style={[styles.pin, output.pinned && { backgroundColor: colors.tile }]} testID="debate-context-pin"> {pinning ? : } - + } /> ); @@ -262,5 +264,4 @@ const styles = StyleSheet.create({ rowLabel: { fontSize: 15.5, lineHeight: 21 }, rowMeta: { fontSize: 13.5, lineHeight: 18 }, pin: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center' }, - pressed: { opacity: 0.6 }, }); diff --git a/apps/mobile/src/app/crossfire-round/[id].tsx b/apps/mobile/src/app/crossfire-round/[id].tsx index 948a3aa3f..7298d20fb 100644 --- a/apps/mobile/src/app/crossfire-round/[id].tsx +++ b/apps/mobile/src/app/crossfire-round/[id].tsx @@ -1,6 +1,6 @@ import { combineDelivery } from '@debatelab/core/voice/delivery'; -import { useLocalSearchParams } from 'expo-router'; -import { useEffect, useState } from 'react'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { StyleSheet } from 'react-native'; @@ -12,9 +12,12 @@ import { Text } from '@/components/text'; import { DeliverySummary } from '@/components/voice'; import { useAuth } from '@/lib/auth'; import { backLabel } from '@/lib/back-label'; -import { fetchCrossfireRound, type SavedCrossfire } from '@/lib/crossfire'; +import { deleteCrossfireRound, fetchCrossfireRound, type SavedCrossfire } from '@/lib/crossfire'; +import { confirmDestructive, showNotice } from '@/lib/confirm'; +import { useLatest } from '@/lib/live-value'; import { formatWhen } from '@/lib/dates'; import { shareText } from '@/lib/export'; +import { runRequest } from '@/lib/run-request'; import { useCopyHolder } from '@/lib/use-copy-holder'; type State = { status: 'loading' } | { status: 'missing' } | { status: 'failed' } | { status: 'ready'; round: SavedCrossfire }; @@ -27,13 +30,41 @@ type State = { status: 'loading' } | { status: 'missing' } | { status: 'failed' */ export default function SavedCrossfireScreen() { const { t } = useTranslation(); + const router = useRouter(); const { id, from } = useLocalSearchParams<{ id: string; from?: string }>(); const { session } = useAuth(); const userId = session?.user.id ?? null; // The state remembers whose round it holds, so another account never sees it while its own read runs. - const [held, setHeld] = useState<{ owner: string | null; state: State }>({ owner: userId, state: { status: 'loading' } }); - const state: State = held.owner === userId ? held.state : { status: 'loading' }; + const [held, setHeld] = useState<{ owner: string | null; id: string; state: State }>({ owner: userId, id, state: { status: 'loading' } }); + const state: State = held.owner === userId && held.id === id ? held.state : { status: 'loading' }; const copies = useCopyHolder(); + const owner = useLatest(userId); + const roundId = useLatest(id); + const live = useRef(true); + useEffect(() => { + live.current = true; + return () => { live.current = false; }; + }, []); + + const remove = async () => { + const forUser = userId; + if (!forUser) return; + const confirmed = await confirmDestructive({ + title: t('historyPage.deleteCrossfireTitle'), + message: t('historyPage.deleteCrossfireDesc'), + confirm: t('common.delete'), + cancel: t('common.cancel'), + }); + if (!confirmed || !live.current || owner.current !== forUser || roundId.current !== id) return; + const stillHere = () => live.current && owner.current === forUser && roundId.current === id; + const deleted = await runRequest( + () => deleteCrossfireRound(forUser, id).then(() => true), + () => false, + ); + if (!stillHere()) return; + if (deleted) router.back(); + else showNotice(t('historyPage.crossfireDeleteFailed')); + }; useEffect(() => { if (!userId) return; @@ -41,11 +72,11 @@ export default function SavedCrossfireScreen() { let active = true; fetchCrossfireRound(forUser, id, copies.mark()) .then((round) => { - if (active) setHeld({ owner: forUser, state: round ? { status: 'ready', round } : { status: 'missing' } }); + if (active) setHeld({ owner: forUser, id, state: round ? { status: 'ready', round } : { status: 'missing' } }); }) .catch((cause: unknown) => { console.warn('Could not load the crossfire:', cause instanceof Error ? cause.message : cause); - if (active) setHeld({ owner: forUser, state: { status: 'failed' } }); + if (active) setHeld({ owner: forUser, id, state: { status: 'failed' } }); }); return () => { active = false; @@ -74,7 +105,7 @@ export default function SavedCrossfireScreen() { void shareText(resolution, `${resolution}\n\n${transcriptText}`).catch(() => {})} - actions={[]} + actions={[{ key: 'delete', label: t('common.delete'), symbol: 'trash', icon: 'trash', destructive: true, onPress: () => void remove() }]} /> }> diff --git a/apps/mobile/src/app/crossfire.tsx b/apps/mobile/src/app/crossfire.tsx index d8803c2df..e28495a08 100644 --- a/apps/mobile/src/app/crossfire.tsx +++ b/apps/mobile/src/app/crossfire.tsx @@ -1,3 +1,5 @@ +import { Owl } from '@/components/owl'; +import { PressScale as BouncePressable } from '@/components/press-scale'; import { createCrossfireSetupState, crossfireReducer, expectedUserTurnKind } from '@debatelab/core/crossfire/reducer'; import { CrossfireConfigSchema, @@ -13,7 +15,7 @@ import { combineDelivery, measureDelivery } from '@debatelab/core/voice/delivery import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; import { memo, useCallback, useEffect, useReducer, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, AppState, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { ActivityIndicator, AppState, KeyboardAvoidingView, Platform, ScrollView, StyleSheet, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { AppTextInput } from '@/components/app-text-input'; @@ -649,15 +651,15 @@ export default function CrossfireScreen() { {state.phase === 'active' ? ( - endSession('manual')} testID="crossfire-end" - style={({ pressed }) => [styles.end, { borderColor: colors.border, backgroundColor: colors.input }, pressed && styles.pressed]}> + style={[styles.end, { borderColor: colors.border, backgroundColor: colors.input }]}> {t('crossfire.endSession')} - + ) : null} @@ -689,6 +691,7 @@ export default function CrossfireScreen() { {state.phase === 'active' && state.request.status === 'failed' ? ( + {state.request.errorKind === 'timeout' ? t('crossfire.timeout') : state.request.errorKind === 'response' ? t('crossfire.invalidResponse') : t('crossfire.error')} @@ -716,7 +719,7 @@ export default function CrossfireScreen() { ) : ( <> - + {state.errorKind === 'timeout' ? t('crossfire.debriefTimeout') : t('crossfire.debriefError')} @@ -755,7 +758,7 @@ export default function CrossfireScreen() { style={[styles.input, { color: colors.foreground }]} testID="crossfire-input" /> - - + @@ -834,7 +837,6 @@ const styles = StyleSheet.create({ // Long labels ("Munkamenet befejezése") wrap instead of squeezing the resolution. end: { borderWidth: 1, borderRadius: 19, paddingHorizontal: 14, paddingVertical: 6, minHeight: 38, justifyContent: 'center', maxWidth: '38%' }, endLabel: { fontSize: 14.5, textAlign: 'center' }, - pressed: { opacity: 0.7 }, liveContent: { paddingHorizontal: 18, paddingTop: 16, paddingBottom: 24, gap: 14, width: '100%', maxWidth: 720, alignSelf: 'center' }, notice: { fontSize: 14, lineHeight: 19 }, thinking: { flexDirection: 'row', alignItems: 'center', gap: 10 }, diff --git a/apps/mobile/src/app/debate-check.tsx b/apps/mobile/src/app/debate-check.tsx index c3c1828e2..7db225464 100644 --- a/apps/mobile/src/app/debate-check.tsx +++ b/apps/mobile/src/app/debate-check.tsx @@ -9,6 +9,7 @@ import { SheetScrollView } from '@/components/sheet'; import { Text } from '@/components/text'; import { useDebates } from '@/lib/debate-context'; import { formatRelativeDate } from '@/lib/dates'; +import { useLatest } from '@/lib/live-value'; import { haptics } from '@/lib/haptics'; import { useTheme } from '@/theme/use-theme'; @@ -25,12 +26,11 @@ export default function DebateCheckSheet() { const [continuing, setContinuing] = useState(false); const [error, setError] = useState(''); const answered = useRef(false); - const latest = useRef(continueCurrent); - latest.current = continueCurrent; + const latest = useLatest(continueCurrent); useEffect(() => () => { if (!answered.current) latest.current().catch(() => {}); - }, []); + }, [latest]); const keep = async () => { if (continuing) return; diff --git a/apps/mobile/src/app/debates.tsx b/apps/mobile/src/app/debates.tsx index 5630bd95a..b7b6251e5 100644 --- a/apps/mobile/src/app/debates.tsx +++ b/apps/mobile/src/app/debates.tsx @@ -5,13 +5,16 @@ import { ActivityIndicator, Pressable, StyleSheet, Switch, View, type PressableP import ReanimatedSwipeable from 'react-native-gesture-handler/ReanimatedSwipeable'; import Animated from 'react-native-reanimated'; +import { PressScale as BouncePressable } from '@/components/press-scale'; +import { runRequest } from '@/lib/run-request'; import { AppTextInput } from '@/components/app-text-input'; import { PrimaryButton, TextButton } from '@/components/buttons'; import { Card, ListItemIn, SkeletonTextRows, usePressScale } from '@/components/card'; import { WEB_SWITCH_THUMB } from '@/components/form-rows'; import { Icon } from '@/components/icon'; -import { SheetScrollView } from '@/components/sheet'; +import { SheetList } from '@/components/sheet'; import { Text } from '@/components/text'; +import { CardRowFrame, useRowArrivals } from '@/components/list-screen'; import { useAuth } from '@/lib/auth'; import { confirmDestructive } from '@/lib/confirm'; import { useDebates } from '@/lib/debate-context'; @@ -74,14 +77,14 @@ export default function DebatesSheet() { haptics.select(); setSwitching(debate.id); setError(''); - try { + return await runRequest(async () => { await switchTo(debate.id); router.back(); - } catch { + }, async () => { setError(t('debate.switchFailed')); - } finally { + }, () => { setSwitching(null); - } + }); }; // The website deletes any debate but the current one, after its warning; its sources and saved work go too. @@ -96,169 +99,178 @@ export default function DebatesSheet() { if (!confirmed) return; setDeleting(debate.id); setError(''); - try { + return await runRequest(async () => { await remove(debate.id); haptics.success(); - } catch { + }, async () => { haptics.warning(); setError(t('debate.deleteFailed')); - } finally { + }, () => { setDeleting(null); - } + }); }; - return ( - - - - {t('debate.yourDebates')} - - router.back()} /> - - - - - - - - {/* Only before any debate is known: a refresh keeps showing the list it has. */} - {status === 'loading' && debates.length === 0 ? : null} - {status === 'error' ? ( - - - {t('mobile.debates.loadFailed')} + const arrivals = useRowArrivals(visible.map((debate) => debate.id)); + const renderDebate = (debate: Debate, index: number) => { + const isCurrent = debate.id === current?.id; + const sources = sourceCounts[debate.id]; + const when = isToday(debate.lastActivityAt) + ? t('mobile.debates.activeToday') + : formatRelativeDate(debate.lastActivityAt); + const row = ( + { + if (event.nativeEvent.actionName === 'delete') void removeDebate(debate); + }} + disabled={switching !== null} + onPress={() => void pick(debate)}> + + + {debate.motion} - void refresh()} /> - - ) : null} - {status === 'ready' && debates.length === 0 ? ( - - {t('debate.noOtherDebates')} - - ) : null} - {status === 'ready' && debates.length > 0 && visible.length === 0 ? ( - - {t('mobile.debates.noMatches')} - - ) : null} - {visible.length > 0 ? ( - - {visible.map((debate, index) => { - const isCurrent = debate.id === current?.id; - const sources = sourceCounts[debate.id]; - const when = isToday(debate.lastActivityAt) - ? t('mobile.debates.activeToday') - : formatRelativeDate(debate.lastActivityAt); - const row = ( - { - if (event.nativeEvent.actionName === 'delete') void removeDebate(debate); - }} - disabled={switching !== null} - onPress={() => void pick(debate)}> - - - {debate.motion} - - - {sources === undefined - ? when - : t('mobile.debates.meta', { sources: t('mobile.debates.sourceCount', { count: sources }), when })} - - - {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} + + {sources === undefined + ? when + : t('mobile.debates.meta', { sources: t('mobile.debates.sourceCount', { count: sources }), when })} + + + {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} + + + ); + }; + return ( + debate.id} + renderItem={({ item, index }) => {renderDebate(item, index)}} + header={ + <> + + + {t('debate.yourDebates')} + + router.back()} /> + - - - - + + + - - - {t('mobile.debates.oneOff')} + + {/* Only before any debate is known: a refresh keeps showing the list it has. */} + {status === 'loading' && debates.length === 0 ? : null} + {status === 'error' ? ( + + + {t('mobile.debates.loadFailed')} + + void refresh()} /> + + ) : null} + {status === 'ready' && debates.length === 0 ? ( + + {t('debate.noOtherDebates')} - - {t('mobile.debates.oneOffDesc')} + ) : null} + {status === 'ready' && debates.length > 0 && visible.length === 0 ? ( + + {t('mobile.debates.noMatches')} - - { - haptics.tap(); - setOneOff(value); - }} - accessibilityLabel={t('mobile.debates.oneOff')} - trackColor={{ false: colors.border, true: colors.primaryFill }} - ios_backgroundColor={colors.border} - thumbColor="#ffffff" - {...WEB_SWITCH_THUMB} - /> - - + ) : null} + + } + ListFooterComponent={ + <> + + + + + + + + {t('mobile.debates.oneOff')} + + + {t('mobile.debates.oneOffDesc')} + + + { + haptics.tap(); + setOneOff(value); + }} + accessibilityLabel={t('mobile.debates.oneOff')} + trackColor={{ false: colors.border, true: colors.primaryFill }} + ios_backgroundColor={colors.border} + thumbColor="#ffffff" + {...WEB_SWITCH_THUMB} + /> + + - {error ? ( - - {error} - - ) : null} + {error ? ( + + {error} + + ) : null} - router.push('/new-debate')} /> - + router.push('/new-debate')} /> + + } + /> ); } @@ -270,13 +282,12 @@ function DebateRowPress({ children, ...props }: Omit - {({ pressed }) => {children}} + {() => {children}} ); } const styles = StyleSheet.create({ - content: { gap: 14 }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, title: { fontSize: 21, lineHeight: 26, letterSpacing: -0.3, flexShrink: 1 }, search: { @@ -289,14 +300,12 @@ const styles = StyleSheet.create({ }, searchInput: { flex: 1, fontSize: 16.5, fontFamily: fontFamily.regular, paddingVertical: 10 }, body: { fontSize: 15, lineHeight: 21 }, - 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 }, meta: { fontSize: 14, lineHeight: 19 }, - pressed: { opacity: 0.6 }, oneOffCard: { padding: 0, gap: 0 }, oneOff: { flexDirection: 'row', alignItems: 'center', gap: 13, paddingVertical: 13, paddingHorizontal: 16 }, oneOffIcon: { width: 42, height: 42, borderRadius: radius.tile, alignItems: 'center', justifyContent: 'center' }, diff --git a/apps/mobile/src/app/factoids/[id].tsx b/apps/mobile/src/app/factoids/[id].tsx index 47b8d85e7..64097bb3e 100644 --- a/apps/mobile/src/app/factoids/[id].tsx +++ b/apps/mobile/src/app/factoids/[id].tsx @@ -3,7 +3,11 @@ import { SafeWebUrlSchema } from '@debatelab/core/ai/schemas/url'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { View } from 'react-native'; +import { Celebration, clearCelebration, hasCelebration } from '@/components/celebration'; + +import { runRequest } from '@/lib/run-request'; import { ActionMenu } from '@/components/action-menu'; import { PrimaryButton, SecondaryButton } from '@/components/buttons'; import { openCitation } from '@/components/citation-target'; @@ -39,6 +43,8 @@ export default function FactoidsResultScreen() { const userId = session?.user.id ?? null; const { activeDebateId } = useDebates(); const [exportOpen, setExportOpen] = useState(false); + const [celebrate, setCelebrate] = useState(() => hasCelebration(params.id)); + useEffect(() => clearCelebration(params.id), [params.id]); const [saving, setSaving] = useState(false); const live = useRef(true); const owner = useRef(userId); @@ -94,7 +100,7 @@ export default function FactoidsResultScreen() { } const url = SafeWebUrlSchema.safeParse(factoid.citation?.url); setSaving(true); - try { + return await runRequest(async () => { await addSource(forUser, { title: factoid.shortClaim, content: `${factoid.context}\n\nKey Figure: ${factoid.keyFigure}\nLimitations: ${factoid.limitations}`, @@ -104,12 +110,12 @@ export default function FactoidsResultScreen() { debateId, }); if (live.current && owner.current === forUser) setNotice(`${t('factoids.savedToLibrary')}. ${t('factoids.savedToLibraryDesc')}`); - } catch (cause) { + }, async (cause) => { console.warn('Could not save the factoid to the Library:', cause instanceof Error ? cause.message : cause); if (live.current && owner.current === forUser) setNotice(t('factoids.saveFailed')); - } finally { + }, () => { if (live.current) setSaving(false); - } + }); }; const savedId = result.savedId; @@ -122,56 +128,66 @@ export default function FactoidsResultScreen() { ]; return ( - run(() => shareText(title, factoidsToText(output, t)))} actions={actions} />} - actions={ - <> - router.push({ pathname: '/ask', params: { prompt: discussFactoidsMessage(output) } })} - testID="discuss-button" - /> - setExportOpen(true)} disabled={doc.busy} testID="export-button" /> - - }> - - } - library={doc.library} - sources={result.sources} - saving={saving} - onCite={(target) => openCitation(router, target)} - onSave={(factoid) => void saveToLibrary(factoid)} - onSearch={(query) => router.push({ pathname: '/factoids', params: { claim: query, from: params.from ?? 'prepare' } })} - /> - setExportOpen(false)} - onSelect={(choice) => { - if (choice === 'pdf') run(() => sharePdf(factoidsExportHtml(output, t), FACTOIDS_EXPORT_FILE_NAME, title)); - if (choice === 'docx') run(() => shareFactoidsDocx(output, t, title)); - if (choice === 'copy') run(async () => { - await copyText(factoidsToText(output, t)); - setNotice(t('generateResults.copiedToClipboard')); - }); - }} - /> - + + run(() => shareText(title, factoidsToText(output, t)))} actions={actions} />} + actions={ + <> + router.push({ pathname: '/ask', params: { prompt: discussFactoidsMessage(output) } })} + testID="discuss-button" + /> + setExportOpen(true)} disabled={doc.busy} testID="export-button" /> + + }> + + } + library={doc.library} + sources={result.sources} + saving={saving} + onCite={(target) => openCitation(router, target)} + onSave={(factoid) => void saveToLibrary(factoid)} + onSearch={(query) => router.push({ pathname: '/factoids', params: { claim: query, from: params.from ?? 'prepare' } })} + /> + setExportOpen(false)} + onSelect={(choice) => { + if (choice === 'pdf') run(() => sharePdf(factoidsExportHtml(output, t), FACTOIDS_EXPORT_FILE_NAME, title)); + if (choice === 'docx') run(() => shareFactoidsDocx(output, t, title)); + if (choice === 'copy') run(async () => { + await copyText(factoidsToText(output, t)); + setNotice(t('generateResults.copiedToClipboard')); + }); + }} + /> + + {celebrate ? ( + setCelebrate(false)} + /> + ) : null} + ); } diff --git a/apps/mobile/src/app/factoids/index.tsx b/apps/mobile/src/app/factoids/index.tsx index d1c72767d..fcdf0f6d9 100644 --- a/apps/mobile/src/app/factoids/index.tsx +++ b/apps/mobile/src/app/factoids/index.tsx @@ -6,6 +6,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { StyleSheet, View } from 'react-native'; +import { useLatest } from '@/lib/live-value'; +import { runRequest } from '@/lib/run-request'; import { MainActionButton } from '@/components/buttons'; import { DocumentScreen } from '@/components/document'; import { DocumentHeader } from '@/components/document-header'; @@ -15,6 +17,7 @@ import { ModelPicker } from '@/components/model-picker'; import { SegmentedControl } from '@/components/segmented-control'; import { Text } from '@/components/text'; import { ContextLine } from '@/components/context-line'; +import { markCelebration } from '@/components/celebration'; import { LiveToolRun, useLiveRun } from '@/components/live-run'; import { ToolError, ToolTitle, WorkingIn } from '@/components/tool-form'; import { AIError, isAbortError, type AIErrorKind } from '@/lib/ai-request'; @@ -110,7 +113,7 @@ export default function FactoidsScreen() { abort.current = controller; setError(null); setPhase('working'); - try { + return await runRequest(async () => { const [config, debateContext] = await Promise.all([ readFeatureConfig(forUser, 'factoids'), debateId ? buildDebateContext(forUser, debateId, 'factoids', input.direction === 'against' ? 'con' : 'pro') : Promise.resolve(undefined), @@ -133,12 +136,12 @@ export default function FactoidsScreen() { let savedId: string | null = null; let recordFailed = false; if (debateId) { - try { + await runRequest(async () => { savedId = await recordResult(forUser, 'factoids', { title: input.claim, input, output: result, debateId, side: 'neutral' }); - } catch (cause) { + }, async (cause) => { console.warn('Could not save the factoids to the debate:', cause instanceof Error ? cause.message : cause); recordFailed = true; - } + }); } if (!isCurrent()) return; const key = keepOpenResult({ @@ -153,6 +156,7 @@ export default function FactoidsScreen() { sources: discoveredSources, }); const notice = arrivalNotices({ recordFailed, sourcesFailed: sources.failed, assessed, truncated }); + markCelebration(key); liveRun.complete(); router.replace({ pathname: '/factoids/[id]', @@ -163,7 +167,7 @@ export default function FactoidsScreen() { ...(sources.failed > 0 ? { failed: String(sources.failed) } : {}), }, }); - } catch (cause) { + }, async (cause) => { if (!isCurrent() || isAbortError(cause)) return; const kind = cause instanceof AIError ? cause.kind : 'failed'; if (!(cause instanceof AIError)) console.warn('Factoids failed:', cause instanceof Error ? cause.message : cause); @@ -173,21 +177,22 @@ export default function FactoidsScreen() { // A limit or model refusal opens the website's dialog as a sheet, not an inline error. if (openRefusal(router, cause, 'factoids')) return; setError(kind); - } finally { + }, () => { if (requests.current === request) abort.current = null; - } + }); }; + const submitOnArrival = useLatest(submit); + // A suggestion searches its query with the rest of the last form, once, as the website's handleUseSuggestion. useEffect(() => { if (!suggested?.trim() || ranSuggestion.current || !userId) return; ranSuggestion.current = true; const input = { ...lastForm.get(), claim: suggested.trim() }; setForm({ claim: input.claim }); - void submit(input); + void submitOnArrival.current(input); // submit reads the latest debate and account itself; the suggestion runs only on arrival. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [suggested, userId]); + }, [suggested, userId, submitOnArrival, setForm]); const cancel = () => { abortActive(); @@ -207,7 +212,7 @@ export default function FactoidsScreen() { t(key, { ns: 'tools' }); + const { from } = useLocalSearchParams<{ from?: string }>(); + const tablet = useLayoutClass() !== 'phone'; + const [formatId, setFormatId] = useState('pf'); + const [paper, setPaper] = useState<'letter' | 'a4'>('letter'); + const [orientation, setOrientation] = useState<'landscape' | 'portrait'>('landscape'); + const [strip, setStrip] = useState(true); + const [header, setHeader] = useState({ motion: '', round: '', judge: '' }); + const [exporting, setExporting] = useState(false); + const format = FORMATS[formatId]; + + const exportPdf = async () => { + if (exporting) return; + setExporting(true); + const [short, long] = paper === 'a4' ? [595.28, 841.89] : [612, 792]; + await runRequest( + () => + sharePdf( + flowSheetExportHtml({ format, paper, orientation, strip, ...header }, tools), + `DebateFlowSheet_${formatId}.pdf`, + tools('flow.region'), + orientation === 'landscape' ? { width: long, height: short } : { width: short, height: long }, + ), + () => showNotice(t('mobile.flowSheet.exportFailed')), + () => setExporting(false), + ); + }; + + return ( + } + actions={ void exportPdf()} />}> + + + first label={tools('flow.format')} value={formatId} onChange={setFormatId} + options={BUILT_IN_FORMAT_IDS.map((value) => ({ value, label: FORMATS[value].name }))} /> + label={tools('flow.paper')} value={paper} onChange={setPaper} + options={[{ value: 'letter', label: tools('flow.letter') }, { value: 'a4', label: tools('flow.a4') }]} /> + label={tools('flow.orientation')} value={orientation} onChange={setOrientation} + options={[{ value: 'landscape', label: tools('flow.landscape') }, { value: 'portrait', label: tools('flow.portrait') }]} /> + 0 ? 'flow.questionStrip' : 'flow.notesStrip')} value={strip} onChange={setStrip} /> + + setHeader((current) => ({ ...current, motion }))} /> + + {(['round', 'judge'] as const).map((key) => ( + + setHeader((current) => ({ ...current, [key]: value }))} /> + + ))} + + + ); +} + +const styles = StyleSheet.create({ + fields: { gap: 16 }, + tabletFields: { flexDirection: 'row' }, + field: { flex: 1 }, +}); diff --git a/apps/mobile/src/app/flowchart/index.tsx b/apps/mobile/src/app/flowchart/index.tsx index 1840e532f..e92aeab8d 100644 --- a/apps/mobile/src/app/flowchart/index.tsx +++ b/apps/mobile/src/app/flowchart/index.tsx @@ -2,9 +2,11 @@ import { starterFlowchart, type FlowNode } from '@debatelab/core/flowchart'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { ActivityIndicator, ScrollView, StyleSheet, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { PressScale as BouncePressable } from '@/components/press-scale'; +import { runRequest } from '@/lib/run-request'; import { ActionMenu } from '@/components/action-menu'; import { TextButton } from '@/components/buttons'; import { ClaimInspector } from '@/components/claim-panel'; @@ -15,6 +17,7 @@ import { OfflineNotice } from '@/components/offline-notice'; import { OneOffNotice } from '@/components/one-off-notice'; import { SegmentedControl } from '@/components/segmented-control'; import { Text } from '@/components/text'; +import { useLatest } from '@/lib/live-value'; import { useAuth } from '@/lib/auth'; import { backLabel } from '@/lib/back-label'; import { confirmDestructive, promptText } from '@/lib/confirm'; @@ -78,13 +81,11 @@ export default function FlowchartScreen() { const [claim, setClaim] = useState<{ userId: string; scope: string; map: string; node: string } | null>(null); const live = useRef(true); - const owner = useRef(userId); + const owner = useLatest(userId); const copies = useCopyHolder(flowchartScreenCopies); - owner.current = userId; const loads = useRef(0); const noticeTimer = useRef | null>(null); - const tRef = useRef(t); - tRef.current = t; + const tRef = useLatest(t); useEffect(() => () => { live.current = false; @@ -147,7 +148,7 @@ export default function FlowchartScreen() { console.warn('Could not load flowcharts:', cause instanceof Error ? cause.message : cause); setFailure({ scope: requestedId ? null : scopeKey(activeDebateId), kind: 'failed' }); }); - }, [userId, activeDebateId, requestedId, attempt, copies]); + }, [userId, activeDebateId, requestedId, attempt, copies, owner, tRef]); const open = scope ? openDraft(scope) : null; const draft = open?.draft ?? null; @@ -174,11 +175,11 @@ export default function FlowchartScreen() { const act = async (work: (forUser: string, at: { scope: string; key: string; draft: FlowchartDraft }) => Promise) => { if (!userId || !scope || !open || busy) return; setBusy(true); - try { + return await runRequest(async () => { await work(userId, { scope, key: open.key, draft: open.draft }); - } finally { + }, undefined, () => { if (live.current) setBusy(false); - } + }); }; const askTitle = (defaultValue: string) => @@ -210,7 +211,7 @@ export default function FlowchartScreen() { const save = () => void act(async (forUser, at) => { - try { + return await runRequest(async () => { if (!at.draft.savedId) { const title = (await askTitle(''))?.trim(); if (!title || owner.current !== forUser) return; @@ -225,22 +226,22 @@ export default function FlowchartScreen() { } updateDraft(at.scope, at.key, (current) => ({ ...current, edited: current.doc !== at.draft.doc })); setNotice(t('flowchart.savedToast')); - } catch (cause) { + }, async (cause) => { console.warn('Could not save the flowchart:', cause instanceof Error ? cause.message : cause); setNotice(t('flowchart.saveFailedToast')); - } + }); }); const saveAs = () => void act(async (forUser, at) => { const title = (await askTitle(at.draft.title))?.trim(); if (!title || owner.current !== forUser) return; - try { + return await runRequest(async () => { await saveNew(forUser, at, title); - } catch (cause) { + }, async (cause) => { console.warn('Could not save the flowchart:', cause instanceof Error ? cause.message : cause); setNotice(t('flowchart.saveFailedToast')); - } + }); }); const remove = () => @@ -254,7 +255,7 @@ export default function FlowchartScreen() { cancel: t('common.cancel'), }); if (!confirmed || owner.current !== forUser) return; - try { + return await runRequest(async () => { await deleteFlowchart(forUser, savedId); if (owner.current !== forUser) return; forgetDraft(at.scope, at.key); @@ -262,10 +263,10 @@ export default function FlowchartScreen() { // The screen then opens the debate's next newest map, or the starter map. if (requestedId) setRequestedId(null); else setAttempt((n) => n + 1); - } catch (cause) { + }, async (cause) => { console.warn('Could not delete the flowchart:', cause instanceof Error ? cause.message : cause); setNotice(t('flowchart.deleteFailedToast')); - } + }); }); const runExport = (work: () => Promise) => { @@ -303,11 +304,11 @@ export default function FlowchartScreen() { {t('nav.flowchart')} {draft && scope ? ( - router.push({ pathname: '/flowchart/maps', params: { scope } })} - style={({ pressed }) => [styles.mapRow, pressed && styles.pressed]} + style={[styles.mapRow]} testID="flowchart-maps"> @@ -322,7 +323,7 @@ export default function FlowchartScreen() { {busy ? : } - + ) : null} {oneOff ? : null} @@ -432,7 +433,6 @@ const styles = StyleSheet.create({ mapText: { flex: 1, gap: 1 }, mapTitle: { fontSize: 16.5, lineHeight: 22 }, mapMeta: { fontSize: 13.5, lineHeight: 18 }, - pressed: { opacity: 0.7 }, state: { alignItems: 'center', gap: 10, paddingVertical: 40, paddingHorizontal: 24 }, stateText: { fontSize: 15, lineHeight: 21, textAlign: 'center' }, notice: { position: 'absolute', left: 24, right: 24, borderRadius: 14, paddingHorizontal: 16, paddingVertical: 11, alignSelf: 'center', maxWidth: 560 }, diff --git a/apps/mobile/src/app/flowchart/maps.tsx b/apps/mobile/src/app/flowchart/maps.tsx index d53ca2bed..e15bc4fde 100644 --- a/apps/mobile/src/app/flowchart/maps.tsx +++ b/apps/mobile/src/app/flowchart/maps.tsx @@ -4,12 +4,15 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityIndicator, StyleSheet, View } from 'react-native'; +import { runRequest } from '@/lib/run-request'; import { PrimaryButton, TextButton } from '@/components/buttons'; import { ListItemIn, SkeletonTextRows } from '@/components/card'; import { Icon } from '@/components/icon'; import { PressScale } from '@/components/press-scale'; -import { SheetScrollView } from '@/components/sheet'; +import { useRowArrivals } from '@/components/list-screen'; +import { SheetList } from '@/components/sheet'; import { Text } from '@/components/text'; +import { useLatest } from '@/lib/live-value'; import { useAuth } from '@/lib/auth'; import { confirmDestructive } from '@/lib/confirm'; import { formatRelativeDate } from '@/lib/dates'; @@ -43,8 +46,7 @@ export default function SavedMapsSheet() { // Only the latest tap may open a map; an earlier one still loading is dropped. const selections = useRef(0); const live = useRef(true); - const owner = useRef(userId); - owner.current = userId; + const owner = useLatest(userId); useEffect(() => () => { live.current = false; @@ -56,14 +58,14 @@ export default function SavedMapsSheet() { const forUser = userId; const forScope = scope; const isCurrent = () => loads.current === request && live.current && owner.current === forUser; - try { + return await runRequest(async () => { const items = await fetchFlowchartList(forUser, debateId, copies.mark()); if (isCurrent()) setList((previous) => keepIfSame(previous, { for: `${forUser}:${forScope}`, items, failed: false })); - } catch (cause) { + }, async (cause) => { console.warn('Could not load saved flowcharts:', cause instanceof Error ? cause.message : cause); if (isCurrent()) setList((previous) => (previous?.for === `${forUser}:${forScope}` && previous.items ? previous : { for: `${forUser}:${forScope}`, items: null, failed: true })); - } - }, [debateId, scope, userId, copies]); + }); + }, [debateId, scope, userId, copies, owner]); useEffect(() => { void load(); @@ -86,7 +88,7 @@ export default function SavedMapsSheet() { const forUser = userId; setOpening(index); setFailedOpen(false); - try { + return await runRequest(async () => { // The map shows on the Flowchart screen once this sheet closes, so it counts there. const row = await fetchFlowchart(forUser, item.id, flowchartScreenCopies.mark()); if (!live.current || owner.current !== forUser || selections.current !== selection) return; @@ -97,12 +99,12 @@ export default function SavedMapsSheet() { } showDraft(scope, savedDraft(row)); router.back(); - } catch (cause) { + }, async (cause) => { console.warn('Could not open the flowchart:', cause instanceof Error ? cause.message : cause); if (live.current && selections.current === selection) setFailedOpen(true); - } finally { + }, () => { if (live.current && selections.current === selection) setOpening(null); - } + }); }; const startNew = async () => { @@ -121,8 +123,12 @@ export default function SavedMapsSheet() { router.back(); }; + const items = shown?.items ?? []; + const rows = [...(unsaved ? [{ key: NEW_MAP, item: null }] : []), ...items.map((item) => ({ key: item.id, item }))]; + const arrivals = useRowArrivals(rows.map((entry) => entry.key)); + const row = (key: string, title: string, meta: string, selected: boolean, onPress: () => void, index: number, busy = false) => ( - 0 && { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.line }}> + 0 && { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.line }}> ); - const items = shown?.items ?? []; + return ( - - - - {t('flowchart.savedFlowcharts')} - - router.back()} /> - - - {t('flowchart.savedFlowchartsDesc')} - - void startNew()} testID="new-map" /> - {unsaved || items.length > 0 ? ( - - {unsaved - ? row(NEW_MAP, t('mobile.flowchart.untitled'), unsaved.edited ? t('mobile.flowchart.edited') : t('mobile.flowchart.notSaved'), open?.key === NEW_MAP, () => { - showDraft(scope, unsaved); - router.back(); - }, 0) - : null} - {items.map((item, index) => { - const held = scope ? getDraft(scope, item.id) : undefined; - const meta = held?.edited ? `${formatRelativeDate(item.createdAt)} · ${t('mobile.flowchart.edited')}` : formatRelativeDate(item.createdAt); - return row(`${index}`, item.title, meta, open?.key === item.id, () => void choose(item, index), index + (unsaved ? 1 : 0), opening === index); - })} - - ) : null} - {!shown ? ( - - ) : shown.failed ? ( - + entry.key} + header={ + <> + + + {t('flowchart.savedFlowcharts')} + + router.back()} /> + - {t('mobile.flowchart.loadFailed')} + {t('flowchart.savedFlowchartsDesc')} - void load()} /> - - ) : items.length === 0 ? ( - - {t('flowchart.noSavedFlowcharts')} - - ) : null} - {failedOpen ? ( - - {t('mobile.flowchart.openFailed')} - - ) : null} - + void startNew()} testID="new-map" /> + + } + renderItem={({ item: entry, index }) => { + const item = entry.item; + const held = item && scope ? getDraft(scope, item.id) : undefined; + const itemIndex = index - (unsaved ? 1 : 0); + const meta = item ? (held?.edited ? `${formatRelativeDate(item.createdAt)} · ${t('mobile.flowchart.edited')}` : formatRelativeDate(item.createdAt)) : unsaved?.edited ? t('mobile.flowchart.edited') : t('mobile.flowchart.notSaved'); + return ( + + {row(entry.key, item?.title ?? t('mobile.flowchart.untitled'), meta, open?.key === entry.key, () => { + if (item) void choose(item, itemIndex); + else if (unsaved) { showDraft(scope, unsaved); router.back(); } + }, index, item !== null && opening === itemIndex)} + + ); + }} + ListFooterComponent={ + <> + {!shown ? ( + + ) : shown.failed ? ( + + + {t('mobile.flowchart.loadFailed')} + + void load()} /> + + ) : items.length === 0 ? ( + + {t('flowchart.noSavedFlowcharts')} + + ) : null} + {failedOpen ? ( + + {t('mobile.flowchart.openFailed')} + + ) : null} + + } + /> ); } @@ -203,7 +219,9 @@ 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 }, - list: { borderWidth: 2, borderRadius: radius.field, overflow: 'hidden' }, + list: { borderLeftWidth: 2, borderRightWidth: 2, overflow: 'hidden' }, + firstRow: { borderTopWidth: 2, borderTopLeftRadius: radius.field, borderTopRightRadius: radius.field }, + lastRow: { borderBottomWidth: 2, borderBottomLeftRadius: radius.field, borderBottomRightRadius: radius.field }, row: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 15, paddingVertical: 11, minHeight: 56 }, rowText: { flex: 1, gap: 2 }, rowTitle: { fontSize: 16 }, diff --git a/apps/mobile/src/app/generate.tsx b/apps/mobile/src/app/generate.tsx index 309ca4158..22913b871 100644 --- a/apps/mobile/src/app/generate.tsx +++ b/apps/mobile/src/app/generate.tsx @@ -1,11 +1,12 @@ import { generateDraftSections } from '@debatelab/core/ai/live-sections'; -import type { GenerateInput } from '@debatelab/core/ai/schemas/generate'; +import { GenerateInputSchema, type GenerateInput } from '@debatelab/core/ai/schemas/generate'; import { MAX_MOTION_LENGTH } from '@debatelab/core/validation/motion'; import { useLocalSearchParams, useRouter } from 'expo-router'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { StyleSheet, View } from 'react-native'; +import { runRequest } from '@/lib/run-request'; import { MainActionButton } from '@/components/buttons'; import { markCelebration } from '@/components/celebration'; import { DocumentScreen } from '@/components/document'; @@ -65,10 +66,16 @@ export default function GenerateScreen() { const abort = useRef(null); const live = useRef(true); const liveRun = useLiveRun({ tool: 'case', parts: (draft) => generateDraftSections(draft, t).length }); + const setForm = useCallback((patch: Partial) => + setFormState((previous) => { + const next = { ...previous, ...patch }; + lastForm.set(next); + return next; + }), []); // Dictation fills the motion, as the website's microphone beside it does. The // ref holds words that arrived before the last ones were drawn. const motionRef = useRef(form.motion); - motionRef.current = form.motion; + useLayoutEffect(() => { motionRef.current = form.motion; }, [form.motion]); const dictation = useSpeechCapture(language, (text) => { const motion = appendDictation(motionRef.current, text, MAX_MOTION_LENGTH); motionRef.current = motion; @@ -76,12 +83,6 @@ export default function GenerateScreen() { setMotionError(false); }); - const setForm = useCallback((patch: Partial) => - setFormState((previous) => { - const next = { ...previous, ...patch }; - lastForm.set(next); - return next; - }), []); const endLiveRun = liveRun.end; const abortActive = useCallback(() => { @@ -100,7 +101,10 @@ export default function GenerateScreen() { // The motion follows the Current Debate unless the student typed their own. useEffect(() => { if (oneOff || !current) return; - if (shouldPrefillMotion(lastForm.get().motion, current.motion, debates.map((debate) => debate.motion))) setForm({ motion: current.motion }); + if (shouldPrefillMotion(lastForm.get().motion, current.motion, debates.map((debate) => debate.motion))) { + const role = GenerateInputSchema.shape.role.safeParse(current.settings.role); + setForm({ motion: current.motion, ...(role.success ? { role: role.data } : {}) }); + } }, [current, debates, oneOff, setForm]); // A motion from Siri or Shortcuts (debatelab://generate?motion=) fills the form once, over the Current Debate's. @@ -122,7 +126,7 @@ export default function GenerateScreen() { abort.current = controller; setError(null); setPhase('generating'); - try { + return await runRequest(async () => { const [config, debateContext] = await Promise.all([ readFeatureConfig(forUser, 'generate'), debateId ? buildDebateContext(forUser, debateId, 'generate', input.side) : Promise.resolve(undefined), @@ -145,12 +149,12 @@ export default function GenerateScreen() { let savedId: string | null = null; let recordFailed = false; if (debateId) { - try { + await runRequest(async () => { savedId = await recordResult(forUser, 'case', { title: input.motion, input, output: result, debateId, side: input.side }); - } catch (cause) { + }, async (cause) => { console.warn('Could not save the case to the debate:', cause instanceof Error ? cause.message : cause); recordFailed = true; - } + }); } if (!isCurrent()) return; const key = keepOpenResult({ @@ -172,7 +176,7 @@ export default function GenerateScreen() { pathname: '/case/[id]', params: { id: key, from: from ?? 'prepare', ...(notice ? { notice } : {}), ...(sources.failed > 0 ? { failed: String(sources.failed) } : {}) }, }); - } catch (cause) { + }, async (cause) => { if (!isCurrent() || isAbortError(cause)) return; const kind = cause instanceof AIError ? cause.kind : 'failed'; if (!(cause instanceof AIError)) console.warn('Generate failed:', cause instanceof Error ? cause.message : cause); @@ -183,9 +187,9 @@ export default function GenerateScreen() { // A limit or model refusal opens the website's dialog as a sheet, not an inline error. if (openRefusal(router, cause, 'generate')) return; setError(kind); - } finally { + }, () => { if (requests.current === request) abort.current = null; - } + }); }; const submit = async () => { @@ -212,18 +216,20 @@ export default function GenerateScreen() { }); if (!choice || !live.current) return; let debateId: string | null = null; - try { + const selected = await runRequest(async () => { if (choice.kind === 'switch') { await switchTo(choice.debateId); debateId = choice.debateId; } else if (choice.kind === 'create') { debateId = (await create({ motion: input.motion, settings: {} })).id; } - } catch (cause) { + return true; + }, async (cause) => { console.warn('Could not save the debate:', cause instanceof Error ? cause.message : cause); if (live.current) setError('debate'); - return; - } + return false; + }); + if (!selected) return; if (live.current) void start(input, debateId, forUser); }; @@ -245,7 +251,7 @@ export default function GenerateScreen() { setForm({ role })} options={[ + { value: 'general', label: t('debate.roleAny') }, { value: 'intro', label: t('generateForm.roleIntro') }, { value: 'r1', label: t('generateForm.roleR1') }, { value: 'r2', label: t('generateForm.roleR2') }, diff --git a/apps/mobile/src/app/history.tsx b/apps/mobile/src/app/history.tsx index 70376b231..03c7af944 100644 --- a/apps/mobile/src/app/history.tsx +++ b/apps/mobile/src/app/history.tsx @@ -5,6 +5,8 @@ import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; import Animated from 'react-native-reanimated'; import ReanimatedSwipeable, { type SwipeableMethods } from 'react-native-gesture-handler/ReanimatedSwipeable'; +import { PressScale as BouncePressable } from '@/components/press-scale'; +import { runRequest } from '@/lib/run-request'; import { ActionMenu } from '@/components/action-menu'; import { PrimaryButton, TextButton } from '@/components/buttons'; import { Card, IconTile, ListItemIn, usePressScale } from '@/components/card'; @@ -35,6 +37,7 @@ import { import { keepIfSame } from '@/lib/keep-if-same'; import { useLatest } from '@/lib/live-value'; import { suggestDebate } from '@/lib/motion'; +import { deleteCrossfireRound } from '@/lib/crossfire'; import { deleteResult } from '@/lib/results'; import { deleteSparringRound, sparringDraft } from '@/lib/sparring'; import { hasDocumentText, useWorkActions } from '@/lib/work-actions'; @@ -43,7 +46,7 @@ import { useTheme } from '@/theme/use-theme'; import { EmptyState } from '@/components/empty-state'; /** What the History lists delete, with the website's confirmation and failure copy. */ -const DELETIONS: Record<'case' | 'sparring', { tab: HistoryTab; title: string; desc: string; failed: string; label: string; run: (userId: string, id: string) => Promise }> = { +const DELETIONS: Record<'case' | 'sparring' | 'crossfire', { tab: HistoryTab; title: string; desc: string; failed: string; label: string; run: (userId: string, id: string) => Promise }> = { case: { tab: 'cases', title: 'historyPage.deleteCaseTitle', @@ -52,6 +55,14 @@ const DELETIONS: Record<'case' | 'sparring', { tab: HistoryTab; title: string; d label: 'historyPage.deleteCaseLabel', run: (userId, id) => deleteResult(userId, 'case', id), }, + crossfire: { + tab: 'crossfire', + title: 'historyPage.deleteCrossfireTitle', + desc: 'historyPage.deleteCrossfireDesc', + failed: 'historyPage.crossfireDeleteFailed', + label: 'historyPage.deleteCrossfireAria', + run: deleteCrossfireRound, + }, sparring: { tab: 'sparring', title: 'historyPage.deleteSparringTitle', @@ -74,6 +85,7 @@ type Loaded = | { scope: typeof OUTSIDE; kind: 'unsorted'; entries: UnsortedEntry[] }; const EMPTY: Record = { + crossfire: { title: 'historyPage.emptyCrossfire', desc: 'historyPage.emptyCrossfireDesc', action: 'historyPage.startCrossfire', href: { pathname: '/crossfire', params: { from: 'history' } } }, cases: { title: 'historyPage.emptyCases', desc: 'historyPage.emptyCasesDesc', action: 'historyPage.createCase', href: { pathname: '/generate', params: { from: 'history' } } }, chats: { title: 'historyPage.emptyChats', desc: 'historyPage.emptyChatsDesc', action: 'historyPage.startChat', href: '/ask' }, attacks: { title: 'historyPage.emptyAttacks', desc: 'historyPage.emptyAttacksDesc', action: 'historyPage.attackCase', href: { pathname: '/attack', params: { from: 'history' } } }, @@ -141,19 +153,24 @@ export default function HistoryScreen() { const request = ++loads.current; const isCurrent = () => live.current && loads.current === request && owner.current === forUser; const mark = copies.mark(); - try { + return await runRequest(async () => { const next: Loaded = target === OUTSIDE - ? { scope: OUTSIDE, kind: 'unsorted', entries: await fetchUnsortedWork(forUser, mark) } - : { scope: target, kind: 'debate', lists: await fetchDebateHistory(forUser, target, mark) }; + ? { scope: OUTSIDE, kind: 'unsorted', entries: await fetchUnsortedWork(forUser, mark, (entries) => { + if (isCurrent()) { setLoaded({ scope: OUTSIDE, kind: 'unsorted', entries }); setFailed(null); } + }) } + : { scope: target, kind: 'debate', lists: await fetchDebateHistory(forUser, target, mark, (lists) => { + if (isCurrent()) { setLoaded({ scope: target, kind: 'debate', lists }); setFailed(null); } + }) }; if (!isCurrent()) return; // Focus refreshes usually reread the same rows; keep the shown lists when they did not change. setLoaded((previous) => keepIfSame(previous, next)); setFailed(null); - } catch (cause) { + }, async (cause) => { if (!isCurrent()) return; console.warn('Could not load history:', cause instanceof Error ? cause.message : cause); + setLoaded(null); setFailed(target); - } + }); }, [userId, copies]); // Reload on focus, so a case deleted in its document or work saved on the website shows up. @@ -171,10 +188,10 @@ export default function HistoryScreen() { ? t('debate.outsideDebates') : `${scope === current?.id ? `${t('nav.currentDebate')}: ` : ''}${scopeDebate?.motion ?? ''}`; - // The website's History deletes cases and Sparring rounds, each after its own confirmation. + // The website's History deletes cases, Sparring and Crossfire rounds, each after its own confirmation. const remove = async (item: HistoryItem): Promise => { const forUser = owner.current; - const deletion = item.kind === 'case' || item.kind === 'sparring' ? DELETIONS[item.kind] : null; + const deletion = item.kind === 'case' || item.kind === 'sparring' || item.kind === 'crossfire' ? DELETIONS[item.kind] : null; if (!forUser || !deletion) return false; const confirmed = await confirmDestructive({ title: t(deletion.title), @@ -183,20 +200,22 @@ export default function HistoryScreen() { cancel: t('common.cancel'), }); if (!confirmed || owner.current !== forUser) return false; - try { + const removed = await runRequest(async () => { await deletion.run(forUser, item.id); - } catch (cause) { + return true; + }, async (cause) => { console.warn(`Could not delete the ${item.kind}:`, cause instanceof Error ? cause.message : cause); if (live.current && owner.current === forUser) showNotice(t(deletion.failed)); return false; - } + }); + if (!removed) return false; if (!live.current || owner.current !== forUser) return true; // Any load still running read the list before this delete. loads.current += 1; const list = deletion.tab; setLoaded((previous) => previous?.kind === 'debate' - ? { ...previous, lists: { ...previous.lists, [list]: previous.lists[list].filter((entry) => entry.id !== item.id) } } - : previous); + ? { ...previous, lists: { ...previous.lists, [list]: (previous.lists[list] ?? []).filter((entry) => entry.id !== item.id) } } + : previous?.kind === 'unsorted' ? { ...previous, entries: previous.entries.filter((entry) => entry.id !== item.id || entry.kind !== item.kind) } : previous); return true; }; @@ -227,15 +246,15 @@ export default function HistoryScreen() { const forUser = owner.current; if (!forUser || attaching.has(key)) return; setAttaching((previous) => new Set(previous).add(key)); - try { + return await runRequest(async () => { await attachToDebate(forUser, entry, debateId); if (!live.current || owner.current !== forUser) return; if (shownScope.current === OUTSIDE) setAttachNotice({ scope: OUTSIDE, text: t('debate.attached') }); attached(entry); - } catch (cause) { + }, async (cause) => { console.warn('Could not add to the debate:', cause instanceof Error ? cause.message : cause); if (live.current && owner.current === forUser) showNotice(t('debate.attachFailed')); - } finally { + }, () => { if (live.current) { setAttaching((previous) => { const next = new Set(previous); @@ -243,7 +262,7 @@ export default function HistoryScreen() { return next; }); } - } + }); }; // The attach notice belongs to the list it was shown for. @@ -254,7 +273,7 @@ export default function HistoryScreen() { ? [] : shown.kind === 'unsorted' ? shown.entries.map((entry) => ({ type: 'unsorted', key: `${entry.kind}-${entry.id}`, entry })) - : shown.lists[tab].map((item) => ({ type: 'item', key: `${item.kind}-${item.id}`, item })); + : (shown.lists[tab] ?? []).map((item) => ({ type: 'item', key: `${item.kind}-${item.id}`, item })); const arrivals = useRowArrivals(rows.map((row) => row.key)); const header = ( @@ -350,6 +369,7 @@ export default function HistoryScreen() { debates={debates} choice={attachChoices[row.key]} busy={attaching.has(row.key)} + onDelete={row.entry.kind === 'crossfire' ? () => remove(row.entry as HistoryItem) : undefined} onChoose={(debateId) => setAttachChoices((previous) => ({ ...previous, [row.key]: debateId }))} onAttach={(debateId) => void attachEntry(row.key, row.entry, debateId)} /> @@ -364,7 +384,7 @@ export default function HistoryScreen() { item={row.item} first={index === 0} onPress={href ? () => router.push(href) : undefined} - onDelete={row.item.kind === 'case' || row.item.kind === 'sparring' ? () => remove(row.item) : undefined} + onDelete={row.item.kind === 'case' || row.item.kind === 'sparring' || row.item.kind === 'crossfire' ? () => remove(row.item) : undefined} /> @@ -385,12 +405,12 @@ function ScopePicker({ label, onPress }: { label: string; onPress: () => void }) const { t } = useTranslation(); const { colors } = useTheme(); return ( - [styles.scope, { backgroundColor: colors.card, borderColor: colors.border }, pressed && styles.pressed]}> + style={[styles.scope, { backgroundColor: colors.card, borderColor: colors.border }]}> {t('debate.historyScope')} @@ -400,7 +420,7 @@ function ScopePicker({ label, onPress }: { label: string; onPress: () => void }) - + ); } @@ -468,8 +488,8 @@ function HistoryRow({ item, first, onPress, onDelete }: { item: HistoryItem; fir onPressOut={press.onPressOut} testID="history-row" style={{ backgroundColor: colors.card }}> - {({ pressed }) => ( - + {() => ( + @@ -525,16 +545,16 @@ function HistoryRow({ item, first, onPress, onDelete }: { item: HistoryItem; fir overshootRight={false} containerStyle={divider} renderRightActions={() => ( - void remove()} style={[styles.deleteAction, { backgroundColor: colors.destructive }]}> {t('common.delete')} - + )}> {body} @@ -573,6 +593,7 @@ function UnsortedRow({ busy, onChoose, onAttach, + onDelete, }: { entry: UnsortedEntry; debates: Debate[]; @@ -581,6 +602,7 @@ function UnsortedRow({ busy: boolean; onChoose: (debateId: string) => void; onAttach: (debateId: string) => void; + onDelete?: () => Promise; }) { const { t } = useTranslation(); const router = useRouter(); @@ -611,6 +633,7 @@ function UnsortedRow({ + {onDelete ? void onDelete()} /> : null} {href ? router.push(href)} /> : null} {suggestion ? ( @@ -624,18 +647,18 @@ function UnsortedRow({ ) : null} {debates.length > 0 ? ( <> - setPicking(true)} disabled={busy} testID="unsorted-target" - style={({ pressed }) => [styles.target, { borderColor: colors.border, backgroundColor: colors.input }, pressed && styles.pressed]}> + style={[styles.target, { borderColor: colors.border, backgroundColor: colors.input }]}> {chosen?.motion ?? t('debate.chooseDebate')} - + { if (!feature || !userId) return close(); setSwitching(true); - try { + await runRequest(async () => { const current = await readFeatureConfig(userId, feature); await setFeatureConfig(feature, isProOnlyModel(current.model) ? { model: DEFAULT_MODEL, reasoningEffort: 'low' } : { reasoningEffort: 'low' }); - } catch (cause) { + }, async (cause) => { console.warn('Could not switch to low reasoning:', cause instanceof Error ? cause.message : cause); - } + }); close(); }; return ( diff --git a/apps/mobile/src/app/new-debate.tsx b/apps/mobile/src/app/new-debate.tsx index ac9ac6055..df1649fbf 100644 --- a/apps/mobile/src/app/new-debate.tsx +++ b/apps/mobile/src/app/new-debate.tsx @@ -6,6 +6,7 @@ import { TextButton } from '@/components/buttons'; import { DebateForm } from '@/components/debate-form'; import { SheetScrollView } from '@/components/sheet'; import { Text } from '@/components/text'; +import { markNewDebate } from '@/lib/debate-arrival'; import { useDebates } from '@/lib/debate-context'; /** @@ -34,9 +35,11 @@ export default function NewDebateSheet() { initial={motion ? { motion } : undefined} submitLabel={t('debate.startDebate')} onSubmit={async (values) => { - await create(values); + const debate = await create(values); + markNewDebate(debate.id); // Closes this sheet and the switcher under it, if it was opened from there. router.dismissAll(); + router.navigate('/(tabs)/(debate)'); }} /> diff --git a/apps/mobile/src/app/new-note.tsx b/apps/mobile/src/app/new-note.tsx index 8ffa0e3de..7a2ebd758 100644 --- a/apps/mobile/src/app/new-note.tsx +++ b/apps/mobile/src/app/new-note.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { StyleSheet, View } from 'react-native'; +import { runRequest } from '@/lib/run-request'; import { MainActionButton, TextButton } from '@/components/buttons'; import { Field } from '@/components/field'; import { FolderSelect } from '@/components/folder-select'; @@ -47,18 +48,18 @@ export default function NewNoteSheet() { if (!ready || !userId || !current) return; setSaving(true); setError(''); - try { + return await runRequest(async () => { const { status } = await queueAddSource(userId, { title: title.trim(), content: content.trim(), type: 'text', folder, debateId: current.id }); toast(status === 'saved' ? { tone: 'success', title: t('libraryPage.sourceSaved', { title: title.trim() }) } : { tone: 'info', title: t('mobile.offlineQueue.noteQueuedTitle'), message: t('mobile.offlineQueue.noteQueuedDesc') }); router.back(); - } catch (cause) { + }, async (cause) => { console.warn('Could not save the note:', cause instanceof Error ? cause.message : cause); haptics.warning(); setError(t('mobile.library.saveFailed')); setSaving(false); - } + }); }; return ( diff --git a/apps/mobile/src/app/plans.tsx b/apps/mobile/src/app/plans.tsx index cadcea712..ac7139d3b 100644 --- a/apps/mobile/src/app/plans.tsx +++ b/apps/mobile/src/app/plans.tsx @@ -2,8 +2,9 @@ import { useRouter } from 'expo-router'; import * as WebBrowser from 'expo-web-browser'; import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Pressable, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; +import { PressScale as BouncePressable } from '@/components/press-scale'; import { PrimaryButton, TextButton } from '@/components/buttons'; import { Icon } from '@/components/icon'; import { Owl } from '@/components/owl'; @@ -130,7 +131,7 @@ export default function PlansSheet() { : [t('mobile.plans.dailyUses', { count: DAILY_USES.basic }), t('pricingModal.freeFeature2'), t('pricingModal.proFeature5')]; const intro = product?.introOffer && product.isEligibleForIntroOffer ? product.introOffer : null; return ( - ))} - + ); }; diff --git a/apps/mobile/src/app/save-case.tsx b/apps/mobile/src/app/save-case.tsx index e6c029556..2534d749f 100644 --- a/apps/mobile/src/app/save-case.tsx +++ b/apps/mobile/src/app/save-case.tsx @@ -1,12 +1,14 @@ -import { useLocalSearchParams, useRouter } from 'expo-router'; -import { useState } from 'react'; +import type { NativeStackNavigationProp } from 'expo-router'; +import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router'; +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { StyleSheet, View } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import { PrimaryButton, TextButton } from '@/components/buttons'; import { Field } from '@/components/field'; import { SheetScrollView } from '@/components/sheet'; import { Text } from '@/components/text'; +import { useToast } from '@/components/toast'; import { useAuth } from '@/lib/auth'; import { openResult, updateOpenResult } from '@/lib/open-results'; import { saveCase } from '@/lib/results'; @@ -19,6 +21,15 @@ import { useTheme } from '@/theme/use-theme'; export default function SaveCaseSheet() { const { t } = useTranslation(); const router = useRouter(); + const navigation = useNavigation>>(); + const toast = useToast(); + const saved = useRef(false); + useEffect(() => navigation.addListener('transitionEnd', (event) => { + if (event.data.closing && saved.current) { + saved.current = false; + toast({ tone: 'success', title: t('generate.saved') }); + } + }), [navigation, toast, t]); const { colors } = useTheme(); const { key } = useLocalSearchParams<{ key: string }>(); const { session } = useAuth(); @@ -39,7 +50,15 @@ export default function SaveCaseSheet() { try { const savedId = await saveCase(userId, name, current.output); updateOpenResult(key, { savedId, title: name }); + saved.current = true; router.back(); + // Web has no native sheet transition. Native waits for transitionEnd above. + if (Platform.OS === 'web') { + requestAnimationFrame(() => { + saved.current = false; + toast({ tone: 'success', title: t('generate.saved') }); + }); + } } catch (cause) { console.warn('Could not save the case:', cause instanceof Error ? cause.message : cause); setSaving(false); diff --git a/apps/mobile/src/app/sign-in.tsx b/apps/mobile/src/app/sign-in.tsx index 3da8064da..8ced6c913 100644 --- a/apps/mobile/src/app/sign-in.tsx +++ b/apps/mobile/src/app/sign-in.tsx @@ -1,3 +1,4 @@ +import { APP_LANGUAGES, isAppLanguage } from '@debatelab/core/languages'; import * as AppleAuthentication from 'expo-apple-authentication'; import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -11,9 +12,11 @@ import { SegmentedControl } from '@/components/segmented-control'; import { Text } from '@/components/text'; import { useAuth } from '@/lib/auth'; import { signInErrorKey } from '@/lib/sign-in-errors'; +import { changeAppLanguage } from '@/lib/i18n'; import { appleSignInAvailable, googleSignInAvailable, signInWithApple, signInWithGoogle } from '@/lib/social-sign-in'; import { Icon } from '@/components/icon'; import { OwlMark } from '@/components/owl'; +import { ActionMenu } from '@/components/action-menu'; import { useTheme } from '@/theme/use-theme'; type Mode = 'email' | 'demo'; @@ -30,13 +33,15 @@ const DEMO_CODE_PATTERN = /^DEMO-[A-Z0-9]{4}-[A-Z0-9]{4}$/; * Google too. */ export default function SignInScreen() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { colors, scheme } = useTheme(); const insets = useSafeAreaInsets(); // On iPad the form sits in the middle of the screen instead of under the status bar. const centred = useWindowDimensions().width >= 700; const { signInWithEmailOtp, verifyEmailOtp, signInWithDemoCode, sessionExpired } = useAuth(); const [mode, setMode] = useState('email'); + const [languageMenu, setLanguageMenu] = useState(false); + const languageLabel = APP_LANGUAGES[isAppLanguage(i18n.language) ? i18n.language : 'en'].label; const [status, setStatus] = useState('idle'); const [codeSent, setCodeSent] = useState(false); const [email, setEmail] = useState(''); @@ -139,7 +144,22 @@ export default function SignInScreen() { centred && styles.centred, { paddingTop: insets.top + 32, paddingBottom: insets.bottom + 24 }, ]}> - + + + setLanguageMenu(true)} + /> + + ({ key, label: language.label, icon: key === i18n.language ? 'check' : undefined }))} + cancelLabel={t('common.cancel')} + onClose={() => setLanguageMenu(false)} + onSelect={(language) => { if (isAppLanguage(language)) void changeAppLanguage(language); }} + /> {t('login.title')} @@ -306,6 +326,7 @@ const styles = StyleSheet.create({ notice: { flexDirection: 'row', alignItems: 'flex-start', gap: 10, borderWidth: 2, borderRadius: 14, padding: 12 }, noticeText: { flex: 1 }, root: { flex: 1 }, + brandRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 16 }, // Phone-width on iPad, centred. content: { paddingHorizontal: 24, gap: 24, width: '100%', maxWidth: 460, alignSelf: 'center' }, centred: { flexGrow: 1, justifyContent: 'center' }, diff --git a/apps/mobile/src/app/source/[id].tsx b/apps/mobile/src/app/source/[id].tsx index e69ae0234..852e7c7a8 100644 --- a/apps/mobile/src/app/source/[id].tsx +++ b/apps/mobile/src/app/source/[id].tsx @@ -1,7 +1,6 @@ import { useLocalSearchParams, useRouter } from 'expo-router'; import { StyleSheet } from 'react-native'; -import { SheetScrollView } from '@/components/sheet'; import { SourceView } from '@/components/source-view'; /** A source, read-only, in a form sheet. The iPad Library shows the same view beside its list. */ @@ -9,13 +8,10 @@ export default function SourceSheet() { const router = useRouter(); const { id } = useLocalSearchParams<{ id: string }>(); return ( - - {/* Opened from a link with nothing under it, closing goes to the Library. */} - (router.canGoBack() ? router.back() : router.replace('/library'))} /> - + (router.canGoBack() ? router.back() : router.replace('/library'))} /> ); } const styles = StyleSheet.create({ - content: { paddingTop: 18, gap: 14 }, + content: { paddingTop: 18 }, }); diff --git a/apps/mobile/src/app/sparring/index.tsx b/apps/mobile/src/app/sparring/index.tsx index 2ab012f49..e1d60bffe 100644 --- a/apps/mobile/src/app/sparring/index.tsx +++ b/apps/mobile/src/app/sparring/index.tsx @@ -18,9 +18,10 @@ import { measureDelivery } from '@debatelab/core/voice/delivery'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { AppState, Keyboard, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'; +import { AppState, Keyboard, KeyboardAvoidingView, Platform, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { PressScale as BouncePressable } from '@/components/press-scale'; import { AppTextInput } from '@/components/app-text-input'; import { ActionMenu } from '@/components/action-menu'; import { MainActionButton, TextButton } from '@/components/buttons'; @@ -47,7 +48,7 @@ import { AIError, isAbortError, untilAborted } from '@/lib/ai-request'; import { useAuth } from '@/lib/auth'; import { backLabel } from '@/lib/back-label'; import { useDebates } from '@/lib/debate-context'; -import { fetchFeatureConfig } from '@/lib/feature-config'; +import { ModelPicker } from '@/components/model-picker'; import { useContentWidth } from '@/lib/layout'; import { openRefusal } from '@/lib/refusal'; import { sessionQuotaRefusal, useSessionQuota } from '@/lib/session-quota'; @@ -64,7 +65,7 @@ import { useVoiceAvailability } from '@/lib/voice-availability'; import { radius } from '@/theme/colors'; import { fontFamily } from '@/theme/fonts'; import { useTheme } from '@/theme/use-theme'; -import { OwlAvatar, OwlLoader } from '@/components/owl'; +import { Owl, OwlAvatar, OwlLoader } from '@/components/owl'; const TICK_MS = 250; const MIN_MOTION_CHARS = 8; @@ -108,7 +109,7 @@ export default function SparringScreen() { const { session: auth } = useAuth(); const userId = auth?.user.id ?? null; const { debates, current, oneOff, create, switchTo, refresh } = useDebates(); - const { outputLanguage } = useSettings(); + const { outputLanguage, readFeatureConfig } = useSettings(); const { colors } = useTheme(); const [session, setSession] = useState(() => sparringDraft.get()); @@ -181,7 +182,7 @@ export default function SparringScreen() { setRequest({ status: 'pending' }); try { // Read under the request's abort, so leaving while the settings load sends nothing. - const config = await untilAborted(fetchFeatureConfig(forUser, 'sparring'), controller.signal); + const config = await untilAborted(readFeatureConfig(forUser, 'sparring'), controller.signal); const scorecard = await judgeSparring( { action: 'score', sessionId: snapshot.id, motion: snapshot.motion, userSide: snapshot.userSide, format: snapshot.format, difficulty: snapshot.difficulty, speeches: snapshot.speeches, language: outputLanguage }, { config, debateId: snapshot.debateId ?? null, signal: controller.signal }, @@ -209,7 +210,7 @@ export default function SparringScreen() { setRequest({ status: 'failed', failure: classify(cause, true) }); openRefusal(router, cause, 'sparring'); } - }, [commit, outputLanguage, refresh, router, t]); + }, [commit, outputLanguage, readFeatureConfig, refresh, router, t]); const runTurn = useCallback(async (snapshot: SparringSession, forUser: string) => { const turn = currentSparringTurn(snapshot); @@ -220,7 +221,7 @@ export default function SparringScreen() { setRequest({ status: 'pending' }); try { // Read under the request's abort, so leaving while the settings load sends nothing. - const config = await untilAborted(fetchFeatureConfig(forUser, 'sparring'), controller.signal); + const config = await untilAborted(readFeatureConfig(forUser, 'sparring'), controller.signal); const { speaker: _speaker, ...currentTurn } = turn; const spoken = voiceActiveRef.current; const { speech: generated, voice } = await sparringTurn( @@ -256,28 +257,32 @@ export default function SparringScreen() { setRequest({ status: 'failed', failure: classify(cause, false) }); openRefusal(router, cause, 'sparring'); } - }, [commit, outputLanguage, refresh, router, runJudge, voiceActiveRef]); + }, [commit, outputLanguage, readFeatureConfig, refresh, router, runJudge, voiceActiveRef]); - // The clock counts down from its deadline; the tick only redraws it, and coming back from the background redraws at once. + // Only expiry changes the session. The small clock components tick independently. const timerRunning = session?.timerRunning ?? false; + const timerDeadline = session?.timerDeadline ?? null; useEffect(() => { - if (!timerRunning) return; + if (!timerRunning || timerDeadline === null) return; + let timer: ReturnType | undefined; const tick = () => { + clearTimeout(timer); const latest = sessionRef.current; - if (!latest) return; + if (!latest || !latest.timerRunning || latest.timerDeadline === null) return; + const delay = latest.timerDeadline - Date.now(); + if (delay > 0) { timer = setTimeout(tick, delay); return; } const next = tickSparringTimer(latest, Date.now()); if (next !== latest) commit(next); }; tick(); - const timer = setInterval(tick, TICK_MS); const subscription = AppState.addEventListener('change', (state) => { if (state === 'active') tick(); }); return () => { - clearInterval(timer); + clearTimeout(timer); subscription.remove(); }; - }, [commit, timerRunning]); + }, [commit, timerRunning, timerDeadline]); const begin = (side: SparringSide, debateId: string | null, forUser: string) => { const started = startSparringSession({ motion, userSide: side, format, difficulty, debateId, ...(voiceMode ? { voice: true } : {}) }, newSparringId(), Date.now()); @@ -485,13 +490,17 @@ export default function SparringScreen() { ))} + {formError ? ( - - {formError} - + + + + {formError} + + ) : null} speech.speaker === 'ai'); const turnLabel = turn ? t(`sparring.turns.${turn.id}`) : t('sparring.round.complete'); @@ -677,28 +685,7 @@ function LiveRound({ keyboardShouldPersistTaps="handled" keyboardDismissMode="interactive"> {keyboard ? null : ( - - 0 ? remaining / total : 0} warning={warning}> - - {status} - - {judging ? ( - - ) : ( - - {formatClock(remaining)} - - )} - {judging ? null : ( - - {t('mobile.timer.ofTotal', { total: formatClock(total) })} - - )} - - + )} {voice ? : null} @@ -711,7 +698,7 @@ function LiveRound({ {failure ? ( - + {judging ? t('sparring.round.scoreFailed') : t('sparring.round.opponentFailed')} @@ -733,7 +720,7 @@ function LiveRound({ ) : null} {userTurn && lastAi ? ( - setExpanded((value) => !value)} @@ -748,7 +735,7 @@ function LiveRound({ {lastAi.content} - + ) : null} {userTurn ? ( @@ -787,9 +774,7 @@ function LiveRound({ {keyboard ? ( // With the keyboard up, the speech goes in from a slim bar above it, beside its clock. - - {formatClock(remaining)} - + ) : ( @@ -809,6 +794,66 @@ function LiveRound({ ); } +/** Display-only ticks never write the saved round or redraw the speech form. */ +function useSparringSeconds(session: SparringSession) { + const [now, setNow] = useState(() => Date.now()); + const { timerRunning, timerDeadline, timerRemainingSeconds } = session; + useEffect(() => { + if (!timerRunning || timerDeadline === null) return; + setNow(Date.now()); + const timer = setInterval(() => setNow(Date.now()), TICK_MS); + const subscription = AppState.addEventListener('change', (state) => { + if (state === 'active') setNow(Date.now()); + }); + return () => { clearInterval(timer); subscription.remove(); }; + }, [timerRunning, timerDeadline]); + return timerRunning && timerDeadline !== null ? Math.max(0, Math.ceil((timerDeadline - now) / 1000)) : timerRemainingSeconds; +} + +function SparringClock({ session, total, ringSize, judging, userTurn, status }: { + session: SparringSession; total: number; ringSize: number; judging: boolean; userTurn: boolean; status: string; +}) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const seconds = useSparringSeconds(session); + const remaining = judging ? 0 : seconds; + const warning = userTurn && remaining <= 30; + return ( + + 0 ? remaining / total : 0} warning={warning}> + + {status} + + {judging ? ( + + ) : ( + + {formatClock(remaining)} + + )} + {judging ? null : ( + + {t('mobile.timer.ofTotal', { total: formatClock(total) })} + + )} + + + ); +} + +function SparringKeyboardClock({ session, userTurn }: { session: SparringSession; userTurn: boolean }) { + const { colors } = useTheme(); + const remaining = useSparringSeconds(session); + return ( + + {formatClock(remaining)} + + ); +} + const styles = StyleSheet.create({ speaker: { flexDirection: 'row', alignItems: 'center', gap: 8 }, speakerLabel: { flex: 1 }, diff --git a/apps/mobile/src/app/timer-format.tsx b/apps/mobile/src/app/timer-format.tsx index e19923d4e..12e97ae6c 100644 --- a/apps/mobile/src/app/timer-format.tsx +++ b/apps/mobile/src/app/timer-format.tsx @@ -1,10 +1,11 @@ -import { CUSTOM_LIMITS, customToRoundFormat, parseCustomFormat, roundFormatToCustom, type CustomFormat, type CustomSegment } from '@debatelab/core/free-tools/custom-format'; +import { CUSTOM_LIMITS, customToRoundFormat, encodeCustomFormat, parseCustomFormat, roundFormatToCustom, type CustomFormat, type CustomSegment } from '@debatelab/core/free-tools/custom-format'; import { initialRoundState } from '@debatelab/core/free-tools/round-timer'; import { useLocalSearchParams, 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 { PressScale as BouncePressable } from '@/components/press-scale'; import { MainActionButton, SecondaryButton, TextButton } from '@/components/buttons'; import { Field } from '@/components/field'; import { FormGroup, SwitchRow } from '@/components/form-rows'; @@ -14,6 +15,8 @@ import { SegmentedControl } from '@/components/segmented-control'; import { SheetScrollView } from '@/components/sheet'; import { Text } from '@/components/text'; import { haptics } from '@/lib/haptics'; +import { shareUrl } from '@/lib/export'; +import { showNotice } from '@/lib/confirm'; import { formatFor, formatName } from '@/lib/timer'; import { getTimer, saveFormat, updateSession } from '@/lib/timer-store'; import { radius } from '@/theme/colors'; @@ -51,16 +54,16 @@ function MinutesField({ label, seconds, min, max, onChange, testID }: { function SmallIconButton({ icon, label, onPress, disabled }: { icon: IconName; label: string; onPress: () => void; disabled?: boolean }) { const { colors } = useTheme(); return ( - [styles.iconButton, { backgroundColor: colors.segmentTrack }, (pressed || disabled) && styles.dimmed]}> + style={[styles.iconButton, { backgroundColor: colors.segmentTrack }, disabled && styles.dimmed]}> - + ); } @@ -200,6 +203,10 @@ export default function TimerFormatSheet() { {tools('custom.poiHint')} = CUSTOM_LIMITS.segments} testID="timer-format-add" /> + CUSTOM_LIMITS.encoded} + onPress={() => { + if (cleaned) void shareUrl(cleaned.name, `https://debateowl.com/tools/debate-timer?custom=${encodeCustomFormat(cleaned)}`).catch(() => showNotice(t('common.error'))); + }} /> ); diff --git a/apps/mobile/src/app/timer.tsx b/apps/mobile/src/app/timer.tsx index 127067297..2d492f4d8 100644 --- a/apps/mobile/src/app/timer.tsx +++ b/apps/mobile/src/app/timer.tsx @@ -1,3 +1,4 @@ +import { customToRoundFormat, decodeCustomFormat } from '@debatelab/core/free-tools/custom-format'; import { BUILT_IN_FORMAT_IDS, FORMATS, formatDuration, type RoundSegment } from '@debatelab/core/free-tools/formats'; import { clockFace, @@ -12,12 +13,16 @@ import { toggleSpeech, type PrepSide, } from '@debatelab/core/free-tools/round-timer'; -import { useRouter } from 'expo-router'; -import { useEffect, useState } from 'react'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { AppState, Pressable, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'; +import { AppState, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { PressScale as BouncePressable } from '@/components/press-scale'; +import { Celebration } from '@/components/celebration'; +import { Owl } from '@/components/owl'; +import { roundFeedback, type RoundSummary } from '@/lib/round-feedback'; import { ActionMenu, type MenuItem } from '@/components/action-menu'; import { TextButton } from '@/components/buttons'; import { Icon } from '@/components/icon'; @@ -53,17 +58,47 @@ export default function TimerScreen() { const { t } = useTranslation(); const tools: Translate = (key, options) => t(key, { ns: 'tools', ...options }); const router = useRouter(); + const { custom: customParam } = useLocalSearchParams<{ custom?: string | string[] }>(); + const appliedCustom = useRef(null); + useEffect(() => { + const encoded = Array.isArray(customParam) ? customParam[0] : customParam; + if (!encoded || appliedCustom.current === encoded) return; + appliedCustom.current = encoded; + const custom = decodeCustomFormat(encoded); + if (custom) updateSession((current) => ({ ...current, choice: { id: 'custom', custom }, round: initialRoundState(customToRoundFormat(custom)) })); + }, [customParam]); const insets = useSafeAreaInsets(); const { width, height } = useWindowDimensions(); const layout = useLayoutClass(); const { colors, scheme } = useTheme(); - const { session, saved } = useTimerStore(); + const { session, saved, loaded } = useTimerStore(); const [now, setNow] = useState(() => Date.now()); const [menu, setMenu] = useState(false); + const [celebration, setCelebration] = useState(null); const { round, muted, choice } = session; const { format, segment, elapsed, remainingMs, overtime, inGrace, poi } = speechStatus(session, now); const speechRunning = isSpeechRunning(round); + const [reaction, setReaction] = useState<{ kind: 'start' | 'minute' | 'up'; at: number } | null>(null); + const previous = useRef({ index: round.segmentIndex, remaining: remainingMs, running: speechRunning, choice }); + useEffect(() => { + const before = previous.current; + const sameSpeech = before.choice === choice && before.index === round.segmentIndex; + const started = speechRunning && (!before.running || !sameSpeech) && elapsed < TICK_MS * 2; + const timeUp = sameSpeech && before.remaining > 0 && remainingMs <= 0; + const lastMinute = sameSpeech && before.remaining > 60_000 && remainingMs <= 60_000 && remainingMs > 0; + previous.current = { index: round.segmentIndex, remaining: remainingMs, running: speechRunning, choice }; + if (started || timeUp || lastMinute) setReaction({ kind: timeUp ? 'up' : lastMinute ? 'minute' : 'start', at: Date.now() }); + if (loaded) { + const summary = roundFeedback.takeCompletion(session, Date.now()); + if (summary) setCelebration(summary); + } + }, [choice, elapsed, loaded, now, remainingMs, round.segmentIndex, session, speechRunning]); + useEffect(() => { + if (!reaction) return; + const timeout = setTimeout(() => setReaction(null), 1100); + return () => clearTimeout(timeout); + }, [reaction]); const prepRunning = isPrepRunning(round, 'a') || isPrepRunning(round, 'b'); useKeepAwakeWhile(speechRunning || prepRunning); @@ -152,17 +187,17 @@ export default function TimerScreen() { const ringSize = Math.round(wideLayout ? Math.min(width * 0.36, height * 0.5, 400) : Math.min(width - 72, height * 0.34, 320)); const formatPill = ( - setMenu(true)} testID="timer-format" - style={({ pressed }) => [styles.format, { borderColor: colors.border, backgroundColor: colors.input }, pressed && styles.pressed]}> + style={[styles.format, { borderColor: colors.border, backgroundColor: colors.input }]}> {name} - + ); const clock = ( @@ -178,7 +213,7 @@ export default function TimerScreen() { accessibilityRole="timer" accessibilityLabel={`${tools('timer.clockLabel')}: ${face.text}, ${status}`} testID="timer-display"> - + {status} @@ -192,6 +227,10 @@ export default function TimerScreen() { {t('mobile.timer.ofTotal', { total: formatDuration(segment.seconds) })} + {/* Absolute: no height or width is taken from the ring, POI pills or controls, even at 320×568. */} + + + {poi && !overtime ? ( @@ -227,17 +266,16 @@ export default function TimerScreen() { {format.segments.map((item, index) => { const selected = index === round.segmentIndex; return ( - go(index)} testID={`timer-speech-${index}`} - style={({ pressed }) => [ + style={[ styles.speech, { borderColor: selected ? colors.primaryFill : colors.border, backgroundColor: selected ? colors.primaryFill : colors.card }, - pressed && styles.pressed, ]}> {item.short} @@ -246,7 +284,7 @@ export default function TimerScreen() { {formatDuration(item.seconds)} - + ); })} @@ -259,7 +297,7 @@ export default function TimerScreen() { const left = prepRemainingMs(round, side, now); const running = isPrepRunning(round, side); return ( - {left === 0 ? tools('timer.prepUsed') : running ? tools('timer.pausePrep') : tools('timer.startPrep')} - + ); })} @@ -356,6 +394,16 @@ export default function TimerScreen() { go(round.segmentIndex + 1)} disabled={isLast} testID="timer-next" /> + {celebration ? ( + setCelebration(null)} + /> + ) : null} void }) { const reduceMotion = useReduceMotion(); const [beat, setBeat] = useState(0); const [settled, setSettled] = useState(false); - const lines = useRef(BEATS.slice(1).map(() => new Animated.Value(0))).current; + const [lines] = useState(() => BEATS.slice(1).map(() => new Animated.Value(0))); useEffect(() => { if (reduceMotion || settled) { diff --git a/apps/mobile/src/components/action-menu.tsx b/apps/mobile/src/components/action-menu.tsx index 90adbea16..a1e3e783e 100644 --- a/apps/mobile/src/components/action-menu.tsx +++ b/apps/mobile/src/components/action-menu.tsx @@ -1,4 +1,5 @@ -import { useEffect, useRef } from 'react'; +import { useEffect } from 'react'; +import { useLatest } from '@/lib/live-value'; import { ActionSheetIOS, Alert, Platform } from 'react-native'; import type { IconName } from './icon-paths'; @@ -26,8 +27,7 @@ export interface ActionMenuProps { /** A native iOS action sheet, opened while `visible` is true. The web preview draws its own (action-menu.web.tsx). */ export function ActionMenu({ visible, title, items, cancelLabel, onSelect, onClose }: ActionMenuProps) { - const latest = useRef({ items, onSelect, onClose }); - latest.current = { items, onSelect, onClose }; + const latest = useLatest({ items, onSelect, onClose, title, cancelLabel }); useEffect(() => { if (!visible) return; let live = true; @@ -38,6 +38,7 @@ export function ActionMenu({ visible, title, items, cancelLabel, onSelect, onClo const item = index === undefined ? undefined : current[index]; if (item && !item.disabled) select(item.key); }; + const { title, cancelLabel } = latest.current; const labels = latest.current.items.map((item) => item.label); if (Platform.OS === 'ios') { ActionSheetIOS.showActionSheetWithOptions( @@ -60,7 +61,6 @@ export function ActionMenu({ visible, title, items, cancelLabel, onSelect, onClo live = false; }; // Shown once per opening; the latest items and handlers are read from the ref. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [visible]); + }, [visible, latest]); return null; } diff --git a/apps/mobile/src/components/action-menu.web.tsx b/apps/mobile/src/components/action-menu.web.tsx index e10a2618d..8d99a10dc 100644 --- a/apps/mobile/src/components/action-menu.web.tsx +++ b/apps/mobile/src/components/action-menu.web.tsx @@ -1,5 +1,6 @@ import { Modal, Pressable, StyleSheet, View } from 'react-native'; +import { PressScale as BouncePressable } from '@/components/press-scale'; import type { ActionMenuProps } from './action-menu'; import { Icon } from './icon'; import { Text } from './text'; @@ -20,7 +21,7 @@ export function ActionMenu({ visible, title, items, cancelLabel, onSelect, onClo ) : null} {items.map((item, index) => ( - [styles.item, (index > 0 || title) && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && { backgroundColor: colors.line }]}> {item.label} {item.icon ? : null} - + ))} diff --git a/apps/mobile/src/components/announcement-banner.tsx b/apps/mobile/src/components/announcement-banner.tsx index 81853e14e..c67dc58fe 100644 --- a/apps/mobile/src/components/announcement-banner.tsx +++ b/apps/mobile/src/components/announcement-banner.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, Pressable, StyleSheet, View, type ViewStyle } from 'react-native'; +import { ActivityIndicator, StyleSheet, View, type ViewStyle } from 'react-native'; +import { PressScale as BouncePressable } from '@/components/press-scale'; import { IconTile } from './card'; import { Icon } from './icon'; import { Text } from './text'; @@ -43,16 +44,16 @@ export function AnnouncementBanner({ announcements, style }: { announcements: An ) : null} - [styles.dismiss, pressed && styles.pressed]}> + style={[styles.dismiss]}> {dismissing ? : } - + ); } @@ -65,5 +66,4 @@ const styles = StyleSheet.create({ position: { fontSize: 13 }, body: { fontSize: 14.5, lineHeight: 20 }, dismiss: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center', marginTop: -4, marginRight: -6 }, - pressed: { opacity: 0.6 }, }); diff --git a/apps/mobile/src/components/app-sidebar.tsx b/apps/mobile/src/components/app-sidebar.tsx index 9c4856a62..13b8625c1 100644 --- a/apps/mobile/src/components/app-sidebar.tsx +++ b/apps/mobile/src/components/app-sidebar.tsx @@ -2,10 +2,11 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { useNavigationContainerRef, usePathname, useRouter } from 'expo-router'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { ScrollView, StyleSheet, View } from 'react-native'; import Animated, { FadeInUp, FadeOutUp, LinearTransition, useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { PressScale as BouncePressable } from '@/components/press-scale'; import { Icon } from './icon'; import { OwlMark, useReduceMotion } from './owl'; import { PressScale } from './press-scale'; @@ -154,7 +155,7 @@ export function AppSidebar({ width }: { width: number }) { - [styles.switcher, { backgroundColor: colors.tile, borderColor: colors.tileBorder }, pressed && styles.pressed]} + style={[styles.switcher, { backgroundColor: colors.tile, borderColor: colors.tileBorder }]} testID="sidebar-debate-switcher"> @@ -173,8 +174,8 @@ export function AppSidebar({ width }: { width: number }) { - - + { @@ -182,10 +183,10 @@ export function AppSidebar({ width }: { width: number }) { router.push('/new-debate'); }} hitSlop={6} - style={({ pressed }) => [styles.newDebate, { backgroundColor: colors.primaryFill }, pressed && styles.pressed]} + style={[styles.newDebate, { backgroundColor: colors.primaryFill }]} testID="sidebar-new-debate"> - + {SIDEBAR_SECTIONS.map((section) => ( @@ -241,7 +242,7 @@ function SidebarGroup({ return ( {/* A heading for VoiceOver's rotor that also opens and closes the section, as iPadOS sidebar headers do. */} - [styles.groupHeader, pressed && styles.pressed]} + style={[styles.groupHeader]} testID={`sidebar-toggle-${section.key}`}> {title} @@ -259,7 +260,7 @@ function SidebarGroup({ - + {expanded && loaded ? section.items.map((item, index) => { const selected = item.key === active; @@ -311,5 +312,4 @@ const styles = StyleSheet.create({ activeDot: { width: 7, height: 7, borderRadius: 4 }, row: { flexDirection: 'row', alignItems: 'center', gap: 12, borderRadius: 11, paddingHorizontal: 12, paddingVertical: 9, minHeight: 44 }, rowLabel: { flex: 1, fontSize: 16, lineHeight: 21 }, - pressed: { opacity: 0.7 }, }); diff --git a/apps/mobile/src/components/app-tabs.web.tsx b/apps/mobile/src/components/app-tabs.web.tsx index 9ab534e7d..83f1afb26 100644 --- a/apps/mobile/src/components/app-tabs.web.tsx +++ b/apps/mobile/src/components/app-tabs.web.tsx @@ -1,9 +1,10 @@ import { TabList, TabSlot, TabTrigger, Tabs, type TabTriggerSlotProps } from 'expo-router/ui'; import { forwardRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { Pressable, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { PressScale as BouncePressable } from '@/components/press-scale'; import { Icon } from './icon'; import type { IconName } from './icon-paths'; import { TABS } from './tabs'; @@ -53,13 +54,13 @@ const TabButton = forwardRef + {/* Tab labels are fixed chrome, so they stop growing at a modest Dynamic Type size. */} {label} - + ); }); diff --git a/apps/mobile/src/components/app-text-input.tsx b/apps/mobile/src/components/app-text-input.tsx index 124c156bc..e25e7a36b 100644 --- a/apps/mobile/src/components/app-text-input.tsx +++ b/apps/mobile/src/components/app-text-input.tsx @@ -1,7 +1,8 @@ import { forwardRef } from 'react'; -import { InputAccessoryView, Keyboard, Platform, Pressable, StyleSheet, TextInput, View, type TextInputProps } from 'react-native'; +import { InputAccessoryView, Keyboard, Platform, StyleSheet, TextInput, View, type TextInputProps } from 'react-native'; import { useTranslation } from 'react-i18next'; +import { PressScale as BouncePressable } from '@/components/press-scale'; import { scaledForPreview, Text } from './text'; import { useTheme } from '@/theme/use-theme'; @@ -33,11 +34,11 @@ export function KeyboardDoneBar() { return ( - Keyboard.dismiss()} hitSlop={8} style={styles.done}> + Keyboard.dismiss()} hitSlop={8} style={styles.done}> {t('mobile.common.done')} - + ); diff --git a/apps/mobile/src/components/ask-answer.tsx b/apps/mobile/src/components/ask-answer.tsx index b1fd54a68..121ad9c9b 100644 --- a/apps/mobile/src/components/ask-answer.tsx +++ b/apps/mobile/src/components/ask-answer.tsx @@ -3,8 +3,9 @@ import { useRouter } from 'expo-router'; import * as WebBrowser from 'expo-web-browser'; import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; +import { ActivityIndicator, StyleSheet, View } from 'react-native'; +import { PressScale as BouncePressable } from '@/components/press-scale'; import { SuccessCheck } from './success-check'; import { Chip, CiteBadge } from './chip'; import { FadingScrollRow } from './edge-fade'; @@ -122,7 +123,7 @@ function SaveToLibrary({ onSave }: { onSave: () => Promise }) { ); } return ( - Promise }) { setState('saving'); void onSave().then((saved) => setState(saved ? 'saved' : 'idle')); }} - style={({ pressed }) => [styles.save, pressed && styles.pressed]} + style={[styles.save]} testID="ask-save"> {state === 'saving' ? : } {t('askChat.saveToLibrary')} - + ); } @@ -169,5 +170,4 @@ const styles = StyleSheet.create({ saved: { flexDirection: 'row', alignItems: 'center', gap: 7, alignSelf: 'flex-start', minHeight: 32 }, savedCheck: { width: 20, height: 20, borderRadius: 10, alignItems: 'center', justifyContent: 'center' }, saveText: { fontSize: 14.5 }, - pressed: { opacity: 0.6 }, }); diff --git a/apps/mobile/src/components/ask-chat-list.tsx b/apps/mobile/src/components/ask-chat-list.tsx index 88938bd64..fbbdede77 100644 --- a/apps/mobile/src/components/ask-chat-list.tsx +++ b/apps/mobile/src/components/ask-chat-list.tsx @@ -1,9 +1,10 @@ import { useFocusEffect } from 'expo-router'; import { useCallback, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { FlatList, Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native'; +import { FlatList, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { PressScale as BouncePressable } from '@/components/press-scale'; import { Card, ListItemIn, SkeletonTextRows } from './card'; import { Icon } from './icon'; import { CardRowFrame, useRowArrivals } from './list-screen'; @@ -166,12 +167,12 @@ export function AskChatPane({ return ( - + - - + + - + [styles.text, (pressed || disabled) && styles.dimmed]}> + style={[styles.text, disabled && styles.dimmed]}> {label} - + ); } diff --git a/apps/mobile/src/components/card.tsx b/apps/mobile/src/components/card.tsx index 6c1963cb5..bc1c34fad 100644 --- a/apps/mobile/src/components/card.tsx +++ b/apps/mobile/src/components/card.tsx @@ -1,7 +1,7 @@ -import type { ReactNode } from 'react'; +import { useEffect, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native'; -import Animated, { FadeInDown, LinearTransition, useAnimatedStyle, useSharedValue, withSpring, withTiming } from 'react-native-reanimated'; +import Animated, { FadeInDown, LinearTransition, cancelAnimation, useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated'; import { Icon } from './icon'; import type { IconName } from './icon-paths'; @@ -46,6 +46,23 @@ export function ListItemIn({ index, layout = false, enter = true, style, childre ); } +// App-session keys belong to decorative entrances, never to saved content or request state. +const entered = new Set(); + +/** A short staggered spring the first time this tab's card appears this app session. */ +export function SessionEntrance({ name, index = 0, style, children }: { name: string; index?: number; style?: StyleProp; children: ReactNode }) { + const reduce = useReduceMotion(); + const [enter] = useState(() => !entered.has(name)); + useEffect(() => { entered.add(name); }, [name]); + return ( + + {children} + + ); +} + /** * PressScale's give for a row that must stay its own Pressable (a Link's trigger, a row in a * swipeable, a tile whose layout lives on the Pressable): spread the handlers on the Pressable and @@ -54,11 +71,17 @@ export function ListItemIn({ index, layout = false, enter = true, style, childre export function usePressScale(scaleTo = 0.97) { const reduce = useReduceMotion(); const scale = useSharedValue(1); - const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] })); + useEffect(() => { + if (reduce) { + cancelAnimation(scale); + scale.set(1); + } + }, [reduce, scale]); + const style = useAnimatedStyle(() => ({ transform: [{ scale: reduce ? 1 : scale.value }] })); return { style, onPressIn: () => { - if (!reduce) scale.set(withTiming(scaleTo, { duration: 90 })); + if (!reduce) scale.set(withSpring(scaleTo, SPRING.press)); }, onPressOut: () => { if (!reduce) scale.set(withSpring(1, SPRING.pop)); diff --git a/apps/mobile/src/components/case-document.tsx b/apps/mobile/src/components/case-document.tsx index aad4afd0f..5211f5b2c 100644 --- a/apps/mobile/src/components/case-document.tsx +++ b/apps/mobile/src/components/case-document.tsx @@ -4,8 +4,9 @@ import type { GenerateOutput } from '@debatelab/core/ai/schemas/generate'; import * as WebBrowser from 'expo-web-browser'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { Pressable, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; +import { PressScale as BouncePressable } from '@/components/press-scale'; import { hostOf, shorten, type CitationTarget } from './citation-target'; import { Bullets, Callout, Divider, DocSection, Labeled, NumberedItems, Quote, SubHeading, Tag } from './document'; import { Icon } from './icon'; @@ -284,19 +285,19 @@ export function CaseDocument({ const url = source.url; const label = [sourceLabel(source), source.claim].filter(Boolean).join(' – '); return url ? ( - void WebBrowser.openBrowserAsync(url)} - style={({ pressed }) => [styles.caseSource, pressed && styles.pressed]}> + style={[styles.caseSource]}> {sourceLabel(source)} {source.claim ? `${source.org ? ' – ' : ''}${source.claim}` : ''} {` – ${hostOf(url)}`} - + ) : ( {label} @@ -325,11 +326,11 @@ export function ResearchedSources({ sources }: { sources: DiscoveredSource[] }) return ( {sources.map((source, index) => ( - void WebBrowser.openBrowserAsync(source.url)} - style={({ pressed }) => [styles.source, index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> + style={[styles.source, index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }]}> @@ -344,7 +345,7 @@ export function ResearchedSources({ sources }: { sources: DiscoveredSource[] }) ].filter(Boolean).join(' · ')} - + ))} ); @@ -375,5 +376,4 @@ const styles = StyleSheet.create({ sourceText: { flex: 1, gap: 2 }, sourceTitle: { fontSize: 15, lineHeight: 20 }, sourceMeta: { fontSize: 13 }, - pressed: { opacity: 0.7 }, }); diff --git a/apps/mobile/src/components/celebration.tsx b/apps/mobile/src/components/celebration.tsx index f9afcfad7..513fb1ebc 100644 --- a/apps/mobile/src/components/celebration.tsx +++ b/apps/mobile/src/components/celebration.tsx @@ -18,6 +18,7 @@ import { Owl } from './owl'; import { SuccessCheck } from './success-check'; import { Text } from './text'; +import { sessionSlot } from '@/lib/session-memory'; import { haptics } from '@/lib/haptics'; import { palette, radius } from '@/theme/colors'; import { DURATION, EASE, SPRING, staggerDelay, useReduceMotion } from '@/theme/motion'; @@ -47,19 +48,19 @@ const plain = (value: number) => String(value); * Results that should celebrate when their document opens: Generate marks the case it just * made, and the case screen takes the mark once, so reopening a saved case never celebrates. */ -const pending = new Set(); +const pending = sessionSlot(() => new Set()); export function markCelebration(key: string) { - pending.add(key); + pending.get().add(key); } /** Whether `key` was just finished; the mark is cleared by `clearCelebration` once the screen has read it. */ export function hasCelebration(key: string | undefined): boolean { - return Boolean(key && pending.has(key)); + return Boolean(key && pending.get().has(key)); } export function clearCelebration(key: string | undefined) { - if (key) pending.delete(key); + if (key) pending.get().delete(key); } /** @@ -76,12 +77,15 @@ export function Celebration({ stats, onDone, haptic = true, + encouraging = false, }: { eyebrow?: string; title: string; stats: CelebrationStat[]; onDone: () => void; haptic?: boolean; + /** A completed practice with a lower score gets encouragement, without a victory burst. */ + encouraging?: boolean; }) { const { t } = useTranslation(); const { colors } = useTheme(); @@ -149,11 +153,11 @@ export function Celebration({ style={[StyleSheet.absoluteFill, styles.scrim, { backgroundColor: colors.background }]}> - + - {reduce ? null : } + {reduce || encouraging ? null : } {eyebrow ? ( @@ -206,7 +210,7 @@ function StatTile({ stat, index, animate, last }: { stat: CelebrationStat; index ); } -/** The number alone re-renders while it counts, on the display's frames, eased out over 600 ms. */ +/** The number alone re-renders while it counts, at most ten times a second, eased out over 600 ms. */ function CountUp({ value, format, delay, animate, tick }: { value: number; format: (value: number) => string; delay: number; animate: boolean; tick: boolean }) { const { colors } = useTheme(); const [counted, setCurrent] = useState(animate ? 0 : value); @@ -214,21 +218,24 @@ function CountUp({ value, format, delay, animate, tick }: { value: number; forma const current = animate ? counted : value; useEffect(() => { if (!animate) return; - let frame = 0; + let interval: ReturnType | undefined; let start = 0; - const step = (now: number) => { - if (!start) start = now; + const step = () => { + const now = Date.now(); const progress = Math.min(1, (now - start) / COUNT_MS); setCurrent(Math.round(value * (1 - Math.pow(1 - progress, 3)))); - if (progress < 1) frame = requestAnimationFrame(step); - else if (tick) haptics.select(); + if (progress >= 1) { + clearInterval(interval); + if (tick) haptics.select(); + } }; const timer = setTimeout(() => { - frame = requestAnimationFrame(step); + start = Date.now(); + interval = setInterval(step, 100); }, delay); return () => { clearTimeout(timer); - cancelAnimationFrame(frame); + clearInterval(interval); }; }, [animate, delay, tick, value]); return ( diff --git a/apps/mobile/src/components/chip.tsx b/apps/mobile/src/components/chip.tsx index 01ca25210..4c5f9dfdf 100644 --- a/apps/mobile/src/components/chip.tsx +++ b/apps/mobile/src/components/chip.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; -import { Pressable, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; +import { PressScale as BouncePressable } from '@/components/press-scale'; import { Icon } from './icon'; import type { IconName } from './icon-paths'; import { Text } from './text'; @@ -35,7 +36,7 @@ export function Chip({ const { colors } = useTheme(); const foreground = selected ? colors.chipSelectedForeground : colors.secondary; return ( - [ + style={[ styles.chip, selected ? { backgroundColor: colors.tile, borderColor: colors.chipSelectedBorder } : { backgroundColor: colors.input, borderColor: colors.border }, - pressed && styles.pressed, ]}> {leading} {icon ? : null} @@ -59,7 +59,7 @@ export function Chip({ {label} {trailing} - + ); } @@ -87,7 +87,6 @@ const styles = StyleSheet.create({ maxWidth: 280, }, label: { fontSize: 14.5, flexShrink: 1 }, - pressed: { opacity: 0.7 }, cite: { minWidth: 20, height: 20, borderRadius: 6, paddingHorizontal: 5, alignItems: 'center', justifyContent: 'center' }, citeText: { fontSize: 12, lineHeight: 15 }, }); diff --git a/apps/mobile/src/components/claim-panel.tsx b/apps/mobile/src/components/claim-panel.tsx index b2b534f26..4c0f24da1 100644 --- a/apps/mobile/src/components/claim-panel.tsx +++ b/apps/mobile/src/components/claim-panel.tsx @@ -13,8 +13,9 @@ import { } from '@debatelab/core/flowchart'; import { useState, type ComponentType, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { ScrollView, StyleSheet, View } from 'react-native'; +import { PressScale as BouncePressable } from '@/components/press-scale'; import { ActionMenu } from './action-menu'; import { TextButton } from './buttons'; import { Chip } from './chip'; @@ -168,7 +169,7 @@ export function ClaimPanel({ {others.map((other, index) => { const allowed = canLink(draft.doc, other.id, node.id); return ( - ({ ...current, doc: linkCards(current.doc, other.id, node.id, kind), edited: true })); setMode('view'); }} - style={({ pressed }) => [styles.row, index > 0 && { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.line }, pressed && styles.pressed, !allowed && styles.disabled]} + style={[styles.row, index > 0 && { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.line }, !allowed && styles.disabled]} testID="link-target"> @@ -188,7 +189,7 @@ export function ClaimPanel({ {allowed ? typeLabel(t, other.data.type) : t('mobile.flowchart.alreadyLinked')} - + ); })} @@ -225,15 +226,15 @@ export function ClaimPanel({ 0 && { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.line }]}> {line} - change((current) => ({ ...current, doc: unlinkCards(current.doc, link.edgeId), edited: true }))} - style={({ pressed }) => [styles.remove, pressed && styles.pressed]} + style={[styles.remove]} testID="remove-link"> - + ); })} @@ -288,9 +289,9 @@ export function ClaimInspector({ claim, width, onClose }: { claim: { scope: stri {t('export.claim')} - + - + @@ -317,6 +318,5 @@ const styles = StyleSheet.create({ rowMeta: { fontSize: 13, lineHeight: 17 }, dot: { width: 10, height: 10, borderRadius: 3 }, remove: { minWidth: 32, minHeight: 32, alignItems: 'center', justifyContent: 'center' }, - pressed: { opacity: 0.7 }, disabled: { opacity: 0.45 }, }); diff --git a/apps/mobile/src/components/crossfire-views.tsx b/apps/mobile/src/components/crossfire-views.tsx index 80409ab1d..6a6380b9e 100644 --- a/apps/mobile/src/components/crossfire-views.tsx +++ b/apps/mobile/src/components/crossfire-views.tsx @@ -1,12 +1,14 @@ import type { CrossfireDebrief, CrossfireTurn } from '@debatelab/core/crossfire/schemas'; import { useTranslation } from 'react-i18next'; -import { Pressable, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; +import { PressScale as BouncePressable } from '@/components/press-scale'; import { PopIn } from './celebration'; import { Callout, DocSection, Paragraph, Tag } from './document'; import { Icon } from './icon'; import { Text } from './text'; +import { haptics } from '@/lib/haptics'; import { radius } from '@/theme/colors'; import { useTheme } from '@/theme/use-theme'; @@ -34,13 +36,16 @@ export function ChoiceCards({ {options.map((option, index) => { const selected = option.value === value; return ( - onChange(option.value)} + onPress={() => { + if (!selected) haptics.select(); + onChange(option.value); + }} testID={`${testIDPrefix}-${option.value}`} - style={({ pressed }) => [styles.card, index > 0 && { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.line }, pressed && styles.pressed]}> + style={[styles.card, index > 0 && { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.line }]}> {option.label} @@ -50,7 +55,7 @@ export function ChoiceCards({ {selected ? : } - + ); })} @@ -235,7 +240,6 @@ const styles = StyleSheet.create({ cardLabel: { fontSize: 16.5 }, cardDescription: { fontSize: 14, lineHeight: 19 }, checkSpace: { width: 20 }, - pressed: { opacity: 0.7 }, tags: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 4 }, empty: { borderWidth: 2, borderStyle: 'dashed', borderRadius: radius.card, padding: 22 }, emptyText: { fontSize: 15, lineHeight: 21, textAlign: 'center' }, diff --git a/apps/mobile/src/components/debate-form.tsx b/apps/mobile/src/components/debate-form.tsx index 8e952abc2..c844e4127 100644 --- a/apps/mobile/src/components/debate-form.tsx +++ b/apps/mobile/src/components/debate-form.tsx @@ -1,7 +1,8 @@ import { useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Pressable, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; +import { PressScale as BouncePressable } from '@/components/press-scale'; import { MainActionButton } from './buttons'; import { Card } from './card'; import { Field } from './field'; @@ -19,6 +20,7 @@ import { type Debate, type DebateSettings, } from '@/lib/debates'; +import { runRequest } from '@/lib/run-request'; import { haptics } from '@/lib/haptics'; import { useTheme } from '@/theme/use-theme'; @@ -62,20 +64,20 @@ export function DebateForm({ savingRef.current = true; setSaving(true); setError(''); - try { + return await runRequest(async () => { 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) { + }, async (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 ( @@ -105,7 +107,7 @@ export function DebateForm({ {DEBATE_FORMATS.map((value, index) => { const selected = value === format; return ( - [styles.option, index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> + style={[styles.option, index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }]}> {t(`debate.formats.${value}`)} {selected ? : null} - + ); })} @@ -188,5 +190,4 @@ const styles = StyleSheet.create({ 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/document-header.web.tsx b/apps/mobile/src/components/document-header.web.tsx index df7f16c2e..335108e2c 100644 --- a/apps/mobile/src/components/document-header.web.tsx +++ b/apps/mobile/src/components/document-header.web.tsx @@ -1,9 +1,10 @@ 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 { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { PressScale as BouncePressable } from '@/components/press-scale'; import { ActionMenu } from './action-menu'; import type { HeaderAction, HeaderButton } from './document-header'; import { Icon } from './icon'; @@ -20,27 +21,27 @@ export function DocumentHeader({ backLabel, onShare, actions, buttons = [] }: { const [open, setOpen] = useState(false); return ( - router.back()} style={styles.back} testID="document-back"> + router.back()} style={styles.back} testID="document-back"> {backLabel} - + {buttons.map((button) => ( - + - + ))} {onShare ? ( - + - + ) : null} {actions.length > 0 ? ( - setOpen(true)} hitSlop={8} style={styles.icon} testID="document-more"> + setOpen(true)} hitSlop={8} style={styles.icon} testID="document-more"> - + ) : null} ( {index > 0 ? : null} - toggle(item.key)} testID="document-item" - style={({ pressed }) => [styles.itemHeader, pressed && styles.pressed]}> + style={[styles.itemHeader]}> {n} @@ -299,7 +300,7 @@ export function NumberedItems({ items, testID, outline = false }: { items: Numbe )} - + {group.open ? {item.body} : null} ))} @@ -468,7 +469,6 @@ const styles = StyleSheet.create({ contentRail: { alignSelf: 'flex-start', paddingLeft: 20, paddingRight: 28 }, pinnedRail: { alignItems: 'flex-start' }, pinnedRowRail: { paddingLeft: 16, paddingRight: 24 }, - pressed: { opacity: 0.7 }, notice: { position: 'absolute', left: 24, right: 24, borderRadius: 14, paddingHorizontal: 16, paddingVertical: 11, alignSelf: 'center', maxWidth: 560 }, noticeRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10, borderWidth: 2 }, noticeCheck: { width: 24, height: 24, borderRadius: 12, alignItems: 'center', justifyContent: 'center' }, diff --git a/apps/mobile/src/components/factoids-document.tsx b/apps/mobile/src/components/factoids-document.tsx index 4a0b27079..6f72480ce 100644 --- a/apps/mobile/src/components/factoids-document.tsx +++ b/apps/mobile/src/components/factoids-document.tsx @@ -2,8 +2,9 @@ import type { DiscoveredSource } from '@debatelab/core/ai/discovered-sources'; import type { Factoid, FactoidsInput, FactoidsOutput } from '@debatelab/core/ai/schemas/factoids'; import { SafeWebUrlSchema } from '@debatelab/core/ai/schemas/url'; import { useTranslation } from 'react-i18next'; -import { Pressable, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; +import { PressScale as BouncePressable } from '@/components/press-scale'; import { TextButton } from './buttons'; import { ResearchedSources } from './case-document'; import { hostOf, shorten, type CitationTarget } from './citation-target'; @@ -157,17 +158,17 @@ export function FactoidsDocument({ ) : ( - onSearch(query)} - style={({ pressed }) => [styles.suggestion, { backgroundColor: colors.background }, pressed && styles.pressed]}> + style={[styles.suggestion, { backgroundColor: colors.background }]}> {query} - + ))} ) : null} @@ -190,5 +191,4 @@ const styles = StyleSheet.create({ body: { fontSize: 15.5, lineHeight: 22 }, suggestion: { flexDirection: 'row', alignItems: 'center', gap: 10, borderRadius: 10, paddingHorizontal: 12, paddingVertical: 10 }, suggestionText: { flex: 1, fontSize: 14.5, lineHeight: 20 }, - pressed: { opacity: 0.7 }, }); diff --git a/apps/mobile/src/components/flowchart-views.tsx b/apps/mobile/src/components/flowchart-views.tsx index adbfe7722..5f4901cf5 100644 --- a/apps/mobile/src/components/flowchart-views.tsx +++ b/apps/mobile/src/components/flowchart-views.tsx @@ -1,7 +1,8 @@ import { flowColumns, mapRows, type FlowchartDocument, type FlowEdgeKind, type FlowNode, type FlowNodeType } from '@debatelab/core/flowchart'; import { useTranslation } from 'react-i18next'; -import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { ScrollView, StyleSheet, View } from 'react-native'; +import { PressScale as BouncePressable } from '@/components/press-scale'; import { Text } from './text'; import { flowMeta, mapMeta, typeLabel, viaLabel } from '@/lib/flowchart-text'; @@ -52,18 +53,17 @@ export function FlowCard({ node, meta, onPress, testID, selected = false }: { no const { colors } = useTheme(); const flow = useFlowColors(); return ( - [ + style={[ styles.card, { backgroundColor: flow.cardFill, borderColor: colors.border, borderLeftColor: flow.type[node.data.type] }, // The card open in the iPad inspector. selected && { backgroundColor: colors.tile, borderColor: colors.chipSelectedBorder, borderLeftColor: flow.type[node.data.type] }, - pressed && styles.pressed, ]}> {node.data.label} @@ -73,7 +73,7 @@ export function FlowCard({ node, meta, onPress, testID, selected = false }: { no {meta} ) : null} - + ); } @@ -168,7 +168,6 @@ const styles = StyleSheet.create({ card: { borderWidth: 2, borderLeftWidth: 4, borderRadius: radius.field, paddingHorizontal: 11, paddingVertical: 10, marginBottom: 10 }, cardLabel: { fontSize: 14.5, lineHeight: 19 }, cardMeta: { fontSize: 12.5, lineHeight: 17, marginTop: 3 }, - pressed: { opacity: 0.7 }, legend: { flexDirection: 'row', flexWrap: 'wrap', columnGap: 12, rowGap: 6, paddingHorizontal: SIDE, marginBottom: 12 }, legendItem: { flexDirection: 'row', alignItems: 'center', gap: 5 }, swatch: { width: 10, height: 10, borderRadius: 3 }, diff --git a/apps/mobile/src/components/form-rows.tsx b/apps/mobile/src/components/form-rows.tsx index 65ef5c524..00a1465eb 100644 --- a/apps/mobile/src/components/form-rows.tsx +++ b/apps/mobile/src/components/form-rows.tsx @@ -1,7 +1,8 @@ import { useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { Platform, Pressable, StyleSheet, Switch, View } from 'react-native'; +import { Platform, StyleSheet, Switch, View } from 'react-native'; +import { PressScale as BouncePressable } from '@/components/press-scale'; import { ActionMenu } from './action-menu'; import { Icon } from './icon'; import { Text } from './text'; @@ -59,14 +60,14 @@ export function SelectRow({ const chosen = options.find((option) => option.value === value)?.label ?? value; return ( <> - setOpen(true)} testID={testID} - style={({ pressed }) => [pressed && styles.pressed, disabled && styles.disabled]}> + style={[disabled && styles.disabled]}> {label} @@ -77,7 +78,7 @@ export function SelectRow({ - + ( - {item.icon ? : null} - + ); return ( @@ -67,7 +68,7 @@ export function LibraryHeader({ title, onAdd, disabled, sort, onSort, filter, on {title} - - - + - + {/* Under the buttons, wherever a large text size puts them. */} {open === 'add' ? ( diff --git a/apps/mobile/src/components/library-parts.tsx b/apps/mobile/src/components/library-parts.tsx index ac3e91714..069c2a7bc 100644 --- a/apps/mobile/src/components/library-parts.tsx +++ b/apps/mobile/src/components/library-parts.tsx @@ -1,8 +1,9 @@ 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 { StyleSheet, Switch, View } from 'react-native'; +import { PressScale as BouncePressable } from '@/components/press-scale'; import { ActionMenu } from './action-menu'; import { WEB_SWITCH_THUMB } from './form-rows'; import { Icon } from './icon'; @@ -93,15 +94,15 @@ export function FolderBar({ testID="folder-active" {...WEB_SWITCH_THUMB} /> - setMenu(true)} testID="folder-menu" - style={({ pressed }) => [styles.more, { backgroundColor: colors.segmentTrack }, pressed && styles.pressed]}> + style={[styles.more, { backgroundColor: colors.segmentTrack }]}> - + = Omit, 'ListHeaderComponent' | 'contentContainerStyle' | 'CellRendererComponent' | 'onRefresh' | 'refreshing'> & { /** What `Screen` would show above the list: titles, pickers, search. */ header?: ReactNode; + gap?: number; safeTop?: boolean; wide?: boolean; /** The column's style when the list sits in an iPad pane instead of a tab. */ @@ -31,7 +32,7 @@ type ListScreenProps = Omit, 'ListHeaderComponent' | 'conten * the rows are a FlatList, so only those near the screen are drawn. Rows that * should look like one card use `CardRowFrame`. */ -export function ListScreen({ header, safeTop, wide, contentStyle, animateLayout = false, onRefresh, ...list }: ListScreenProps): ReactElement { +export function ListScreen({ header, gap = 16, safeTop, wide, contentStyle, animateLayout = false, onRefresh, ...list }: ListScreenProps): ReactElement { const insets = useSafeAreaInsets(); const reduce = useReduceMotion(); const { colors } = useTheme(); @@ -51,8 +52,10 @@ export function ListScreen({ header, safeTop, wide, contentStyle, animateLayo itemLayoutAnimation={animateLayout ? LinearTransition.duration(200).reduceMotion(motionSetting(reduce)) : undefined} refreshControl={refresh ? : undefined} {...list} + // Above the rows, so a menu the header opens (the web preview draws its own) is not drawn under a row's entrance animation. + ListHeaderComponentStyle={styles.headerLayer} ListHeaderComponent={ - + {header} @@ -96,6 +99,7 @@ const styles = StyleSheet.create({ content: { paddingHorizontal: 20, paddingBottom: 32, width: '100%', maxWidth: 640, alignSelf: 'center' }, wide: { paddingHorizontal: 36, paddingBottom: 48, maxWidth: HOME_MAX_WIDTH }, header: { gap: 16, paddingBottom: 16 }, + headerLayer: { zIndex: 1 }, frame: { borderLeftWidth: 2, borderRightWidth: 2, overflow: 'hidden' }, frameFirst: { borderTopWidth: 2, borderTopLeftRadius: radius.card, borderTopRightRadius: radius.card }, frameLast: { borderBottomWidth: 2, borderBottomLeftRadius: radius.card, borderBottomRightRadius: radius.card }, diff --git a/apps/mobile/src/components/live-run.tsx b/apps/mobile/src/components/live-run.tsx index 4555e93c7..d39dad87a 100644 --- a/apps/mobile/src/components/live-run.tsx +++ b/apps/mobile/src/components/live-run.tsx @@ -1,3 +1,4 @@ +import type { TFunction } from 'i18next'; import type { LiveGeneration, LivePhase } from '@debatelab/core/ai/live'; import type { DraftBlock, DraftSection } from '@debatelab/core/ai/live-sections'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -12,6 +13,7 @@ import { ToolProgress } from './tool-form'; import { beginGeneratingActivity, type GeneratingActivityHandle } from '@/lib/generating-activity'; import type { GeneratingTool } from '@/lib/generating-activity-state'; +import { useLatest, useLiveValue, useLiveValueStore, type LiveValue } from '@/lib/live-value'; import { noteFinishedResult } from '@/lib/review-prompt'; import { motionSetting, SPRING } from '@/theme/motion'; import { radius } from '@/theme/colors'; @@ -43,20 +45,22 @@ export interface LiveRunActivity { * marks it ready, `end('failed')` as failed, and any other end takes it away. */ export function useLiveRun(activity?: LiveRunActivity) { - const [state, setState] = useState({}); + const state = useLiveValueStore({}); const run = useRef(0); const live = useRef(true); const lockScreen = useRef(null); - const activityRef = useRef(activity); - activityRef.current = activity; - useEffect(() => () => { - live.current = false; - lockScreen.current?.finish('cancelled'); + const activityRef = useLatest(activity); + useEffect(() => { + live.current = true; + return () => { + live.current = false; + lockScreen.current?.finish('cancelled'); + }; }, []); const begin = useCallback((title?: string): LiveGeneration => { const id = ++run.current; - setState({}); + state.set({}); lockScreen.current?.finish('cancelled'); const tool = activityRef.current; const handle = tool && title ? beginGeneratingActivity(tool.tool, title) : null; @@ -67,26 +71,26 @@ export function useLiveRun(activity?: LiveRunActivity) { return { onDraft: (draft) => { if (!current()) return; - setState((previous) => ({ ...previous, phase: 'writing', draft })); + state.set((previous) => ({ ...previous, phase: 'writing', draft })); phase = 'writing'; parts = activityRef.current?.parts(draft) ?? parts; handle?.update({ phase, parts }); }, onPhase: (next) => { if (!current()) return; - setState((previous) => ({ ...previous, phase: next })); + state.set((previous) => ({ ...previous, phase: next })); phase = next; handle?.update({ phase, parts }); }, }; - }, []); + }, [state, activityRef]); const end = useCallback((outcome: 'failed' | 'cancelled' = 'cancelled') => { run.current += 1; lockScreen.current?.finish(outcome); lockScreen.current = null; - if (live.current) setState({}); - }, []); + if (live.current) state.set({}); + }, [state]); /** The answer arrived: the Lock Screen says it is ready. The screen moves on to the document itself. */ const complete = useCallback(() => { @@ -121,7 +125,7 @@ function usePhaseLabel(phase: LivePhase | undefined, fallback: string): string { /** A blinking bar where the model is writing; steady under Reduce Motion, as on the website. */ function Caret() { const { colors } = useTheme(); - const opacity = useRef(new Animated.Value(1)).current; + const [opacity] = useState(() => new Animated.Value(1)); const reduceMotion = useReduceMotion(); useEffect(() => { if (reduceMotion) { @@ -190,14 +194,14 @@ function Block({ block, writing }: { block: DraftBlock; writing: boolean }) { export function LiveToolRun({ label, run, - sections, + sections: buildSections, detail, onCancel, pose, }: { label: string; - run: LiveRunState; - sections: DraftSection[]; + run: LiveValue; + sections: (draft: Record, t: TFunction) => DraftSection[]; detail?: string; onCancel: () => void; /** The tool's own owl before the answer starts; the phase picks it otherwise. */ @@ -206,21 +210,23 @@ export function LiveToolRun({ const reduceMotion = useReduceMotion(); const { t } = useTranslation(); const { colors } = useTheme(); - const status = usePhaseLabel(run.phase, label); + const state = useLiveValue(run); + const sections = state.draft ? buildSections(state.draft, t) : []; + const status = usePhaseLabel(state.phase, label); const elapsed = useElapsedSeconds(); // The owl looks things up while researching or checking sources, as on the website. if (sections.length === 0) { - const looking = run.phase === 'research' || run.phase === 'checking'; - return ; + const looking = state.phase === 'research' || state.phase === 'checking'; + return ; } - const writing = run.phase === 'writing'; + const writing = state.phase === 'writing'; const lastSection = sections.length - 1; const lastBlock = sections[lastSection].blocks.length - 1; return ( - + {writing ? t('tools.live.writingLive') : status} diff --git a/apps/mobile/src/components/outline.tsx b/apps/mobile/src/components/outline.tsx index 22423c9e1..8d4120aad 100644 --- a/apps/mobile/src/components/outline.tsx +++ b/apps/mobile/src/components/outline.tsx @@ -1,8 +1,10 @@ -import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, type ReactNode, type RefObject } from 'react'; +import { createContext, useContext, useEffect, useId, useMemo, useRef, useState, type ReactNode, type RefObject } from 'react'; import { useTranslation } from 'react-i18next'; -import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { ScrollView, StyleSheet, View } from 'react-native'; +import { PressScale as BouncePressable } from '@/components/press-scale'; import { Text } from './text'; +import { useLatest } from '@/lib/live-value'; import { useTheme } from '@/theme/use-theme'; @@ -43,16 +45,16 @@ export function useOutlineItems(items: { key: string; title: string }[], enabled const views = useRef(new Map()); const id = useId(); const active = outline && enabled ? outline : null; + const latestItems = useLatest(items); const signature = items.map((item) => `${item.key}\u0000${item.title}`).join('\u0001'); useEffect(() => { if (!active) return; - const removals = items.map((item, index) => + const removals = latestItems.current.map((item, index) => active.register({ id: `${id}-${item.key}`, label: item.title, number: index + 1, view: () => views.current.get(item.key) ?? null }), ); return () => removals.forEach((remove) => remove()); // The signature stands for the items' keys and titles. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [active, id, signature]); + }, [active, id, signature, latestItems]); if (!active) return undefined; return (key) => (view) => { views.current.set(key, view); @@ -67,7 +69,9 @@ export function useOutlineItems(items: { key: string; title: string }[], enabled export function useOutlineRail(content: RefObject, scroll: RefObject, covered: number) { const [anchors, setAnchors] = useState>(new Map()); const [positions, setPositions] = useState>(new Map()); - const [scrollY, setScrollY] = useState(0); + const scrollY = useRef(0); + const selected = useRef(undefined); + const [current, setCurrent] = useState(undefined); const outline = useMemo( () => ({ @@ -84,7 +88,7 @@ export function useOutlineRail(content: RefObject, scroll: RefObjec [], ); - const measure = useCallback(() => { + const measure = () => { const root = content.current; if (!root) return; anchors.forEach((anchor) => { @@ -93,13 +97,21 @@ export function useOutlineRail(content: RefObject, scroll: RefObjec setPositions((previous) => (previous.get(anchor.id) === y ? previous : new Map(previous).set(anchor.id, y))), ); }); - }, [anchors, content]); - - useEffect(measure, [measure]); + }; + const latestMeasure = useLatest(measure); + useEffect(() => latestMeasure.current(), [anchors, content, latestMeasure]); const entries = [...anchors.values()].sort((a, b) => (positions.get(a.id) ?? Infinity) - (positions.get(b.id) ?? Infinity)); // The part whose heading has scrolled to the top of the page, or the first. - const current = entries.reduce((found, anchor) => ((positions.get(anchor.id) ?? Infinity) <= scrollY + covered + 32 ? anchor.id : found), entries[0]?.id); + const onScroll = (y: number) => { + scrollY.current = y; + const next = entries.reduce((found, anchor) => ((positions.get(anchor.id) ?? Infinity) <= y + covered + 32 ? anchor.id : found), entries[0]?.id); + if (next === selected.current) return; + selected.current = next; + setCurrent(next); + }; + const latestScroll = useLatest(onScroll); + useEffect(() => latestScroll.current(scrollY.current), [anchors, positions, covered, latestScroll]); const jump = (anchor: Anchor) => { const root = content.current; @@ -108,7 +120,7 @@ export function useOutlineRail(content: RefObject, scroll: RefObjec view.measureLayout(root, (_x, y) => scroll.current?.scrollTo({ y: y - covered - 12, animated: true })); }; - return { outline, entries, current, measure, jump, onScroll: setScrollY }; + return { outline, entries, current: current ?? entries[0]?.id, measure, jump, onScroll }; } export function OutlineProvider({ outline, children }: { outline: Outline; children: ReactNode }) { @@ -127,19 +139,19 @@ export function OutlineRail({ entries, current, onJump }: { entries: Anchor[]; c {entries.map((anchor) => { const on = anchor.id === current; return ( - onJump(anchor)} - style={({ pressed }) => [styles.entry, on && { backgroundColor: colors.tile }, pressed && styles.pressed]}> + style={[styles.entry, on && { backgroundColor: colors.tile }]}> {anchor.number ?? ''} {anchor.label} - + ); })} @@ -152,5 +164,4 @@ const styles = StyleSheet.create({ entry: { flexDirection: 'row', gap: 8, borderRadius: 10, paddingVertical: 7, paddingHorizontal: 8 }, number: { width: 14, fontSize: 14, lineHeight: 19 }, label: { flex: 1, fontSize: 14.5, lineHeight: 19 }, - pressed: { opacity: 0.7 }, }); diff --git a/apps/mobile/src/components/owl.tsx b/apps/mobile/src/components/owl.tsx index e59c1e8f0..946c28042 100644 --- a/apps/mobile/src/components/owl.tsx +++ b/apps/mobile/src/components/owl.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Image, type ImageSourcePropType, type ImageStyle, StyleSheet, View } from 'react-native'; import Animated, { cancelAnimation, @@ -58,7 +58,7 @@ const POSES: Record = { const MARK: ImageSourcePropType = require('@/assets/owl/mark.png'); /** A one-shot move the owl plays when it arrives or changes pose. */ -export type OwlGesture = 'wave' | 'cheer' | 'hop'; +export type OwlGesture = 'wave' | 'cheer' | 'hop' | 'shake'; const GESTURES: Partial> = { point: 'wave', @@ -152,6 +152,9 @@ function OwlLids({ eyes, size, lid, offset = { left: 0, top: 0 } }: { eyes: read /** The gesture's transform at progress 0..1: the website's owl-wave, owl-cheer and owl-hop keyframes. */ function gestureTransform(move: OwlGesture | undefined, t: number, size: number) { 'worklet'; + if (move === 'shake') { + return [{ translateX: interpolate(t, [0, 0.2, 0.4, 0.6, 0.8, 1], [0, -3, 3, -2, 1, 0]) }]; + } if (move === 'wave') { return [{ rotate: `${interpolate(t, [0, 0.18, 0.38, 0.58, 0.78, 1], [0, -7, 6, -4, 2, 0])}deg` }]; } @@ -177,6 +180,7 @@ function gestureTransform(move: OwlGesture | undefined, t: number, size: number) } const GESTURE_TIMING: Record = { + shake: { duration: 420, delay: 80, origin: '50% 50%' }, wave: { duration: 900, delay: 120, origin: '50% 90%' }, cheer: { duration: 1100, delay: 80, origin: '50% 100%' }, hop: { duration: 600, delay: 80, origin: '50% 100%' }, @@ -276,6 +280,14 @@ export function Owl({ pose, size, mirrored, label, style, gesture, breathe = tru export function OwlAvatar({ pose, size, label, blink = false }: { pose: OwlPose; size: number; label?: string; blink?: boolean }) { const { scheme } = useTheme(); const reduce = useReduceMotion(); + const previousPose = useRef(pose); + const change = useSharedValue(1); + useEffect(() => { + if (reduce) change.set(1); + else if (previousPose.current !== pose) change.set(withSequence(withTiming(0.94, { duration: 0 }), withSpring(1, SPRING.settle))); + previousPose.current = pose; + }, [change, pose, reduce]); + const poseChange = useAnimatedStyle(() => ({ transform: [{ scale: reduce ? 1 : change.value }] })); // The website zooms the full pose 1.6x about 52% across and 32% down, onto the head and shoulders. const zoomed = size * 1.6; const offset = { left: -0.52 * (zoomed - size), top: -0.32 * (zoomed - size) }; @@ -293,8 +305,10 @@ export function OwlAvatar({ pose, size, label, blink = false }: { pose: OwlPose; { width: size, height: size, borderRadius: size / 2 }, scheme === 'dark' ? styles.avatarDark : styles.avatarLight, ]}> - - {eyes ? : null} + + + {eyes ? : null} + ); } @@ -320,7 +334,7 @@ export function OwlMark({ size }: { size: number }) { * The owl floating gently while a request runs, where the website shows OwlLoader (owl-bob: * 5 points up and down over 2.4 s), blinking as it waits; still under Reduce Motion. */ -export function OwlLoader({ pose, size, label }: { pose: OwlPose; size: number; label?: string }) { +export function OwlLoader({ pose, size, label, step }: { pose: OwlPose; size: number; label?: string; step?: string }) { const reduce = useReduceMotion(); const bob = useSharedValue(0); useEffect(() => { @@ -341,7 +355,7 @@ export function OwlLoader({ pose, size, label }: { pose: OwlPose; size: number; accessibilityElementsHidden={!label} importantForAccessibility={label ? 'yes' : 'no-hide-descendants'} style={style}> - + ); } diff --git a/apps/mobile/src/components/practice-tools.ts b/apps/mobile/src/components/practice-tools.ts index 7c809a240..5220b322d 100644 --- a/apps/mobile/src/components/practice-tools.ts +++ b/apps/mobile/src/components/practice-tools.ts @@ -1,7 +1,7 @@ import type { IconName } from './icon-paths'; /** - * The Practice tools, in the website's order, then two of its free tools. + * The Practice tools, in the website's order, then its free tools. * All of them are in the app. The free tools name themselves from the * website's `tools` catalog. */ @@ -12,6 +12,7 @@ export const PRACTICE_TOOLS = [ { key: 'sparring', icon: 'swords' }, { key: 'timer', icon: 'timer' }, { key: 'speech-time', icon: 'quote', title: 'calc.region', description: 'mobile.practice.speechTimeDesc' }, + { key: 'flow-sheet', icon: 'file', title: 'flow.region', description: 'mobile.practice.flowSheetDesc' }, { key: 'motions', icon: 'sparkles', title: 'motions.region', description: 'mobile.practice.motionsDesc' }, ] as const satisfies readonly { key: string; icon: IconName; title?: string; description?: string }[]; diff --git a/apps/mobile/src/components/press-scale.tsx b/apps/mobile/src/components/press-scale.tsx index f225339e9..f3bd8a723 100644 --- a/apps/mobile/src/components/press-scale.tsx +++ b/apps/mobile/src/components/press-scale.tsx @@ -1,30 +1,39 @@ -import type { ReactNode } from 'react'; -import { Pressable, type PressableProps, type StyleProp, type ViewStyle } from 'react-native'; -import Animated, { useAnimatedStyle, useSharedValue, withSpring, withTiming } from 'react-native-reanimated'; +import { useEffect, useState, type Ref } from 'react'; +import { Pressable, type PressableProps, type View } from 'react-native'; +import Animated, { cancelAnimation, useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated'; import { SPRING, useReduceMotion } from '@/theme/motion'; -/** - * A pressable card or row that gives under the finger: it eases to 97% as a finger lands and - * springs back on release, on the UI thread. Rows never lift (the website's motion-list-item); - * they only press in. Under Reduce Motion it does not move. - */ -export function PressScale({ children, style, scaleTo = 0.97, onPressIn, onPressOut, ...props }: Omit & { children: ReactNode; style?: StyleProp; scaleTo?: number }) { +const AnimatedPressable = Animated.createAnimatedComponent(Pressable); + +/** A UI-thread spring press, on the hit target itself so flex, refs and accessibility stay intact. */ +export function PressScale({ style, scaleTo = 0.97, onPressIn, onPressOut, disabled, ref, ...props }: PressableProps & { scaleTo?: number; ref?: Ref }) { const reduce = useReduceMotion(); const scale = useSharedValue(1); - const animated = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] })); + const [pressed, setPressed] = useState(false); + useEffect(() => { + if (reduce || disabled) { + cancelAnimation(scale); + scale.set(1); + } + }, [reduce, disabled, scale]); + const animated = useAnimatedStyle(() => ({ transform: [{ scale: reduce ? 1 : scale.value }] })); return ( - { - if (!reduce) scale.set(withTiming(scaleTo, { duration: 90 })); + if (typeof style === 'function') setPressed(true); + if (!reduce) scale.set(withSpring(scaleTo, SPRING.press)); onPressIn?.(event); }} onPressOut={(event) => { - if (!reduce) scale.set(withSpring(1, SPRING.pop)); + if (typeof style === 'function') setPressed(false); + scale.set(reduce ? 1 : withSpring(1, SPRING.pop)); onPressOut?.(event); - }}> - {children} - + }} + /> ); } diff --git a/apps/mobile/src/components/round-controls.tsx b/apps/mobile/src/components/round-controls.tsx index 419607a90..dca25aff1 100644 --- a/apps/mobile/src/components/round-controls.tsx +++ b/apps/mobile/src/components/round-controls.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react'; import { Pressable, StyleSheet, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { PressScale as BouncePressable } from '@/components/press-scale'; import { Icon } from './icon'; import type { IconName } from './icon-paths'; import { Text } from './text'; @@ -12,15 +13,15 @@ import { useTheme } from '@/theme/use-theme'; export function RoundIconButton({ icon, label, onPress, testID }: { icon: IconName; label: string; onPress: () => void; testID?: string }) { const { colors, scheme } = useTheme(); return ( - [styles.round, { backgroundColor: scheme === 'dark' ? 'rgba(255,255,255,0.1)' : colors.segmentTrack }, pressed && styles.pressed]}> + style={[styles.round, { backgroundColor: scheme === 'dark' ? 'rgba(255,255,255,0.1)' : colors.segmentTrack }]}> - + ); } @@ -49,7 +50,7 @@ export function LiveTopBar({ left, right, title, subtitle }: { left: ReactNode; export function SideControl({ icon, label, onPress, disabled, testID }: { icon: IconName; label: string; onPress: () => void; disabled?: boolean; testID?: string }) { const { colors, scheme } = useTheme(); return ( - {label} - + ); } diff --git a/apps/mobile/src/components/round-feedback-observer.tsx b/apps/mobile/src/components/round-feedback-observer.tsx new file mode 100644 index 000000000..e193d0705 --- /dev/null +++ b/apps/mobile/src/components/round-feedback-observer.tsx @@ -0,0 +1,13 @@ +import { useEffect } from 'react'; + +import { roundFeedback } from '@/lib/round-feedback'; +import { useTimerStore } from '@/lib/timer-store'; + +/** Reads timer changes while its screen is closed; never drives or changes a clock. */ +export function RoundFeedbackObserver() { + const { session, loaded } = useTimerStore(); + useEffect(() => { + if (loaded) roundFeedback.observe(session, Date.now()); + }, [loaded, session]); + return null; +} diff --git a/apps/mobile/src/components/segmented-control.tsx b/apps/mobile/src/components/segmented-control.tsx index 66562b966..4911127cf 100644 --- a/apps/mobile/src/components/segmented-control.tsx +++ b/apps/mobile/src/components/segmented-control.tsx @@ -1,5 +1,6 @@ -import { Pressable, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; +import { PressScale as BouncePressable } from '@/components/press-scale'; import { Text } from './text'; import { haptics } from '@/lib/haptics'; @@ -25,7 +26,7 @@ export function SegmentedControl({ {options.map((option) => { const selected = option.value === value; return ( - ({ {option.label} - + ); })} diff --git a/apps/mobile/src/components/sheet.tsx b/apps/mobile/src/components/sheet.tsx index 788e88197..4fc4ecc86 100644 --- a/apps/mobile/src/components/sheet.tsx +++ b/apps/mobile/src/components/sheet.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import { ScrollView, StyleSheet, type StyleProp, type ViewStyle } from 'react-native'; +import { FlatList, ScrollView, StyleSheet, View, type FlatListProps, type StyleProp, type ViewStyle } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { ReservedWidth } from '@/lib/layout'; @@ -40,3 +40,34 @@ export function SheetScrollView({ const styles = StyleSheet.create({ content: { paddingHorizontal: 20, paddingTop: 22, gap: 18, width: '100%', maxWidth: 640, alignSelf: 'center' }, }); + +/** A sheet's direct native scroll view, with virtualized rows instead of nested lists. */ +export function SheetList({ header, contentStyle, gap = 18, ListFooterComponent, ...list }: Omit, 'ListHeaderComponent' | 'contentContainerStyle'> & { + header?: ReactNode; + gap?: number; + contentStyle?: StyleProp; +}) { + const insets = useSafeAreaInsets(); + const { colors } = useTheme(); + const hasRows = (list.data?.length ?? 0) > 0; + return ( + + {header} : null} + ListFooterComponent={ListFooterComponent} + ListFooterComponentStyle={[listStyles.footer, { gap, paddingTop: hasRows ? gap : 0 }, list.ListFooterComponentStyle]} + /> + + ); +} + +const listStyles = StyleSheet.create({ + header: { gap: 18, paddingBottom: 18 }, + footer: { gap: 18, paddingTop: 18 }, +}); diff --git a/apps/mobile/src/components/sidebar-items.ts b/apps/mobile/src/components/sidebar-items.ts index c0e79b6f1..d0cdd7c22 100644 --- a/apps/mobile/src/components/sidebar-items.ts +++ b/apps/mobile/src/components/sidebar-items.ts @@ -29,8 +29,8 @@ const under = (...prefixes: string[]) => (pathname: string) => /** * Every place in the app, grouped the way Adam asked for the iPad sidebar: * Debate, Prepare, Practice, Library and You. A tool's documents count as the - * tool, so a saved case keeps Generate highlighted. The two free tools from - * round 3 close the Practice section, named from the website's `tools` catalog + * tool, so a saved case keeps Generate highlighted. The free tools + * close the Practice section, named from the website's `tools` catalog * as the Practice tab names them. */ export const SIDEBAR_SECTIONS: SidebarSection[] = [ @@ -62,6 +62,7 @@ export const SIDEBAR_SECTIONS: SidebarSection[] = [ { key: 'flowchart', label: 'nav.flowchart', icon: 'branch', target: { kind: 'screen', pathname: '/flowchart' }, matches: under('/flowchart') }, { key: 'timer', label: 'nav.timer', icon: 'timer', target: { kind: 'screen', pathname: '/timer' }, matches: under('/timer', '/timer-format') }, { key: 'speech-time', label: 'tools:calc.region', icon: 'quote', target: { kind: 'screen', pathname: '/speech-time' }, matches: under('/speech-time') }, + { key: 'flow-sheet', label: 'tools:flow.region', icon: 'file', target: { kind: 'screen', pathname: '/flow-sheet' }, matches: under('/flow-sheet') }, { key: 'motions', label: 'tools:motions.region', icon: 'sparkles', target: { kind: 'screen', pathname: '/motions' }, matches: under('/motions') }, ], }, diff --git a/apps/mobile/src/components/source-file-reader.tsx b/apps/mobile/src/components/source-file-reader.tsx index 0b50beab1..8be34feac 100644 --- a/apps/mobile/src/components/source-file-reader.tsx +++ b/apps/mobile/src/components/source-file-reader.tsx @@ -4,6 +4,7 @@ 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 { useLatest } from '../lib/live-value'; import { importSourceFile, SourceFileImportError, @@ -55,13 +56,16 @@ const readers = { * browser. It draws nothing; give it a job and it answers through `onDone`. */ export default function SourceFileReader({ job, readChunk, onDone }: Props) { + const latest = useLatest({ job, readChunk, onDone }); + const jobId = job?.id; useEffect(() => { - if (!job) return; + const { job, readChunk, onDone } = latest.current; + if (!job || job.id !== jobId) return; let stopped = false; let bytes: Promise | null = null; // One slice at a time into one buffer the file's size, so at most one slice is in flight. const read = () => { - bytes ??= (async () => { + if (bytes === null) bytes = (async () => { const out = new Uint8Array(job.size); let offset = 0; for (let index = 0; index < job.chunks; index += 1) { @@ -98,7 +102,6 @@ export default function SourceFileReader({ job, readChunk, onDone }: Props) { stopped = true; }; // A job runs once; its id names it. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [job?.id]); + }, [jobId, latest]); return null; } diff --git a/apps/mobile/src/components/source-row.tsx b/apps/mobile/src/components/source-row.tsx index 49452eb1d..d17e5419f 100644 --- a/apps/mobile/src/components/source-row.tsx +++ b/apps/mobile/src/components/source-row.tsx @@ -5,6 +5,7 @@ import { Pressable, StyleSheet, Switch, View } from 'react-native'; import Animated from 'react-native-reanimated'; import ReanimatedSwipeable, { type SwipeableMethods } from 'react-native-gesture-handler/ReanimatedSwipeable'; +import { PressScale as BouncePressable } from '@/components/press-scale'; import { IconTile, usePressScale } from './card'; import { WEB_SWITCH_THUMB } from './form-rows'; import { Icon } from './icon'; @@ -128,7 +129,7 @@ export function SourceRow({ overshootRight={false} containerStyle={!first && { borderTopWidth: 1, borderTopColor: colors.line }} renderLeftActions={() => ( - {toggleLabel} - + )} renderRightActions={() => ( - void remove()} @@ -149,7 +150,7 @@ export function SourceRow({ {t('common.delete')} - + )}> {/* The switch sits beside the link, not in it, so turning a source on or off never opens it. */} @@ -187,8 +188,8 @@ export function SourceRow({ 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 }) => ( - + {() => ( + @@ -259,7 +260,6 @@ const styles = StyleSheet.create({ 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 }, diff --git a/apps/mobile/src/components/source-view.tsx b/apps/mobile/src/components/source-view.tsx index 9e8b3ce09..1ee47165d 100644 --- a/apps/mobile/src/components/source-view.tsx +++ b/apps/mobile/src/components/source-view.tsx @@ -1,12 +1,13 @@ import { useFocusEffect } from 'expo-router'; import * as WebBrowser from 'expo-web-browser'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; +import { ActivityIndicator, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native'; +import { PressScale as BouncePressable } from '@/components/press-scale'; import { SuccessCheck } from './success-check'; import { MainActionButton, SecondaryButton, TextButton } from './buttons'; -import { Card, IconTile } from './card'; +import { IconTile } from './card'; import { Field } from './field'; import { FolderSelect } from './folder-select'; import { FormGroup, SelectRow, SwitchRow } from './form-rows'; @@ -14,6 +15,10 @@ import { Icon } from './icon'; import { folderLabel, SOURCE_ICONS, sourceMeta } from './source-display'; import { WaitingToSync } from './source-row'; import { Text } from './text'; +import { CardRowFrame, ListScreen } from './list-screen'; +import { SheetList } from './sheet'; +import { runRequest } from '@/lib/run-request'; +import { sourceParagraphs } from '@/lib/source-paragraphs'; import { useToast } from './toast'; import { useAuth } from '@/lib/auth'; @@ -38,40 +43,11 @@ const TYPE_LABELS: Record = { text: 'libraryPage.textSource' type Draft = Pick & { origin: string }; -/** - * 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. - * Edits, the switch and moves go through the offline queue; a note that has - * not reached the server is read from the queue, and its temporary id gives way - * to the row's once it syncs. - */ -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; +/** Keep the source read's focus lifetime separate from edits and queue overlays. */ +function useSourceCopy(userId: string | null, realId: string, local: boolean) { 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 queued = useQueuedWrites(userId); - const realId = currentSourceId(queued, id); - const local = isLocalSourceId(realId); - // What shows: a waiting note from the queue, or the row read with the queued changes on top. - const view: Source | null | undefined = local ? queuedSource(queued, realId) : source && withQueuedWrite(queued, source); - const waiting = view ? waitingSourceIds(queued).has(view.id) : false; - const { offline } = useOfflineStatus(); - const { folders } = useLibraryFolders(view?.debateId ?? null); - // A write anywhere, a queued change syncing or one the server refused reads the row again, // so the view ends on what the server holds, never on an edit that did not save. const [reloads, setReloads] = useState(0); @@ -81,6 +57,7 @@ export function SourceView({ id, onDone, onRemoved }: { id: string; onDone?: () // A note that waits has no row to read yet. useFocusEffect( useCallback(() => { + void reloads; if (!userId || local) return; let active = true; setFailed(false); @@ -98,10 +75,45 @@ export function SourceView({ id, onDone, onRemoved }: { id: string; onDone?: () active = false; copies.release(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- `reloads` only asks for the read again. + // `reloads` only asks for the read again. }, [userId, realId, local, copies, reloads]), ); + return { source, setSource, failed }; +} + +/** + * 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. + * Edits, the switch and moves go through the offline queue; a note that has + * not reached the server is read from the queue, and its temporary id gives way + * to the row's once it syncs. + */ +export function SourceView({ id, onDone, onRemoved, style, contentStyle }: { id: string; onDone?: () => void; onRemoved?: () => void; style?: StyleProp; contentStyle?: StyleProp }) { + const { t, i18n } = useTranslation(); + const { colors } = useTheme(); + const toast = useToast(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const [draft, setDraft] = useState(null); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [saveError, setSaveError] = useState(''); + const actions = useSourceActions(); + const queued = useQueuedWrites(userId); + const realId = currentSourceId(queued, id); + const local = isLocalSourceId(realId); + const { source, setSource, failed } = useSourceCopy(userId, realId, local); + // What shows: a waiting note from the queue, or the row read with the queued changes on top. + const view: Source | null | undefined = local ? queuedSource(queued, realId) : source && withQueuedWrite(queued, source); + const waiting = view ? waitingSourceIds(queued).has(view.id) : false; + const { offline } = useOfflineStatus(); + const { folders } = useLibraryFolders(view?.debateId ?? null); + const url = webUrl(view?.origin ?? null); const toggle = async (isActive: boolean) => { @@ -136,11 +148,12 @@ export function SourceView({ id, onDone, onRemoved }: { id: string; onDone?: () if (draft.content.trim() !== view.content) patch.content = draft.content; if (draft.type !== view.type) patch.type = draft.type; if (draft.origin.trim() !== (view.origin ?? '')) patch.origin = draft.origin; + const debateId = view.debateId; setSaving(true); setSaveError(''); - try { + return await runRequest(async () => { // Offline the edit waits on the phone; the marker below says so instead of the mint confirmation. - const status = await queueUpdateSource(userId, view.debateId, view.id, patch); + const status = await queueUpdateSource(userId, debateId, view.id, patch); // A saved edit shows at once; a queued one shows through the queue until it syncs or is refused. if (status === 'saved') { setSource((current) => current && { ...current, title: draft.title.trim(), content: draft.content.trim(), type: draft.type, origin: draft.origin.trim() || null }); @@ -153,19 +166,19 @@ export function SourceView({ id, onDone, onRemoved }: { id: string; onDone?: () // 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) { + }, async (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 = view?.metadata?.semantic; const words = view ? view.content.trim().split(/\s+/).filter(Boolean).length : 0; - return ( + const header = ( <> @@ -258,17 +271,17 @@ export function SourceView({ id, onDone, onRemoved }: { id: string; onDone?: () ) : null} {url ? ( - void WebBrowser.openBrowserAsync(url)} - style={({ pressed }) => [styles.origin, { backgroundColor: colors.card, borderColor: colors.border }, pressed && styles.pressed]}> + style={[styles.origin, { backgroundColor: colors.card, borderColor: colors.border }]}> {url.replace(/^https?:\/\/(www\.)?/i, '')} - + ) : view.origin ? ( {view.origin} @@ -306,28 +319,51 @@ export function SourceView({ id, onDone, onRemoved }: { id: string; onDone?: () actions.share(view)} /> - - - {view.content} - - - + )} + + ); + const paragraphs = useMemo(() => !failed && view && !draft ? sourceParagraphs(view.content) : [], [failed, view, draft]); + const List = onDone ? SheetList : ListScreen; + return ( + String(index)} + initialNumToRender={6} + maxToRenderPerBatch={6} + windowSize={5} + renderItem={({ item, index }) => ( + + {index < paragraphs.length - 1 && item.endsWith('\n') ? item.slice(0, -1) : item} + + )} + ListFooterComponentStyle={{ paddingTop: 14 }} + ListFooterComponent={!failed && view && !draft ? ( + void remove()} testID="source-delete" - style={({ pressed }) => [styles.delete, { borderColor: colors.border }, pressed && styles.pressed]}> + style={[styles.delete, { borderColor: colors.border }]}> {t('common.delete')} - - - )} - + + ) : null} + /> ); } const styles = StyleSheet.create({ + sheetContent: { paddingTop: 18 }, + paragraph: { paddingHorizontal: 16 }, + paragraphFirst: { paddingTop: 16 }, + paragraphLast: { paddingBottom: 16 }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, headerText: { flex: 1 }, kind: { flexDirection: 'row', alignItems: 'center', gap: 9 }, @@ -338,7 +374,6 @@ const styles = StyleSheet.create({ 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 }, diff --git a/apps/mobile/src/components/sparring-views.tsx b/apps/mobile/src/components/sparring-views.tsx index a87987898..803a1207c 100644 --- a/apps/mobile/src/components/sparring-views.tsx +++ b/apps/mobile/src/components/sparring-views.tsx @@ -14,10 +14,12 @@ import { DocumentHeader } from './document-header'; import { Text } from './text'; import { DeliveryFacts } from './voice'; +import { runRequest } from '@/lib/run-request'; import { useAuth } from '@/lib/auth'; import { backLabel } from '@/lib/back-label'; import { confirmDestructive } from '@/lib/confirm'; import { sharePdf, shareText } from '@/lib/export'; +import { shareSparringDocx } from '@/lib/export-docx'; import { sparringExportFileName, sparringExportHtml } from '@/lib/export-html'; import { deleteSparringRound } from '@/lib/sparring'; import { radius } from '@/theme/colors'; @@ -92,24 +94,33 @@ export function SparringScorecardScreen({ cancel: t('common.cancel'), }); if (!confirmed || !live.current || owner.current !== forUser) return; - try { + return await runRequest(async () => { await deleteSparringRound(forUser, session.id); if (live.current && owner.current === forUser) onDeleted(); - } catch (cause) { + }, async (cause) => { console.warn('Could not delete the round:', cause instanceof Error ? cause.message : cause); if (live.current) setFailed(t('historyPage.sparringDeleteFailed')); - } + }); }; - const exportPdf = async () => { + const exportWord = async () => { try { - await sharePdf(sparringExportHtml(session, t), sparringExportFileName(session), winnerText(t, session.scorecard.winner)); + await shareSparringDocx(session, t, t('common.docx')); } catch (cause) { console.warn('Could not export the round:', cause instanceof Error ? cause.message : cause); if (live.current) setFailed(t('mobile.sparring.exportFailed')); } }; + const exportPdf = async () => { + return await runRequest(async () => { + await sharePdf(sparringExportHtml(session, t), sparringExportFileName(session), winnerText(t, session.scorecard.winner)); + }, async (cause) => { + console.warn('Could not export the round:', cause instanceof Error ? cause.message : cause); + if (live.current) setFailed(t('mobile.sparring.exportFailed')); + }); + }; + return ( void shareText(winnerText(t, session.scorecard.winner), scorecardText(t, session, session.scorecard)).catch(() => {})} actions={[ - // The website's PDF export of the scorecard and transcript, through the share sheet. + // The website's Word and PDF exports of the scorecard and transcript. + { key: 'word', label: t('common.docx'), symbol: 'doc.text', icon: 'file', onPress: () => void exportWord() }, { key: 'pdf', label: t('mobile.sparring.exportPdf'), symbol: 'arrow.down.doc', icon: 'download', onPress: () => void exportPdf() }, { key: 'delete', label: t('common.delete'), symbol: 'trash', icon: 'trash', destructive: true, onPress: () => void remove() }, ]} diff --git a/apps/mobile/src/components/success-check.tsx b/apps/mobile/src/components/success-check.tsx index eee1557d7..de277be57 100644 --- a/apps/mobile/src/components/success-check.tsx +++ b/apps/mobile/src/components/success-check.tsx @@ -10,6 +10,7 @@ import Animated, { } from 'react-native-reanimated'; import Svg, { Path } from 'react-native-svg'; +import { useLatest } from '@/lib/live-value'; import { haptics } from '@/lib/haptics'; import { EASE, useReduceMotion } from '@/theme/motion'; import { useTheme } from '@/theme/use-theme'; @@ -30,7 +31,7 @@ export function SuccessCheck({ size = 22, delay = 0, haptic = true }: { size?: n const pop = useSharedValue(reduce ? 1 : 0); const draw = useSharedValue(reduce ? 1 : 0); - useEffect(() => { + const play = useLatest(() => { if (haptic) haptics.success(); if (reduce) { pop.set(1); @@ -41,8 +42,8 @@ export function SuccessCheck({ size = 22, delay = 0, haptic = true }: { size?: n pop.set(withDelay(delay, withSequence(withTiming(1.14, { duration: 250, easing: EASE.decelerate }), withTiming(1, { duration: 170, easing: EASE.standard })))); draw.set(withDelay(delay + 140, withTiming(1, { duration: 360, easing: EASE.decelerate }))); // One pop per mount: a re-render must not replay it. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }); + useEffect(() => { play.current(); }, [play]); const badge = useAnimatedStyle(() => ({ opacity: Math.min(1, pop.value * 1.6), diff --git a/apps/mobile/src/components/timer-ring.tsx b/apps/mobile/src/components/timer-ring.tsx index f788ac518..09aa97d86 100644 --- a/apps/mobile/src/components/timer-ring.tsx +++ b/apps/mobile/src/components/timer-ring.tsx @@ -1,20 +1,33 @@ -import type { ReactNode } from 'react'; +import { useEffect, type ReactNode } from 'react'; import { StyleSheet, View } from 'react-native'; +import Animated, { Easing, useAnimatedProps, useSharedValue, withTiming } from 'react-native-reanimated'; import Svg, { Circle, Defs, LinearGradient, Stop } from 'react-native-svg'; +import { useReduceMotion } from '@/theme/motion'; import { useTheme } from '@/theme/use-theme'; const STROKE = 9; +const AnimatedCircle = Animated.createAnimatedComponent(Circle); /** * The live round's ring from mock 05: a faint track and a gradient arc for the * time left, drawn from the top. `warning` turns the arc red for the last seconds. */ -export function TimerRing({ size, fraction, warning, children }: { size: number; fraction: number; warning?: boolean; children: ReactNode }) { +export function TimerRing({ size, fraction, warning, children, durationMs, running = false }: { size: number; fraction: number; warning?: boolean; children: ReactNode; durationMs?: number; running?: boolean }) { const { colors, scheme } = useTheme(); const radius = (size - STROKE) / 2; const circumference = 2 * Math.PI * radius; const left = Math.min(1, Math.max(0, fraction)); + const reduce = useReduceMotion(); + const sweep = useSharedValue(left); + useEffect(() => { + // Anchor to the wall-clock reading, then draw the next 250 ms on the UI thread. + sweep.set(left); + if (!reduce && running && durationMs && left > 0) { + sweep.set(withTiming(Math.max(0, left - 250 / durationMs), { duration: 250, easing: Easing.linear })); + } + }, [durationMs, fraction, left, reduce, running, sweep]); + const animatedProps = useAnimatedProps(() => ({ strokeDashoffset: circumference * (1 - sweep.value) })); // Plume, from the button face to its lighter tint: the website has no gradient, so the ring stays in one hue. const [from, to] = scheme === 'dark' ? ['#4CA6FD', '#7CC0FE'] : ['#0A6FF0', '#4CA6FD']; return ( @@ -35,7 +48,8 @@ export function TimerRing({ size, fraction, warning, children }: { size: number; strokeWidth={STROKE} /> {left > 0 ? ( - ) : null} diff --git a/apps/mobile/src/components/toast.tsx b/apps/mobile/src/components/toast.tsx index ed3c19c98..13d853d57 100644 --- a/apps/mobile/src/components/toast.tsx +++ b/apps/mobile/src/components/toast.tsx @@ -1,8 +1,9 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; -import { AccessibilityInfo, Pressable, StyleSheet, View } from 'react-native'; +import { AccessibilityInfo, StyleSheet, View } from 'react-native'; import Animated, { FadeInDown, FadeOutDown } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { PressScale as BouncePressable } from '@/components/press-scale'; import { SuccessCheck } from './success-check'; import { Icon } from './icon'; import { Text } from './text'; @@ -41,6 +42,10 @@ const TAB_BAR = 56; let ids = 0; +function nextId() { + return ++ids; +} + /** * 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 @@ -54,7 +59,7 @@ export function ToastProvider({ children }: { children: ReactNode }) { 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 }); + setToast({ ...next, id: nextId() }); }, []); useEffect(() => { @@ -99,7 +104,7 @@ function ToastView({ toast, onClose }: { toast: ShownToast; onClose: () => void ) : toast.tone === 'error' ? ( ) : null} - + {toast.title} @@ -108,9 +113,9 @@ function ToastView({ toast, onClose }: { toast: ShownToast; onClose: () => void {toast.message} ) : null} - + {toast.action ? ( - { @@ -118,11 +123,11 @@ function ToastView({ toast, onClose }: { toast: ShownToast; onClose: () => void toast.action?.onPress(); }} testID="toast-action" - style={({ pressed }) => [styles.action, pressed && styles.pressed]}> + style={[styles.action]}> {toast.action.label} - + ) : null} @@ -149,5 +154,4 @@ const styles = StyleSheet.create({ 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 fe550a7b5..fcc9e6407 100644 --- a/apps/mobile/src/components/tool-form.tsx +++ b/apps/mobile/src/components/tool-form.tsx @@ -8,12 +8,12 @@ import { OneOffNotice } from './one-off-notice'; import { Text } from './text'; import type { AIErrorKind } from '@/lib/ai-request'; -import { useDailyUsage } from '@/lib/usage'; +import { dailyResetTime, useDailyUsage } from '@/lib/usage'; import { useDebates } from '@/lib/debate-context'; import { haptics } from '@/lib/haptics'; import { radius } from '@/theme/colors'; import { useTheme } from '@/theme/use-theme'; -import { OwlLoader, type OwlPose } from '@/components/owl'; +import { Owl, OwlLoader, type OwlPose } from '@/components/owl'; /** The tool's large title, the line under it, and today's AI uses left, as the website's PageHeader. */ export function ToolTitle({ title, subtitle }: { title: string; subtitle: string }) { @@ -31,13 +31,15 @@ export function ToolTitle({ title, subtitle }: { title: string; subtitle: string } /** "3 of 5 AI uses left today"; nothing while the count is unknown or the plan has no daily uses. */ -export function DailyUsageLine() { - const { t } = useTranslation(); +export function DailyUsageLine({ showReset = false }: { showReset?: boolean }) { + const { t, i18n } = useTranslation(); const usage = useDailyUsage(); if (!usage) return null; return ( - {t('dailyLimit.remaining', { count: usage.remaining, limit: usage.limit })} + {showReset + ? t('dailyLimit.remainingWithReset', { count: usage.remaining, limit: usage.limit, time: dailyResetTime(i18n.language) }) + : t('dailyLimit.remaining', { count: usage.remaining, limit: usage.limit })} ); } @@ -77,6 +79,7 @@ export function ToolError({ title, error, onRetry }: { title: string; error: AIE useEffect(() => haptics.warning(), [error]); return ( + {title} @@ -101,6 +104,7 @@ export function ToolProgress({ elapsed, onCancel, pose = 'thinking', + step, }: { messages: string[]; detail?: string; @@ -108,6 +112,7 @@ export function ToolProgress({ elapsed?: number; onCancel: () => void; pose?: OwlPose; + step?: string; }) { const { t } = useTranslation(); const { colors } = useTheme(); @@ -118,7 +123,7 @@ export function ToolProgress({ }, [messages.length]); return ( - + {messages[index]} diff --git a/apps/mobile/src/components/trial-banner.tsx b/apps/mobile/src/components/trial-banner.tsx index 7c2b46d63..d776d451f 100644 --- a/apps/mobile/src/components/trial-banner.tsx +++ b/apps/mobile/src/components/trial-banner.tsx @@ -3,8 +3,9 @@ import { trialDaysLeft } from '@debatelab/core/trial'; import { useFocusEffect, useRouter } from 'expo-router'; import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Pressable, StyleSheet, View, type ViewStyle } from 'react-native'; +import { StyleSheet, View, type ViewStyle } from 'react-native'; +import { PressScale as BouncePressable } from '@/components/press-scale'; import { Icon } from './icon'; import { Text } from './text'; @@ -78,24 +79,24 @@ export function TrialBanner({ style }: { style?: ViewStyle }) { {message} - router.navigate('/you')} hitSlop={6} - style={({ pressed }) => [styles.action, pressed && styles.pressed]}> + style={[styles.action]}> {t('mobile.trialBanner.seePlan')} - - + [styles.dismiss, pressed && styles.pressed]}> + style={[styles.dismiss]}> - + ); } @@ -106,5 +107,4 @@ const styles = StyleSheet.create({ action: { minHeight: 44, justifyContent: 'center', paddingHorizontal: 6 }, actionText: { fontSize: 15 }, dismiss: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, - pressed: { opacity: 0.6 }, }); diff --git a/apps/mobile/src/components/voice.tsx b/apps/mobile/src/components/voice.tsx index 81572b9eb..9aafa6d99 100644 --- a/apps/mobile/src/components/voice.tsx +++ b/apps/mobile/src/components/voice.tsx @@ -2,8 +2,9 @@ import { formatRoundTime } from '@debatelab/core/sparring/formats'; import { LONG_PAUSE_SECONDS, type SpeechDelivery } from '@debatelab/core/voice/delivery'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Linking, Pressable, StyleSheet, View } from 'react-native'; +import { Linking, StyleSheet, View } from 'react-native'; +import { PressScale as BouncePressable } from '@/components/press-scale'; import { PrimaryButton, SecondaryButton, TextButton } from './buttons'; import { FormGroup, SwitchRow } from './form-rows'; import { Icon } from './icon'; @@ -131,7 +132,7 @@ export function VoiceMicControl({ capture, onStart, onStop, disabled }: { captur return ( - [ + style={[ styles.micButton, listening ? { backgroundColor: colors.destructive, borderColor: colors.destructive } : { backgroundColor: colors.card, borderColor: colors.primaryFill }, - (pressed || disabled) && styles.dimmed, + disabled && styles.dimmed, ]}> {listening ? t('voiceRound.mic.stop') : t('voiceRound.mic.start')} - + {listening ? : null} {listening && capture.interim ? ( @@ -270,7 +271,7 @@ export function DictationButton({ capture, startLabel, stopLabel, inline }: { ca if (inline) { // Inside a composer, beside the paperclip: an icon that turns red while listening. return ( - [styles.dictateInline, pressed && styles.dimmed]}> + style={styles.dictateInline}> - + ); } return ( - [ + style={[ styles.dictate, listening ? { backgroundColor: colors.destructive, borderColor: colors.destructive } : { backgroundColor: colors.card, borderColor: colors.border }, - pressed && styles.dimmed, ]}> - + ); } diff --git a/apps/mobile/src/components/work-row.tsx b/apps/mobile/src/components/work-row.tsx index e80b187ad..ca331e222 100644 --- a/apps/mobile/src/components/work-row.tsx +++ b/apps/mobile/src/components/work-row.tsx @@ -110,8 +110,8 @@ export function WorkRow({ 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 }])}> - {({ pressed }) => ( - + {() => ( + @@ -157,5 +157,4 @@ const styles = StyleSheet.create({ rowText: { flex: 1, gap: 2 }, rowTitle: { fontSize: 16 }, rowMeta: { fontSize: 14 }, - pressed: { opacity: 0.7 }, }); diff --git a/apps/mobile/src/lib/app-intent-links.test.ts b/apps/mobile/src/lib/app-intent-links.test.ts index 1692d4839..ea3e6663c 100644 --- a/apps/mobile/src/lib/app-intent-links.test.ts +++ b/apps/mobile/src/lib/app-intent-links.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { encodeCustomFormat, roundFormatToCustom } from '@debatelab/core/free-tools/custom-format'; +import { FORMATS } from '@debatelab/core/free-tools/formats'; import { continuedRoute, handoffTarget } from './app-intent-links'; @@ -9,6 +11,7 @@ describe('handoffTarget', () => { expect(handoffTarget('/library/')?.webPath).toBe('/app/library'); expect(handoffTarget('/you')?.webPath).toBe('/app/settings'); expect(handoffTarget('/timer')?.webPath).toBe('/tools/debate-timer'); + expect(handoffTarget('/flow-sheet')?.webPath).toBe('/tools/debate-flow-sheet'); expect(handoffTarget('/flowchart/claim')?.webPath).toBe('/app/flowchart'); }); @@ -35,6 +38,16 @@ describe('continuedRoute', () => { expect(continuedRoute('https://debateowl.com/tools/debate-timer')).toBe('/timer'); }); + it('carries custom timer formats through Handoff beyond the app-path length limit', () => { + const format = roundFormatToCustom(FORMATS.wsdc); + format.segments = format.segments.map((segment) => ({ ...segment, label: 'x'.repeat(40) })); + const custom = encodeCustomFormat(format); + const url = `https://debateowl.com/tools/debate-timer?custom=${custom}`; + expect(url.length).toBeGreaterThan(512); + expect(continuedRoute(url)).toBe(`/timer?custom=${custom}`); + expect(continuedRoute(`https://debateowl.com/tools/debate-timer?custom=${'a'.repeat(5000)}`)).toBeNull(); + }); + it('opens History for a result only the other device has', () => { expect(continuedRoute('/case/local-1730000000000-1')).toBe('/history'); expect(continuedRoute('/attack/local-1730000000000-2')).toBe('/history'); diff --git a/apps/mobile/src/lib/app-intent-links.ts b/apps/mobile/src/lib/app-intent-links.ts index 8ae9bf42b..cc0dd581a 100644 --- a/apps/mobile/src/lib/app-intent-links.ts +++ b/apps/mobile/src/lib/app-intent-links.ts @@ -68,6 +68,7 @@ const SCREENS: Record = { '/timer': TIMER, '/timer-format': TIMER, '/speech-time': target('/tools/speech-time-calculator', 'guideHub.links.tools/speech-time-calculator'), + '/flow-sheet': target('/tools/debate-flow-sheet', 'tools:flow.region'), '/motions': target('/tools/debate-motion-generator', 'guideHub.links.tools/debate-motion-generator'), }; @@ -114,8 +115,9 @@ export function handoffTarget(pathname: string): HandoffTarget | null { * offer, or a website link the app already opens, is accepted. */ export function continuedRoute(value: unknown): string | null { - if (typeof value !== 'string' || value.length > MAX_PATH_LENGTH) return null; + if (typeof value !== 'string') return null; if (/^https:/i.test(value)) return appPathForUrl(value); + if (value.length > MAX_PATH_LENGTH) return null; if (!value.startsWith('/') || value.includes('//')) return null; if (!handoffTarget(value)) return null; // A result kept only on the device that made it (open-results.ts's local- keys) is not here; its History is. diff --git a/apps/mobile/src/lib/crossfire.ts b/apps/mobile/src/lib/crossfire.ts index af709295a..17320f91a 100644 --- a/apps/mobile/src/lib/crossfire.ts +++ b/apps/mobile/src/lib/crossfire.ts @@ -19,7 +19,7 @@ import type { DebateContextRequest } from '@debatelab/core/contract/debate-conte import { parseVoicePayload, type VoicePayload } from '@debatelab/core/voice/payload'; import { abortError, AIError, functionOutput, postFunction, PROVIDER, responseExtras, type ResponseExtras } from './ai-request'; -import { debateListScopes } from './debates'; +import { debateListScopes, deletedListScopes } from './debates'; import type { FeatureConfig } from './feature-config'; import { afterWrite, cachedRead, readFailed, type CopyMark } from './offline-cache'; import { sessionSlot } from './session-memory'; @@ -266,3 +266,11 @@ async function loadCrossfireRound(userId: string, id: string): Promise { return cachedRead(userId, 'crossfire', id, () => loadCrossfireRound(userId, id), mark); } + +/** Delete only this account's round and retire every offline copy its lists held. */ +export async function deleteCrossfireRound(userId: string, id: string): Promise { + const { data, error } = await supabase.from('crossfires').delete().eq('user_id', userId).eq('id', id).select('debate_id'); + if (error) throw new Error(error.message); + const deletedFrom = ((data ?? []) as { debate_id: string | null }[]).map((row) => row.debate_id); + afterWrite(userId, [{ group: 'crossfire', key: id }, ...deletedListScopes(deletedFrom, 'crossfire')]); +} diff --git a/apps/mobile/src/lib/dates.ts b/apps/mobile/src/lib/dates.ts index 98ea19c9a..fccba7e7d 100644 --- a/apps/mobile/src/lib/dates.ts +++ b/apps/mobile/src/lib/dates.ts @@ -1,6 +1,10 @@ import { isAppLanguage, toIntlLocale, type AppLanguage } from '@debatelab/core/languages'; -import { formatDistanceToNow, formatRelative, isToday } from 'date-fns'; -import { enGB, es, hu } from 'date-fns/locale'; +import { formatDistanceToNow } from 'date-fns/formatDistanceToNow'; +import { formatRelative } from 'date-fns/formatRelative'; +import { isToday } from 'date-fns/isToday'; +import { enGB } from 'date-fns/locale/en-GB'; +import { es } from 'date-fns/locale/es'; +import { hu } from 'date-fns/locale/hu'; import i18n from './i18n'; diff --git a/apps/mobile/src/lib/debate-arrival.ts b/apps/mobile/src/lib/debate-arrival.ts new file mode 100644 index 000000000..9a95a5b7f --- /dev/null +++ b/apps/mobile/src/lib/debate-arrival.ts @@ -0,0 +1,14 @@ +import { sessionSlot } from './session-memory'; + +/** Only a debate created in this account's current session gets the welcome wave. */ +const pending = sessionSlot(() => null); + +export function markNewDebate(id: string) { + pending.set(id); +} + +export function takeNewDebate(id: string): boolean { + if (pending.get() !== id) return false; + pending.set(null); + return true; +} diff --git a/apps/mobile/src/lib/debate-context.tsx b/apps/mobile/src/lib/debate-context.tsx index 23f8d3e32..bf6e344e9 100644 --- a/apps/mobile/src/lib/debate-context.tsx +++ b/apps/mobile/src/lib/debate-context.tsx @@ -1,6 +1,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { AppState } from 'react-native'; +import { runRequest } from './run-request'; import { publishCurrentDebate } from './debate-widget'; import { clearSpotlight, clearSpotlightDomain } from './spotlight'; import { useAuth } from './auth'; @@ -8,7 +9,7 @@ import { continueDebate, createDebate, deleteDebate, - fetchDebates, + fetchDebatesFrom, sortDebates, switchDebate, updateDebate, @@ -21,6 +22,8 @@ type Status = 'loading' | 'ready' | 'error'; type DebateContextValue = { status: Status; + /** The list came from the server, not from the saved copy painted while it loads; the five-day check waits for it. */ + fresh: boolean; debates: Debate[]; current: Debate | null; /** Session-only, as on the website: tools run without reading from or saving to any debate. */ @@ -47,6 +50,7 @@ export function DebateProvider({ children }: { children: ReactNode }) { const { session } = useAuth(); const userId = session?.user.id ?? null; const [status, setStatus] = useState('loading'); + const [fresh, setFresh] = useState(false); const [debates, setDebates] = useState([]); const [currentId, setCurrentId] = useState(null); const [oneOff, setOneOff] = useState(false); @@ -59,25 +63,40 @@ export function DebateProvider({ children }: { children: ReactNode }) { const refreshes = useRef(0); const owner = useRef(userId); const generation = useRef(0); + // The list on screen is a saved copy (painted while loading, or answered offline), not the server's. + const showingCopy = useRef(false); const refresh = useCallback(async () => { const forUser = owner.current; if (!forUser) return; const writesBefore = writes.current; const request = ++refreshes.current; - try { - const next = await fetchDebates(forUser); - if (owner.current !== forUser || refreshes.current !== request || writes.current !== writesBefore) return; + const gen = generation.current; + const current = () => generation.current === gen && owner.current === forUser && refreshes.current === request && writes.current === writesBefore; + return await runRequest(async () => { + const show = (next: Awaited>['value']) => { + if (!current()) return; + showingCopy.current = true; + setFresh(false); + setDebates((previous) => keepIfSame(previous, next.debates)); + setCurrentId(next.currentId); + setStatus('ready'); + }; + const { value: next, fromCopy } = await fetchDebatesFrom(forUser, show); + if (!current()) return; // Refreshes run on every Debate tab focus; an unchanged list keeps its identity so the tabs do not re-render. setDebates((previous) => keepIfSame(previous, next.debates)); setCurrentId(next.currentId); setStatus('ready'); - } catch (cause) { - if (owner.current !== forUser || refreshes.current !== request) return; + showingCopy.current = fromCopy; + setFresh(!fromCopy); + }, async (cause) => { + // A switch or create since this refresh began already holds newer state. + if (!current()) return; console.warn('Could not load debates:', cause instanceof Error ? cause.message : cause); - // Keep what is on screen; only an empty first load becomes an error. - setStatus((previous) => (previous === 'ready' ? previous : 'error')); - } + // Keep a list the server gave; a saved copy on screen must not hide its refusal. + setStatus((previous) => (previous === 'ready' && !showingCopy.current ? previous : 'error')); + }); }, []); useEffect(() => { @@ -87,6 +106,8 @@ export function DebateProvider({ children }: { children: ReactNode }) { setCurrentId(null); setOneOff(false); setStatus('loading'); + setFresh(false); + showingCopy.current = false; if (!userId) return; void refresh(); // Pick up debates started or switched on the website while the app was in the background. @@ -117,6 +138,7 @@ export function DebateProvider({ children }: { children: ReactNode }) { const current = debates.find((debate) => debate.id === currentId) ?? null; return { status, + fresh, debates, current, oneOff, @@ -190,7 +212,7 @@ export function DebateProvider({ children }: { children: ReactNode }) { setStatus('ready'); }, }; - }, [status, debates, currentId, oneOff, refresh]); + }, [status, fresh, debates, currentId, oneOff, refresh]); return {children}; } diff --git a/apps/mobile/src/lib/debates.ts b/apps/mobile/src/lib/debates.ts index 29261ad56..7fcc57483 100644 --- a/apps/mobile/src/lib/debates.ts +++ b/apps/mobile/src/lib/debates.ts @@ -1,4 +1,4 @@ -import { afterWrite, cachedRead, pickFailure, readAll, readFailed, updateCopy, type CopyMark, type CopyScope } from './offline-cache'; +import { afterWrite, cachedRead, cachedReadFrom, pickFailure, readAll, readFailed, updateCopy, type CopyMark, type CopyScope } from './offline-cache'; import { supabase } from './supabase'; /** @@ -108,8 +108,13 @@ async function setCurrentPointer(userId: string, debateId: string) { */ /** Every debate, newest activity first, and the current one when it is still in the list. Offline, the saved copy. */ -export function fetchDebates(userId: string): Promise { - return cachedRead(userId, 'debates', 'all', () => loadDebates(userId)); +export function fetchDebates(userId: string, onCopy?: (value: DebateList) => void): Promise { + return cachedRead(userId, 'debates', 'all', () => loadDebates(userId), undefined, onCopy); +} + +/** fetchDebates, saying whether the list is the saved copy rather than the server's answer. */ +export function fetchDebatesFrom(userId: string, onCopy?: (value: DebateList) => void): Promise<{ value: DebateList; fromCopy: boolean }> { + return cachedReadFrom(userId, 'debates', 'all', () => loadDebates(userId), undefined, onCopy); } async function loadDebates(userId: string): Promise { @@ -274,8 +279,8 @@ async function countRows(userId: string, table: string, debateId: string, sides? const sum = (counts: number[]) => counts.reduce((total, count) => total + count, 0); /** Sources, saved results per side and Sparring rounds in one debate. One-off work has no debate and never counts. */ -export function fetchDebateCounts(userId: string, debateId: string, mark?: CopyMark): Promise { - return cachedRead(userId, 'debate-lists', `counts:${debateId}`, () => loadDebateCounts(userId, debateId), mark); +export function fetchDebateCounts(userId: string, debateId: string, mark?: CopyMark, onCopy?: (value: DebateCounts) => void): Promise { + return cachedRead(userId, 'debate-lists', `counts:${debateId}`, () => loadDebateCounts(userId, debateId), mark, onCopy); } async function loadDebateCounts(userId: string, debateId: string): Promise { @@ -325,8 +330,8 @@ async function fetchResultRows(userId: string, debateId: string, table: ResultTa const newestFirst = (lists: DebateItem[][], limit: number) => lists.flat().sort((a, b) => b.createdAt - a.createdAt).slice(0, limit); /** The newest saved results and rounds for one side of a debate, like the website's side lists. */ -export function fetchRecentWork(userId: string, debateId: string, side: DebateSide, limit: number, mark?: CopyMark): Promise { - return cachedRead(userId, 'debate-lists', `recent:${debateId}:${side}:${limit}`, () => loadRecentWork(userId, debateId, side, limit), mark); +export function fetchRecentWork(userId: string, debateId: string, side: DebateSide, limit: number, mark?: CopyMark, onCopy?: (value: DebateItem[]) => void): Promise { + return cachedRead(userId, 'debate-lists', `recent:${debateId}:${side}:${limit}`, () => loadRecentWork(userId, debateId, side, limit), mark, onCopy); } async function loadRecentWork(userId: string, debateId: string, side: DebateSide, limit: number): Promise { @@ -404,8 +409,8 @@ function listScopes(debateId: string | undefined, change: 'source' | DebateItemK } /** A debate's saved Prepare results for both sides, newest first, for the Prepare tab. */ -export function fetchPrepareWork(userId: string, debateId: string, limit: number, mark?: CopyMark): Promise { - return cachedRead(userId, 'debate-lists', `prepare:${debateId}:${limit}`, () => loadPrepareWork(userId, debateId, limit), mark); +export function fetchPrepareWork(userId: string, debateId: string, limit: number, mark?: CopyMark, onCopy?: (value: DebateItem[]) => void): Promise { + return cachedRead(userId, 'debate-lists', `prepare:${debateId}:${limit}`, () => loadPrepareWork(userId, debateId, limit), mark, onCopy); } async function loadPrepareWork(userId: string, debateId: string, limit: number): Promise { diff --git a/apps/mobile/src/lib/deep-links.test.ts b/apps/mobile/src/lib/deep-links.test.ts index 44d45034f..e28150f5c 100644 --- a/apps/mobile/src/lib/deep-links.test.ts +++ b/apps/mobile/src/lib/deep-links.test.ts @@ -1,8 +1,26 @@ import { describe, expect, it } from 'vitest'; +import { encodeCustomFormat, roundFormatToCustom, CUSTOM_LIMITS } from '@debatelab/core/free-tools/custom-format'; +import { FORMATS } from '@debatelab/core/free-tools/formats'; + import { appPathForUrl, isShareCode } from './deep-links'; describe('appPathForUrl', () => { + it('imports a validated custom timer format, preserving the first value only', () => { + const custom = encodeCustomFormat(roundFormatToCustom(FORMATS.wsdc)); + expect(appPathForUrl(`https://debateowl.com/tools/debate-timer?custom=${custom}&custom=invalid#top`)).toBe(`/timer?custom=${custom}`); + const longFormat = roundFormatToCustom(FORMATS.wsdc); + longFormat.segments = Array.from({ length: CUSTOM_LIMITS.segments }, () => ({ ...longFormat.segments[0], label: 'x'.repeat(CUSTOM_LIMITS.label) })); + const longCustom = encodeCustomFormat(longFormat); + expect(longCustom.length).toBeGreaterThan(2048); + expect(appPathForUrl(`https://www.debateowl.com/tools/debate-timer/?custom=${longCustom}`)).toBe(`/timer?custom=${longCustom}`); + expect(appPathForUrl('https://debateowl.com/tools/debate-timer?custom=invalid')).toBe('/timer'); + expect(appPathForUrl('https://debateowl.com/tools/debate-timer?custom=%zz')).toBe('/timer'); + expect(appPathForUrl('https://debateowl.com/tools/debate-timer#?custom=invalid')).toBe('/timer'); + expect(appPathForUrl(`https://evil.com/tools/debate-timer?custom=${custom}`)).toBeNull(); + expect(appPathForUrl(`https://debateowl.com/tools/debate-timer?custom=${'a'.repeat(CUSTOM_LIMITS.encoded + 1)}`)).toBeNull(); + }); + it('opens a share link on either host', () => { expect(appPathForUrl('https://debateowl.com/share/Ab3dEf7hIj9kLm0n')).toBe('/share/Ab3dEf7hIj9kLm0n'); expect(appPathForUrl('https://www.debateowl.com/share/Ab3dEf7hIj9kLm0n/')).toBe('/share/Ab3dEf7hIj9kLm0n'); @@ -75,6 +93,7 @@ describe('appPathForUrl', () => { expect(appPathForUrl('https://debateowl.com/tools/debate-timer')).toBe('/timer'); expect(appPathForUrl('https://www.debateowl.com/tools/speech-time-calculator?lang=es')).toBe('/speech-time'); expect(appPathForUrl('https://debateowl.com/tools/debate-motion-generator/')).toBe('/motions'); + expect(appPathForUrl('https://debateowl.com/tools/debate-flow-sheet')).toBe('/flow-sheet'); }); }); diff --git a/apps/mobile/src/lib/deep-links.ts b/apps/mobile/src/lib/deep-links.ts index b5113238e..fc1f7e8b9 100644 --- a/apps/mobile/src/lib/deep-links.ts +++ b/apps/mobile/src/lib/deep-links.ts @@ -1,3 +1,5 @@ +import { CUSTOM_LIMITS, decodeCustomFormat, encodeCustomFormat } from '@debatelab/core/free-tools/custom-format'; + /** * Website links that open in the app (universal links): https://debateowl.com/share/ * and https://debateowl.com/app/, also on www. Anything else, including http, @@ -11,6 +13,7 @@ const HOSTS = new Set(['debateowl.com', 'www.debateowl.com']); /** The share function makes 16 letters and digits; older or future codes get some room, never unbounded. */ const SHARE_CODE = /^[A-Za-z0-9_-]{1,64}$/; const MAX_URL_LENGTH = 2048; +const MAX_CUSTOM_URL_LENGTH = CUSTOM_LIMITS.encoded + 96; /** The website's /app pages (src/App.tsx) and the app route each opens. Missing pages open the Debate tab. */ const APP_PAGES: Record = { @@ -40,6 +43,7 @@ const TOOL_PAGES: Record = { 'debate-timer': '/timer', 'speech-time-calculator': '/speech-time', 'debate-motion-generator': '/motions', + 'debate-flow-sheet': '/flow-sheet', }; /** A share code as the share screen accepts it, from a universal link or the app's own scheme. */ @@ -49,10 +53,10 @@ export function isShareCode(code: unknown): code is string { /** * The app route for a website link, or null when the link is not one of ours. - * Query and fragment (such as ?lang=) are dropped. + * Only a valid custom timer format is carried over; other queries and fragments are dropped. */ export function appPathForUrl(url: unknown): string | null { - if (typeof url !== 'string' || url.length > MAX_URL_LENGTH) return null; + if (typeof url !== 'string' || url.length > MAX_CUSTOM_URL_LENGTH) return null; const match = /^https:\/\/([^/?#]*)([^?#]*)/i.exec(url); if (!match) return null; const [, authority, rawPath] = match; @@ -61,6 +65,7 @@ export function appPathForUrl(url: unknown): string | null { // One trailing slash is the same page; empty segments elsewhere are not a website route. const path = rawPath.length > 1 && rawPath.endsWith('/') ? rawPath.slice(0, -1) : rawPath; + if (url.length > MAX_URL_LENGTH && path.toLowerCase() !== '/tools/debate-timer') return null; const segments = path === '' || path === '/' ? [] : path.slice(1).split('/'); if (segments.some((segment) => segment === '')) return null; @@ -77,6 +82,17 @@ export function appPathForUrl(url: unknown): string | null { } if (first?.toLowerCase() === 'tools' && rest.length === 1) { const tool = rest[0].toLowerCase(); + if (tool === 'debate-timer') { + // Match the website's first custom parameter. Decode only after the host + // and route are trusted; malformed input just opens the ordinary timer. + const query = /\?([^#]*)/.exec(url.split('#', 1)[0])?.[1]; + const custom = query?.split('&').find((part) => part.split('=')[0] === 'custom')?.slice('custom='.length); + try { + const format = decodeCustomFormat(custom ? decodeURIComponent(custom.replace(/\+/g, ' ')) : null); + if (format) return `/timer?custom=${encodeCustomFormat(format)}`; + } catch { /* A malformed percent escape is an invalid format. */ } + if (url.length > MAX_URL_LENGTH) return null; + } return Object.prototype.hasOwnProperty.call(TOOL_PAGES, tool) ? TOOL_PAGES[tool] : null; } return null; diff --git a/apps/mobile/src/lib/export-docx-content.ts b/apps/mobile/src/lib/export-docx-content.ts index 3113582d6..5038b679b 100644 --- a/apps/mobile/src/lib/export-docx-content.ts +++ b/apps/mobile/src/lib/export-docx-content.ts @@ -2,11 +2,13 @@ import type { AttackOutput } from '@debatelab/core/ai/schemas/attack'; import type { BrainstormOutput } from '@debatelab/core/ai/schemas/brainstorm'; import type { FactoidsOutput } from '@debatelab/core/ai/schemas/factoids'; import type { GenerateOutput } from '@debatelab/core/ai/schemas/generate'; +import type { SparringSession } from '@debatelab/core/sparring/session'; import { buildAttackDocx, buildBrainstormDocx, buildCaseDocx, buildFactoidsDocx, + buildSparringDocx, type BuiltDocx, type DocxContext, type DocxModule, @@ -58,3 +60,6 @@ export const brainstormDocx = (result: BrainstormOutput, t: Translate): DocxBuil export const factoidsDocx = (result: FactoidsOutput, t: Translate): DocxBuild => (docx) => buildFactoidsDocx(docx, result, docxContext(t)); + +export const sparringDocx = (session: SparringSession, t: Translate): DocxBuild => + (docx) => buildSparringDocx(docx, session, docxContext(t)); diff --git a/apps/mobile/src/lib/export-docx.ts b/apps/mobile/src/lib/export-docx.ts index 6c4924c01..9b88cbbca 100644 --- a/apps/mobile/src/lib/export-docx.ts +++ b/apps/mobile/src/lib/export-docx.ts @@ -2,10 +2,11 @@ import type { AttackOutput } from '@debatelab/core/ai/schemas/attack'; import type { BrainstormOutput } from '@debatelab/core/ai/schemas/brainstorm'; import type { FactoidsOutput } from '@debatelab/core/ai/schemas/factoids'; import type { GenerateOutput } from '@debatelab/core/ai/schemas/generate'; +import type { SparringSession } from '@debatelab/core/sparring/session'; import { File, Paths } from 'expo-file-system'; import * as Sharing from 'expo-sharing'; -import { attackDocx, brainstormDocx, caseDocx, factoidsDocx, type DocxBuild } from './export-docx-content'; +import { attackDocx, brainstormDocx, caseDocx, factoidsDocx, sparringDocx, type DocxBuild } from './export-docx-content'; type Translate = (key: string, options?: Record) => string; @@ -42,3 +43,7 @@ export function shareBrainstormDocx(result: BrainstormOutput, t: Translate, dial export function shareFactoidsDocx(result: FactoidsOutput, t: Translate, dialogTitle: string): Promise { return shareDocx(factoidsDocx(result, t), dialogTitle); } + +export function shareSparringDocx(session: SparringSession, t: Translate, dialogTitle: string): Promise { + return shareDocx(sparringDocx(session, t), dialogTitle); +} diff --git a/apps/mobile/src/lib/export-docx.web.ts b/apps/mobile/src/lib/export-docx.web.ts index 506eec0f1..b95cf96b3 100644 --- a/apps/mobile/src/lib/export-docx.web.ts +++ b/apps/mobile/src/lib/export-docx.web.ts @@ -2,8 +2,9 @@ import type { AttackOutput } from '@debatelab/core/ai/schemas/attack'; import type { BrainstormOutput } from '@debatelab/core/ai/schemas/brainstorm'; import type { FactoidsOutput } from '@debatelab/core/ai/schemas/factoids'; import type { GenerateOutput } from '@debatelab/core/ai/schemas/generate'; +import type { SparringSession } from '@debatelab/core/sparring/session'; -import { attackDocx, brainstormDocx, caseDocx, factoidsDocx, type DocxBuild } from './export-docx-content'; +import { attackDocx, brainstormDocx, caseDocx, factoidsDocx, sparringDocx, type DocxBuild } from './export-docx-content'; type Translate = (key: string, options?: Record) => string; @@ -38,3 +39,7 @@ export function shareBrainstormDocx(result: BrainstormOutput, t: Translate, dial export function shareFactoidsDocx(result: FactoidsOutput, t: Translate, dialogTitle: string): Promise { return shareDocx(factoidsDocx(result, t), dialogTitle); } + +export function shareSparringDocx(session: SparringSession, t: Translate, dialogTitle: string): Promise { + return shareDocx(sparringDocx(session, t), dialogTitle); +} diff --git a/apps/mobile/src/lib/export-html.ts b/apps/mobile/src/lib/export-html.ts index 2eb122345..bccc5f064 100644 --- a/apps/mobile/src/lib/export-html.ts +++ b/apps/mobile/src/lib/export-html.ts @@ -3,6 +3,7 @@ import type { AttackOutput } from '@debatelab/core/ai/schemas/attack'; import type { BrainstormOutput } from '@debatelab/core/ai/schemas/brainstorm'; import type { FactoidsOutput } from '@debatelab/core/ai/schemas/factoids'; import type { GenerateOutput } from '@debatelab/core/ai/schemas/generate'; +import { questioningSegments, speakingSegments, type RoundFormat } from '@debatelab/core/free-tools/formats'; import { mapRows, type FlowchartDocument, type FlowNodeType } from '@debatelab/core/flowchart'; import { formatRoundTime } from '@debatelab/core/sparring/formats'; import type { SparringSession } from '@debatelab/core/sparring/session'; @@ -310,3 +311,47 @@ export function sparringExportHtml(session: SparringSession, t: Translate): stri }); return page(t('export.sparringTranscript'), `${sparringSide(session.userSide, t)} | ${format} | ${difficulty}`, parts.join(''), t); } + +/** The website's printable flow sheet, sized for expo-print's chosen paper. */ +export function flowSheetExportHtml(options: { + format: RoundFormat; + paper: 'letter' | 'a4'; + orientation: 'landscape' | 'portrait'; + strip: boolean; + motion: string; + round: string; + judge: string; +}, t: Translate): string { + const { format, paper, orientation, strip } = options; + const columns = speakingSegments(format); + const questions = questioningSegments(format); + const size = paper === 'a4' ? { long: 277, short: 190 } : { long: 259, short: 196 }; + const width = orientation === 'landscape' ? size.long : size.short; + const height = orientation === 'landscape' ? size.short : size.long; + const notesHeight = height * 0.18; + const speechHeight = height - 22 - (strip ? notesHeight + 2 : 0); + const field = (key: 'motion' | 'round' | 'judge') => `${escape(t(`flow.${key}Short`))} ${escape(options[key])}`; + const headings = columns.map((segment) => ` + ${escape(segment.short)}${segment.side === 'both' ? '' : escape(format.sideNames[segment.side === 'a' ? 0 : 1])} ${segment.seconds / 60} min`).join(''); + const notes = strip ? `${questions.length + ? questions.map((segment) => ``).join('') + : ``}
${escape(segment.short)}: ${escape(segment.label)}${escape(t('flow.notesBox'))}
` : ''; + return `
${escape(format.name)}${field('round')}${field('judge')}
${field('motion')}
+ ${headings}${columns.map(() => '').join('')}
${notes} +
`; +} diff --git a/apps/mobile/src/lib/export.ts b/apps/mobile/src/lib/export.ts index a87827cc5..d5eba1f05 100644 --- a/apps/mobile/src/lib/export.ts +++ b/apps/mobile/src/lib/export.ts @@ -8,8 +8,8 @@ import { Platform, Share } from 'react-native'; * Export on the phone: expo-print renders the document's HTML to a PDF, * which is renamed to the website's file name and handed to the share sheet. */ -export async function sharePdf(html: string, fileName: string, dialogTitle: string): Promise { - const { uri } = await Print.printToFileAsync({ html }); +export async function sharePdf(html: string, fileName: string, dialogTitle: string, page?: { width: number; height: number }): Promise { + const { uri } = await Print.printToFileAsync({ html, ...page }); const target = new File(Paths.cache, fileName); if (target.exists) target.delete(); await new File(uri).move(target); diff --git a/apps/mobile/src/lib/export.web.ts b/apps/mobile/src/lib/export.web.ts index 4c179b7c5..b20a5e774 100644 --- a/apps/mobile/src/lib/export.web.ts +++ b/apps/mobile/src/lib/export.web.ts @@ -13,7 +13,7 @@ function probe() { return target.__debatelabExport; } -export function sharePdf(html: string, fileName: string): Promise { +export function sharePdf(html: string, fileName: string, _dialogTitle?: string, _page?: { width: number; height: number }): Promise { Object.assign(probe(), { html, fileName }); return Promise.resolve(); } diff --git a/apps/mobile/src/lib/history.ts b/apps/mobile/src/lib/history.ts index 48309c12a..4877bf83a 100644 --- a/apps/mobile/src/lib/history.ts +++ b/apps/mobile/src/lib/history.ts @@ -7,12 +7,12 @@ import { supabase } from './supabase'; /** * The website's History page (src/pages/HistoryPage.tsx) and its unsorted * view (src/components/features/debate/UnsortedWork.tsx), read from the same - * tables. A debate shows six lists; work outside every debate is one list of + * tables. A debate shows seven lists; work outside every debate is one list of * every kind, plus Library sources, that can be added to a debate. Offline, * both answer from their saved copies. */ -export type HistoryTab = 'cases' | 'chats' | 'attacks' | 'brainstorms' | 'factoids' | 'sparring'; +export type HistoryTab = 'cases' | 'chats' | 'attacks' | 'brainstorms' | 'factoids' | 'sparring' | 'crossfire'; /** The website's tabs, in its order. */ export const HISTORY_TABS: readonly { key: HistoryTab; kind: DebateItemKind }[] = [ @@ -22,6 +22,7 @@ export const HISTORY_TABS: readonly { key: HistoryTab; kind: DebateItemKind }[] { key: 'brainstorms', kind: 'brainstorm' }, { key: 'factoids', kind: 'factoids' }, { key: 'sparring', kind: 'sparring' }, + { key: 'crossfire', kind: 'crossfire' }, ]; export interface HistoryItem extends DebateItem { @@ -143,27 +144,28 @@ async function fetchRounds(userId: string, scope: Scope): Promise }); } -/** The six lists of one debate. Offline, the saved copy. */ -export function fetchDebateHistory(userId: string, debateId: string, mark?: CopyMark): Promise { - return cachedRead(userId, 'history', debateId, () => loadDebateHistory(userId, debateId), mark); +/** The seven lists of one debate. Offline, the saved copy. */ +export function fetchDebateHistory(userId: string, debateId: string, mark?: CopyMark, onCopy?: (value: HistoryLists) => void): Promise { + return cachedRead(userId, 'history', debateId, () => loadDebateHistory(userId, debateId), mark, onCopy); } async function loadDebateHistory(userId: string, debateId: string): Promise { const scope = { debateId }; - const [cases, chats, attacks, brainstorms, factoids, sparring] = await readAll([ + const [cases, chats, attacks, brainstorms, factoids, sparring, crossfire] = await readAll([ fetchRows(userId, 'case', scope), fetchChatRows(userId, scope), fetchRows(userId, 'attack', scope), fetchRows(userId, 'brainstorm', scope), fetchRows(userId, 'factoids', scope), fetchRounds(userId, scope), + fetchRows(userId, 'crossfire', scope), ]); - return { cases, chats, attacks, brainstorms, factoids, sparring }; + return { cases, chats, attacks, brainstorms, factoids, sparring, crossfire }; } /** Work of every kind and Library sources that belong to no debate, newest first. Offline, the saved copy. */ -export function fetchUnsortedWork(userId: string, mark?: CopyMark): Promise { - return cachedRead(userId, 'history', 'one-off', () => loadUnsortedWork(userId), mark); +export function fetchUnsortedWork(userId: string, mark?: CopyMark, onCopy?: (value: UnsortedEntry[]) => void): Promise { + return cachedRead(userId, 'history', 'one-off', () => loadUnsortedWork(userId), mark, onCopy); } async function loadUnsortedWork(userId: string): Promise { diff --git a/apps/mobile/src/lib/i18n.test.ts b/apps/mobile/src/lib/i18n.test.ts new file mode 100644 index 000000000..be874ca5f --- /dev/null +++ b/apps/mobile/src/lib/i18n.test.ts @@ -0,0 +1,17 @@ +import { expect, it, vi } from 'vitest'; + +vi.mock('expo-localization', () => ({ getLocales: () => [{ languageCode: 'es' }] })); + +it('resolves the initial language and a newly selected language synchronously, with English fallback', async () => { + const { default: i18n, changeAppLanguage } = await import('./i18n'); + expect(i18n.language).toBe('es'); + expect(i18n.t('common.cancel')).toBe('Cancelar'); + expect(i18n.hasResourceBundle('en', 'translation')).toBe(true); + expect(i18n.hasResourceBundle('hu', 'translation')).toBe(false); + const changed = changeAppLanguage('hu'); + expect(i18n.t('common.cancel')).toBe('Mégse'); + expect(i18n.hasResourceBundle('hu', 'tools')).toBe(true); + await changed; + await changeAppLanguage('en'); + expect(i18n.t('common.cancel')).toBe('Cancel'); +}); diff --git a/apps/mobile/src/lib/i18n.ts b/apps/mobile/src/lib/i18n.ts index 3fe5c8a7a..4e24c5285 100644 --- a/apps/mobile/src/lib/i18n.ts +++ b/apps/mobile/src/lib/i18n.ts @@ -13,17 +13,13 @@ import '@formatjs/intl-listformat/locale-data/hu.js'; import { isAppLanguage, type AppLanguage } from '@debatelab/core/languages'; import { getLocales } from 'expo-localization'; -import i18n from 'i18next'; +import i18n, { type ResourceLanguage } from 'i18next'; import { initReactI18next } from 'react-i18next'; // The website's catalogs. App-only strings live under `mobile.*` in the same files. import en from '../../../../src/locales/en.json'; -import es from '../../../../src/locales/es.json'; -import hu from '../../../../src/locales/hu.json'; // The free tools' catalogs (timer, speech-time calculator, motion generator), as the website's `tools` namespace. import toolsEn from '../../../../src/locales/tools/en.json'; -import toolsEs from '../../../../src/locales/tools/es.json'; -import toolsHu from '../../../../src/locales/tools/hu.json'; /** The device language when DebateLab has it, otherwise English. Signed in, the account's choice wins (see settings.tsx). */ export function deviceLanguage(): AppLanguage { @@ -31,16 +27,40 @@ export function deviceLanguage(): AppLanguage { return isAppLanguage(code) ? code : 'en'; } -// Bundled resources make init synchronous; the returned promise cannot reject. +/** Literal requires let Metro defer parsing the unused catalogs while keeping active keys synchronous. */ +function resourcesFor(language: AppLanguage): ResourceLanguage { + if (language === 'es') return { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- Metro loads a bundled JSON catalog synchronously on demand. + translation: require('../../../../src/locales/es.json'), + // eslint-disable-next-line @typescript-eslint/no-require-imports -- Same lazy tools namespace. + tools: require('../../../../src/locales/tools/es.json'), + }; + if (language === 'hu') return { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- Metro loads a bundled JSON catalog synchronously on demand. + translation: require('../../../../src/locales/hu.json'), + // eslint-disable-next-line @typescript-eslint/no-require-imports -- Same lazy tools namespace. + tools: require('../../../../src/locales/tools/hu.json'), + }; + return { translation: en, tools: toolsEn }; +} + +const initialLanguage = deviceLanguage(); +// Both the active language and its English fallback are ready before the first t(). void i18n.use(initReactI18next).init({ - resources: { - en: { translation: en, tools: toolsEn }, - es: { translation: es, tools: toolsEs }, - hu: { translation: hu, tools: toolsHu }, - }, - lng: deviceLanguage(), + resources: { en: resourcesFor('en'), ...(initialLanguage === 'en' ? {} : { [initialLanguage]: resourcesFor(initialLanguage) }) }, + lng: initialLanguage, fallbackLng: 'en', + initImmediate: false, interpolation: { escapeValue: false }, }); +/** Load both namespaces before selecting a language, including the first offline switch to it. */ +export function changeAppLanguage(language: AppLanguage) { + if (!i18n.hasResourceBundle(language, 'translation')) { + const resources = resourcesFor(language); + for (const [namespace, catalog] of Object.entries(resources)) i18n.addResourceBundle(language, namespace, catalog); + } + return i18n.changeLanguage(language); +} + export default i18n; diff --git a/apps/mobile/src/lib/offline-cache.test.ts b/apps/mobile/src/lib/offline-cache.test.ts index 9bda6a687..4beb0343a 100644 --- a/apps/mobile/src/lib/offline-cache.test.ts +++ b/apps/mobile/src/lib/offline-cache.test.ts @@ -4,6 +4,8 @@ const storage = vi.hoisted(() => new Map()); // A Supabase client whose queries answer with the next queued result, for the modules that write. const supabase = vi.hoisted(() => ({ results: [] as unknown[], from: () => undefined as unknown })); vi.mock('./supabase', () => ({ supabase })); +// Deletion does not send AI requests; keep Expo's transport out of this Node test. +vi.mock('./ai-request', () => ({})); const network = vi.hoisted(() => ({ listener: null as ((state: { isConnected: boolean; isInternetReachable: boolean }) => void) | null })); vi.mock('@react-native-async-storage/async-storage', () => ({ @@ -29,7 +31,7 @@ vi.mock('expo-network', () => ({ let offlineFailure: () => Promise; function deferred() { - let resolve!: (value: T) => void; + let resolve!: (value: T | PromiseLike) => void; const promise = new Promise((done) => { resolve = done; }); @@ -49,7 +51,7 @@ async function load() { vi.resetModules(); const cache = await import('./offline-cache'); const status = await import('./network-status'); - const writers = { ...(await import('./debates')), ...(await import('./sources')), ...(await import('./flowcharts')) }; + const writers = { ...(await import('./debates')), ...(await import('./sources')), ...(await import('./flowcharts')), ...(await import('./crossfire')) }; offlineFailure = () => Promise.reject(cache.readFailed({ message: 'TypeError: Network request failed' }, 0)); return { ...cache, ...status, ...writers }; } @@ -182,6 +184,14 @@ describe('which failures fall back', () => { await expect(cachedRead('user-a', 'sources', 'debate-1', offlineFailure)).resolves.toEqual(['source']); }); + // The five-day question waits for a list the server gave (debate-context.tsx), never a copy. + it('says when the answer is the saved copy rather than the server', async () => { + const { cachedReadFrom, setCacheOwner } = await load(); + setCacheOwner('user-a'); + await expect(cachedReadFrom('user-a', 'debates', 'all', async () => ['debate'])).resolves.toEqual({ value: ['debate'], fromCopy: false }); + await expect(cachedReadFrom('user-a', 'debates', 'all', offlineFailure)).resolves.toEqual({ value: ['debate'], fromCopy: true }); + }); + it('throws a refused session, a policy error or any other server answer instead of serving the copy', async () => { const { cachedRead, readFailed, setCacheOwner } = await load(); setCacheOwner('user-a'); @@ -295,6 +305,37 @@ describe('after a write', () => { await expect(cachedRead('user-a', 'debate-lists', 'recent:debate-2:pro:5', offlineFailure)).resolves.toEqual(['other']); }); + it.each([['debate-1'], [null], []] as (string | null)[][])('deleting Crossfire drops its document and affected lists (%j)', async (...deletedFrom) => { + const cache = await load(); + cache.setCacheOwner('user-a'); + await cache.cachedRead('user-a', 'crossfire', 'round-1', async () => ({ transcript: 'private words' })); + for (const key of ['debate-1', 'debate-2', 'one-off']) { + await cache.cachedRead('user-a', 'history', key, async () => ['round-1']); + await cache.cachedRead('user-a', 'debate-lists', `counts:${key}`, async () => ({ crossfire: 1 })); + await cache.cachedRead('user-a', 'debate-lists', `recent:${key}:pro:5`, async () => ['round-1']); + } + const slow = deferred(); + const running = cache.cachedRead('user-a', 'history', deletedFrom[0] ?? 'one-off', () => slow.promise); + supabase.results.push({ data: deletedFrom.map((debate_id) => ({ debate_id })), error: null, status: 200 }); + await cache.deleteCrossfireRound('user-a', 'round-1'); + slow.resolve(['round-1']); + await running; + await expect(cache.cachedRead('user-a', 'crossfire', 'round-1', offlineFailure)).rejects.toThrow(); + for (const key of ['debate-1', 'debate-2', 'one-off']) { + const affected = deletedFrom.length === 0 || key === (deletedFrom[0] ?? 'one-off'); + const read = cache.cachedRead('user-a', 'history', key, offlineFailure); + if (affected) await expect(read).rejects.toThrow(); + else await expect(read).resolves.toEqual(['round-1']); + if (key !== 'one-off') { + for (const list of [`counts:${key}`, `recent:${key}:pro:5`]) { + const count = cache.cachedRead('user-a', 'debate-lists', list, offlineFailure); + if (affected) await expect(count).rejects.toThrow(); + else await expect(count).resolves.toBeDefined(); + } + } + } + }); + it('deleting a source drops it from the Library and the counts that held it', async () => { const cache = await load(); cache.setCacheOwner('user-a'); @@ -358,3 +399,109 @@ describe('after a write', () => { expect(offline.debates[0].id).toBe('debate-2'); }); }); + +describe('copy-first refreshes', () => { + it('shows the copy while refreshing without marking it offline, then returns the fresh answer', async () => { + const cache = await load(); + cache.setCacheOwner('user-a'); + await cache.cachedRead('user-a', 'sources', 'debate-1', async () => ['saved']); + await cache.copiesWritten(); + const response = deferred(); + const onCopy = vi.fn(); + const refresh = cache.cachedRead('user-a', 'sources', 'debate-1', () => response.promise, undefined, onCopy); + await cache.copiesWritten(); + expect(onCopy).toHaveBeenCalledWith(['saved']); + expect(cache.getOfflineStatus().savedCopies).toBe(false); + response.resolve(['fresh']); + await expect(refresh).resolves.toEqual(['fresh']); + await expect(cache.cachedRead('user-a', 'sources', 'debate-1', offlineFailure)).resolves.toEqual(['fresh']); + }); + + it('keeps the copy and marks it offline only when the refresh gets no answer', async () => { + const cache = await load(); + cache.setCacheOwner('user-a'); + await cache.cachedRead('user-a', 'sources', 'debate-1', async () => ['saved']); + const onCopy = vi.fn(); + const response = deferred(); + const refresh = cache.cachedRead('user-a', 'sources', 'debate-1', () => response.promise, undefined, onCopy); + await cache.copiesWritten(); + expect(onCopy).toHaveBeenCalledWith(['saved']); + response.resolve(offlineFailure()); + await expect(refresh).resolves.toEqual(['saved']); + expect(cache.getOfflineStatus().savedCopies).toBe(true); + }); + + it.each([401, 403, 500])('still throws a server error (%s) after showing a copy', async (status) => { + const cache = await load(); + cache.setCacheOwner('user-a'); + await cache.cachedRead('user-a', 'sources', 'debate-1', async () => ['saved']); + const response = deferred(); + const onCopy = vi.fn(); + const refresh = cache.cachedRead('user-a', 'sources', 'debate-1', () => response.promise, undefined, onCopy); + await cache.copiesWritten(); + expect(onCopy).toHaveBeenCalledWith(['saved']); + response.resolve(Promise.reject(cache.readFailed({ message: 'Refused' }, status))); + await expect(refresh).rejects.toThrow('Refused'); + expect(cache.getOfflineStatus().savedCopies).toBe(false); + }); +}); + +it('does not deliver an early copy after its screen releases the load or its owner changes', async () => { + const cache = await load(); + cache.setCacheOwner('user-a'); + await cache.cachedRead('user-a', 'sources', 'debate-1', async () => ['A secret']); + const holder = new cache.CopyHolder(); + const onCopy = vi.fn(); + const response = deferred(); + const refresh = cache.cachedRead('user-a', 'sources', 'debate-1', () => response.promise, holder.mark(), onCopy); + holder.release(); + cache.setCacheOwner('user-b'); + await cache.copiesWritten(); + expect(onCopy).not.toHaveBeenCalled(); + response.resolve(['A refreshed']); + await refresh; + await cache.copiesWritten(); + expect([...storage.values()].some((raw) => raw.includes('A secret') || raw.includes('A refreshed'))).toBe(false); +}); + +it('does not write an unchanged Library payload again, but keeps same-id edits', async () => { + const cache = await load(); + const asyncStorage = (await import('@react-native-async-storage/async-storage')).default; + const writes = vi.spyOn(asyncStorage, 'multiSet'); + cache.setCacheOwner('user-a'); + const rows = [{ id: 'source-1', content: 'Full text', isActive: true }]; + await cache.cachedRead('user-a', 'sources', 'debate-1', async () => rows); + await cache.copiesWritten(); + writes.mockClear(); + await cache.cachedRead('user-a', 'sources', 'debate-1', async () => rows.map((row) => ({ ...row }))); + await cache.copiesWritten(); + const entry = 'debatelab.cache.v1:user-a:sources:debate-1'; + expect(writes.mock.calls.flatMap(([pairs]) => pairs).some(([key]) => key === entry)).toBe(false); + await cache.cachedRead('user-a', 'sources', 'debate-1', async () => [{ ...rows[0], content: 'Edited elsewhere' }]); + await cache.copiesWritten(); + expect(writes.mock.calls.flatMap(([pairs]) => pairs).some(([key]) => key === entry)).toBe(true); + await expect(cache.cachedRead('user-a', 'sources', 'debate-1', offlineFailure)).resolves.toEqual([{ ...rows[0], content: 'Edited elsewhere' }]); +}); + +it('surfaces a fast server refusal even while disk is still reading', async () => { + const cache = await load(); + cache.setCacheOwner('user-a'); + await cache.cachedRead('user-a', 'sources', 'debate-1', async () => ['saved']); + await cache.copiesWritten(); + const asyncStorage = (await import('@react-native-async-storage/async-storage')).default; + const disk = deferred(); + const spy = vi.spyOn(asyncStorage, 'getItem').mockImplementationOnce(() => disk.promise); + const refusal = cache.readFailed({ message: 'Expired session' }, 401); + const onCopy = vi.fn(); + let failure: unknown; + const refresh = cache.cachedRead('user-a', 'sources', 'debate-1', () => Promise.reject(refusal), undefined, onCopy).catch((cause: unknown) => { failure = cause; }); + try { + await new Promise((done) => setTimeout(done, 0)); + expect(failure).toBe(refusal); + expect(onCopy).not.toHaveBeenCalled(); + } finally { + disk.resolve(null); + await refresh; + spy.mockRestore(); + } +}); diff --git a/apps/mobile/src/lib/offline-cache.ts b/apps/mobile/src/lib/offline-cache.ts index 3932b14ab..a95c3ddf9 100644 --- a/apps/mobile/src/lib/offline-cache.ts +++ b/apps/mobile/src/lib/offline-cache.ts @@ -5,9 +5,9 @@ import { createSerialQueue } from './serial-queue'; /** * Saved copies of what the app reads, so debates, sources, saved results and - * past rounds stay readable without a connection. A read goes to the network - * first and keeps a copy of the answer; when no answer comes back from the - * server, or the device is known to be offline, the copy answers instead. A + * past rounds stay readable without a connection. A read can show its saved + * copy immediately through `onCopy` while refreshing through the network. + * Only a failed refresh or known offline device marks that copy offline. A * server that answers with an error (a refused session, a policy, a bad * request) is not a connection problem, so that error is thrown as it is. * Nothing is written offline: saving waits for the network, and every write @@ -234,7 +234,12 @@ async function storeEntry(userId: string, group: CacheGroup, storageKey: string, .slice(LIMITS[group]) .map(([droppedKey]) => droppedKey); for (const droppedKey of dropped) delete entries[droppedKey]; - await AsyncStorage.multiSet([[storageKey, raw], [indexKey(userId), JSON.stringify(entries)]]); + // Sources have no updated_at column. Compare the exact stored value rather + // than an ids-only signature that would miss edits from another client. + const unchanged = group === 'sources' && await AsyncStorage.getItem(storageKey) === raw; + await AsyncStorage.multiSet(unchanged + ? [[indexKey(userId), JSON.stringify(entries)]] + : [[storageKey, raw], [indexKey(userId), JSON.stringify(entries)]]); if (dropped.length > 0) await AsyncStorage.multiRemove(dropped); } @@ -312,35 +317,64 @@ function noteShown(storageKey: string, group: CacheGroup, fromCopy: boolean, mar * Reads through the network and keeps a copy for this account. Offline, or * when no answer came back from the server, answers from the copy; with no * copy, or when the server answered with an error, fails as the read did. + * `onCopy` paints an existing copy while the refresh runs, without an offline + * notice. It does not replace awaiting this promise or handling its errors. * A screen passes its load's `mark` so a copy it shows stops counting once it moves on. */ -export async function cachedRead(userId: string, group: CacheGroup, key: string, load: () => Promise, mark?: CopyMark): Promise { +export async function cachedRead(userId: string, group: CacheGroup, key: string, load: () => Promise, mark?: CopyMark, onCopy?: (value: T) => void): Promise { + return (await cachedReadFrom(userId, group, key, load, mark, onCopy)).value; +} + +/** cachedRead, also saying whether the answer is the saved copy (offline or unreachable) rather than the server's. */ +export async function cachedReadFrom(userId: string, group: CacheGroup, key: string, load: () => Promise, mark?: CopyMark, onCopy?: (value: T) => void): Promise<{ value: T; fromCopy: boolean }> { const ticket = ++tickets; const gen = generation; const storageKey = entryKey(userId, group, key); running.add(ticket); if (isKnownOffline()) { const copy = await readCopy(userId, group, key).catch(() => undefined); - if (copy) { + if (copy && gen === generation && userId === owner) { settle(ticket); if (gen === generation) noteShown(storageKey, group, true, mark); - return copy.value; + return { value: copy.value, fromCopy: true }; } } let value: T; try { - value = await load(); + if (onCopy) { + // Start both reads together and handle refusals immediately, even while + // storage is pending. The returned promise still reports every server error. + const copyRead = readCopy(userId, group, key).catch(() => undefined); + let answered = false; + const response = load().then( + (fresh) => { answered = true; return { ok: true as const, value: fresh }; }, + (cause: unknown) => { answered = true; return { ok: false as const, cause }; }, + ); + const painted = copyRead.then((copy) => { + if (copy && !answered && gen === generation && userId === owner + && ticket >= (newest.get(storageKey) ?? 0) + && !floors.some((floor) => floor.ticket > ticket && inScope(userId, floor.scope, group, storageKey)) + && (!mark || mark.load === mark.holder.loads)) onCopy(copy.value); + }); + await Promise.race([painted, response]); + const fresh = await response; + if (!fresh.ok) throw fresh.cause; + value = fresh.value; + } else value = await load(); } catch (cause) { settle(ticket); - if (!isUnreachable(cause)) throw cause; + if (!isUnreachable(cause)) { + if (gen === generation) noteShown(storageKey, group, false, mark); + throw cause; + } const copy = await readCopy(userId, group, key).catch(() => undefined); - if (!copy) throw cause; + if (!copy || gen !== generation || userId !== owner) throw cause; if (gen === generation) noteShown(storageKey, group, true, mark); - return copy.value; + return { value: copy.value, fromCopy: true }; } if (gen === generation) noteShown(storageKey, group, false, mark); writeCopy(userId, group, key, value, ticket, gen).catch(warn('Could not keep a saved copy')); - return value; + return { value, fromCopy: false }; } /** diff --git a/apps/mobile/src/lib/push.ts b/apps/mobile/src/lib/push.ts index ce3fe9f22..7dd4fdb2b 100644 --- a/apps/mobile/src/lib/push.ts +++ b/apps/mobile/src/lib/push.ts @@ -29,8 +29,10 @@ import { supabase } from './supabase'; * releases it again before doing anything else with push. * * - unavailable: this build cannot get an Expo push token. Expo Go on Android - * has no remote push since SDK 53, and every build needs the app's EAS - * project ID (`extra.eas.projectId` in app.config.ts), which is not set yet. + * has no remote push since SDK 53, every build needs the app's EAS project + * ID (`extra.eas.projectId` in app.config.ts), and a native build not signed + * by DebateOwl's own Apple account has no push entitlement + * (`extra.remotePush` false), so iOS would refuse it a token. * - blocked: iOS Settings turned notifications off for the app. * - off / on: this account on this phone. */ @@ -70,6 +72,7 @@ function projectId(): string | undefined { } function available(): boolean { + if (Constants.expoConfig?.extra?.remotePush === false) return false; return projectId() !== undefined && !(Platform.OS === 'android' && isRunningInExpoGo()); } diff --git a/apps/mobile/src/lib/round-feedback.test.ts b/apps/mobile/src/lib/round-feedback.test.ts new file mode 100644 index 000000000..27bc59413 --- /dev/null +++ b/apps/mobile/src/lib/round-feedback.test.ts @@ -0,0 +1,55 @@ +import { goToSegment, resetSpeech, toggleSpeech } from '@debatelab/core/free-tools/round-timer'; +import { describe, expect, it } from 'vitest'; + +import { RoundFeedback } from './round-feedback'; +import { formatFor, initialSession, type TimerSession } from './timer'; + +const start = 1_000_000; +const fresh = () => initialSession({ id: 'custom', custom: { + name: 'Club', prepSeconds: 0, + segments: [ + { label: 'Opening', seconds: 60, kind: 'speech', side: 'a', poi: false }, + { label: 'Reply', seconds: 60, kind: 'speech', side: 'b', poi: false }, + ], +} }); +const run = (session: TimerSession, now: number) => ({ ...session, round: toggleSpeech(session.round, now) }); + +function lastSpeech(ledger: RoundFeedback) { + let session = run(fresh(), start); + ledger.observe(session, start); + // The root observer sees the change even while the timer screen is closed. + session = { ...session, round: goToSegment(session.round, formatFor(session.choice), 1, start + 70_000) }; + ledger.observe(session, start + 70_000); + session = run(session, start + 75_000); + ledger.observe(session, start + 75_000); + return session; +} + +describe('round animation feedback', () => { + it('counts actual timed speeches including overtime, never prep or idle gaps, and celebrates once', () => { + const ledger = new RoundFeedback(); + const session = lastSpeech(ledger); + expect(ledger.takeCompletion(session, start + 134_999)).toBeNull(); + expect(ledger.takeCompletion(session, start + 135_000)).toEqual({ speeches: 2, seconds: 130 }); + expect(ledger.takeCompletion(session, start + 140_000)).toBeNull(); + const paused = run(session, start + 140_000); + ledger.observe(paused, start + 140_000); + expect(ledger.takeCompletion(run(paused, start + 150_000), start + 151_000)).toBeNull(); + }); + + it('rearms after reset and forgets the old round on a format change', () => { + const ledger = new RoundFeedback(); + let session = lastSpeech(ledger); + expect(ledger.takeCompletion(session, start + 135_000)).not.toBeNull(); + session = { ...session, round: resetSpeech(session.round) }; + ledger.observe(session, start + 140_000); + session = run(session, start + 145_000); + expect(ledger.takeCompletion(session, start + 205_000)).toEqual({ speeches: 2, seconds: 130 }); + session = run(fresh(), start + 210_000); + ledger.observe(session, start + 210_000); + session = { ...session, round: goToSegment(session.round, formatFor(session.choice), 1, start + 210_000) }; + ledger.observe(session, start + 210_000); + session = run(session, start + 211_000); + expect(ledger.takeCompletion(session, start + 271_000)).toEqual({ speeches: 1, seconds: 60 }); + }); +}); diff --git a/apps/mobile/src/lib/round-feedback.ts b/apps/mobile/src/lib/round-feedback.ts new file mode 100644 index 000000000..3b3eebc87 --- /dev/null +++ b/apps/mobile/src/lib/round-feedback.ts @@ -0,0 +1,46 @@ +import { elapsedMs } from '@debatelab/core/free-tools/round-timer'; + +import { formatFor, type TimerSession } from './timer'; + +export type RoundSummary = { speeches: number; seconds: number }; + +/** Decorative, app-session bookkeeping; the timer remains the sole owner of clocks. */ +export class RoundFeedback { + private previous: TimerSession | null = null; + private times = new Map(); + private celebrated = false; + + observe(session: TimerSession, now: number) { + const before = this.previous; + const { round } = session; + const changedFormat = before && before.choice !== session.choice; + const restarted = before && before.round.segmentIndex > 0 && round.segmentIndex === 0; + if (changedFormat || restarted) { + this.times.clear(); + this.celebrated = false; + } else if (before && before.round.segmentIndex !== round.segmentIndex) { + // The old speech's timestamp includes time spent with the screen closed. + this.times.set(before.round.segmentIndex, elapsedMs(before.round.speech, now)); + } + if (before && before.round.segmentIndex === round.segmentIndex && round.speech.elapsedMs === 0 && round.speech.startedAt === null && elapsedMs(before.round.speech, now) > 0) { + this.times.delete(round.segmentIndex); + this.celebrated = false; + } + this.times.set(round.segmentIndex, elapsedMs(round.speech, now)); + this.previous = session; + } + + /** One arrival for this round, even after pause/resume, overtime or reopening the screen. */ + takeCompletion(session: TimerSession, now: number): RoundSummary | null { + this.observe(session, now); + const { round } = session; + const format = formatFor(session.choice); + const last = format.segments.length - 1; + if (this.celebrated || round.segmentIndex !== last || elapsedMs(round.speech, now) < format.segments[last].seconds * 1000) return null; + this.celebrated = true; + const timed = [...this.times.values()].filter((ms) => ms > 0); + return { speeches: timed.length, seconds: Math.round(timed.reduce((sum, ms) => sum + ms, 0) / 1000) }; + } +} + +export const roundFeedback = new RoundFeedback(); diff --git a/apps/mobile/src/lib/run-request.test.ts b/apps/mobile/src/lib/run-request.test.ts new file mode 100644 index 000000000..3b4e2d77c --- /dev/null +++ b/apps/mobile/src/lib/run-request.test.ts @@ -0,0 +1,16 @@ +import { expect, it, vi } from 'vitest'; + +import { runRequest } from './run-request'; + +it('settles a request after its result or error handler, including an early return', async () => { + const events: string[] = []; + const cleanup = () => { events.push('settled'); }; + expect(await runRequest(async () => { events.push('work'); return 42; }, undefined, cleanup)).toBe(42); + expect(events).toEqual(['work', 'settled']); + events.length = 0; + expect(await runRequest(async () => { throw new Error('refused'); }, async () => { events.push('failed'); return false; }, cleanup)).toBe(false); + expect(events).toEqual(['failed', 'settled']); + const rejected = vi.fn(); + await expect(runRequest(async () => { throw new Error('refused'); }, undefined, rejected)).rejects.toThrow('refused'); + expect(rejected).toHaveBeenCalledOnce(); +}); diff --git a/apps/mobile/src/lib/run-request.ts b/apps/mobile/src/lib/run-request.ts new file mode 100644 index 000000000..2285a1afd --- /dev/null +++ b/apps/mobile/src/lib/run-request.ts @@ -0,0 +1,11 @@ +/** Keep request error handling and cleanup outside render functions for React Compiler. */ +export async function runRequest(work: () => Promise, failed?: (cause: unknown) => E | Promise, settled?: () => void): Promise { + try { + return await work(); + } catch (cause) { + if (!failed) throw cause; + return await failed(cause); + } finally { + settled?.(); + } +} diff --git a/apps/mobile/src/lib/settings-cache.test.ts b/apps/mobile/src/lib/settings-cache.test.ts new file mode 100644 index 000000000..5fdd35598 --- /dev/null +++ b/apps/mobile/src/lib/settings-cache.test.ts @@ -0,0 +1,30 @@ +import { beforeEach, expect, it, vi } from 'vitest'; + +const storage = vi.hoisted(() => new Map()); +vi.mock('@react-native-async-storage/async-storage', () => ({ default: { + getItem: vi.fn(async (key: string) => storage.get(key) ?? null), + multiSet: async (pairs: [string, string][]) => { for (const [key, value] of pairs) storage.set(key, value); }, +} })); + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { keepSettingsCopy, preloadSettingsCopy, readSettingsCopy } from './settings-cache'; + +beforeEach(() => { storage.clear(); vi.clearAllMocks(); }); + +it('starts the disk read before auth resolves and reuses it only for its confirmed account', async () => { + await keepSettingsCopy('a', 'A settings'); + const preloaded = preloadSettingsCopy(); + expect(AsyncStorage.getItem).toHaveBeenCalledWith('debatelab.settings.owner'); + await preloaded; + vi.mocked(AsyncStorage.getItem).mockClear(); + expect(await readSettingsCopy('a', preloaded)).toBe('A settings'); + expect(AsyncStorage.getItem).not.toHaveBeenCalled(); +}); + +it('never returns the last account settings to another account', async () => { + await keepSettingsCopy('a', 'A settings'); + storage.set('debatelab.settings.b', 'B settings'); + const preloaded = preloadSettingsCopy(); + expect(await readSettingsCopy('b', preloaded)).toBe('B settings'); + expect(await readSettingsCopy('c', preloaded)).toBeNull(); +}); diff --git a/apps/mobile/src/lib/settings-cache.ts b/apps/mobile/src/lib/settings-cache.ts new file mode 100644 index 000000000..6e441c125 --- /dev/null +++ b/apps/mobile/src/lib/settings-cache.ts @@ -0,0 +1,24 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const LAST_OWNER = 'debatelab.settings.owner'; +const cacheKey = (userId: string) => `debatelab.settings.${userId}`; +type SettingsCopy = { userId: string; raw: string | null }; + +/** Start alongside auth restore, but do not apply anything until auth confirms the owner. */ +export async function preloadSettingsCopy(): Promise { + try { + const userId = await AsyncStorage.getItem(LAST_OWNER); + return userId ? { userId, raw: await AsyncStorage.getItem(cacheKey(userId)) } : null; + } catch { + return null; + } +} + +export async function readSettingsCopy(userId: string, preloaded?: Promise | null): Promise { + const copy = preloaded ? await preloaded : null; + return copy?.userId === userId ? copy.raw : AsyncStorage.getItem(cacheKey(userId)); +} + +export function keepSettingsCopy(userId: string, raw: string): Promise { + return AsyncStorage.multiSet([[cacheKey(userId), raw], [LAST_OWNER, userId]]); +} diff --git a/apps/mobile/src/lib/settings.tsx b/apps/mobile/src/lib/settings.tsx index 71135c94e..4f6cb4aaa 100644 --- a/apps/mobile/src/lib/settings.tsx +++ b/apps/mobile/src/lib/settings.tsx @@ -2,9 +2,9 @@ import { getDefaultFeatureConfigs, type FeatureTab } from '@debatelab/core/ai/fe import { readProModelAccess } from '@debatelab/core/supabase/billing'; import { DEFAULT_MODEL, DEFAULT_PRO_MODEL } from '@debatelab/core/contract/model-catalog'; import { isAppLanguage, type AppLanguage } from '@debatelab/core/languages'; -import AsyncStorage from '@react-native-async-storage/async-storage'; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { runRequest } from './run-request'; import { useAuth } from './auth'; import { canUseProModels, @@ -15,7 +15,8 @@ import { type FeatureConfigChange, } from './feature-config'; import { readFeatureConfigAfterWrites } from './feature-config-read'; -import i18n, { deviceLanguage } from './i18n'; +import i18n, { deviceLanguage, changeAppLanguage } from './i18n'; +import { keepSettingsCopy, preloadSettingsCopy, readSettingsCopy } from './settings-cache'; import { createSerialQueue } from './serial-queue'; import { billingQuery } from './billing-query'; import { supabase } from './supabase'; @@ -75,8 +76,6 @@ function parseSettings(value: unknown): Settings | null { }; } -const cacheKey = (userId: string) => `debatelab.settings.${userId}`; - /** How long a tool waits for earlier settings writes before reading its model anyway, with the choice still being saved on top. */ const READ_WAIT_MS = 5000; @@ -160,7 +159,10 @@ async function writeFeatureConfig(userId: string, feature: FeatureTab, change: F export function SettingsProvider({ children }: { children: ReactNode }) { const { session, loading } = useAuth(); const userId = session?.user.id ?? null; - const [settings, setSettings] = useState(SIGNED_OUT); + const [initialCopy] = useState(preloadSettingsCopy); + const startupCopy = useRef(initialCopy); + const [snapshot, setSnapshot] = useState<{ owner: string | null; value: Settings }>({ owner: null, value: SIGNED_OUT }); + const settings = snapshot.owner === userId ? snapshot.value : SIGNED_OUT; const [ready, setReady] = useState(false); const owner = useRef(userId); // Bumped on every owner change, including signing out and back in as the @@ -190,11 +192,11 @@ export function SettingsProvider({ children }: { children: ReactNode }) { const apply = useCallback((next: Settings, forUser: string | null, gen: number) => { if (generation.current !== gen) return; latest.current = next; - setSettings(next); - if (i18n.language !== next.language) void i18n.changeLanguage(next.language); + setSnapshot({ owner: forUser, value: next }); + if (i18n.language !== next.language) void changeAppLanguage(next.language); applyColorScheme(next.theme); if (forUser) { - AsyncStorage.setItem(cacheKey(forUser), JSON.stringify(next)).catch((cause: unknown) => + keepSettingsCopy(forUser, JSON.stringify(next)).catch((cause: unknown) => console.warn('Could not keep settings on this device:', cause), ); } @@ -202,6 +204,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) { useEffect(() => { if (loading) return; + const preloaded = startupCopy.current; + startupCopy.current = null; owner.current = userId; const gen = ++generation.current; configsSaved.current = null; @@ -212,6 +216,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) { setReady(true); return; } + apply({ ...SIGNED_OUT, language: deviceLanguage() }, null, gen); confirmed.current = { ...latest.current }; let cancelled = false; const started = { ...versions.current }; @@ -227,12 +232,12 @@ export function SettingsProvider({ children }: { children: ReactNode }) { return next; }; void (async () => { - try { - const cached = parseSettings(JSON.parse((await AsyncStorage.getItem(cacheKey(userId))) ?? 'null')); + await runRequest(async () => { + const cached = parseSettings(JSON.parse((await readSettingsCopy(userId, preloaded)) ?? 'null')); if (!cancelled && cached) apply(merge(cached), userId, gen); - } catch (cause) { + }, async (cause) => { console.warn('Could not read settings from this device:', cause); - } + }); if (!cancelled) setReady(true); const { data, error } = await supabase .from('user_settings') @@ -251,7 +256,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) { return () => { cancelled = true; }; - }, [loading, userId, apply]); + }, [loading, userId, apply, initialCopy]); const save = useCallback(async (key: K, value: Settings[K]) => { const forUser = owner.current; @@ -259,7 +264,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) { const gen = generation.current; const version = ++versions.current[key]; apply({ ...latest.current, [key]: value }, forUser, gen); - try { + return await runRequest(async () => { await writes(async () => { // A save made before an owner change is dropped, not written after it, // even when the same account has signed in again since. @@ -269,11 +274,11 @@ export function SettingsProvider({ children }: { children: ReactNode }) { configsSaved.current = true; Object.assign(confirmed.current, { [key]: value }); }); - } catch (cause) { + }, async (cause) => { if (generation.current !== gen) return; if (versions.current[key] === version) apply({ ...latest.current, [key]: confirmed.current[key] }, forUser, gen); throw cause; - } + }); }, [apply, writes]); // The picker shows its own optimistic value and rolls it back on failure (model-picker.tsx). @@ -288,18 +293,18 @@ export function SettingsProvider({ children }: { children: ReactNode }) { pendingConfigs.current.set(key, { feature, change: { [field]: change[field] }, seq }); return key; }); - try { + return await runRequest(async () => { await writes(async () => { if (generation.current !== gen) return; await writeFeatureConfig(forUser, feature, change); if (generation.current !== gen) return; configsSaved.current = true; }); - } finally { + }, undefined, () => { if (generation.current === gen) { for (const key of keys) if (pendingConfigs.current.get(key)?.seq === seq) pendingConfigs.current.delete(key); } - } + }); }, [writes]); const readFeatureConfig = useCallback((forUser: string, feature: FeatureTab) => { diff --git a/apps/mobile/src/lib/sign-in-method.ts b/apps/mobile/src/lib/sign-in-method.ts new file mode 100644 index 000000000..8bba426cb --- /dev/null +++ b/apps/mobile/src/lib/sign-in-method.ts @@ -0,0 +1,24 @@ +import type { User } from '@supabase/supabase-js'; + +/** The website's account-level method, including Apple's linked identities on iOS. */ +export function signInMethodKey(user: User | null): string | null { + if (!user) return null; + const meta = user.app_metadata; + if (typeof meta?.demo_access_code_id === 'string') return 'settings.signInDemo'; + const providers = new Set([ + ...(Array.isArray(meta?.providers) ? meta.providers : [meta?.provider]), + ...(user.identities ?? []).map((identity) => identity.provider), + ]); + const google = providers.has('google'); + const email = providers.has('email'); + if (providers.has('apple')) { + if (google && email) return 'mobile.you.signInAppleGoogleOrEmail'; + if (google) return 'mobile.you.signInAppleOrGoogle'; + if (email) return 'mobile.you.signInAppleOrEmail'; + return 'mobile.you.signInApple'; + } + if (google && email) return 'settings.signInGoogleOrEmail'; + if (google) return 'settings.signInGoogle'; + if (email) return 'settings.signInEmail'; + return null; +} diff --git a/apps/mobile/src/lib/source-file.tsx b/apps/mobile/src/lib/source-file.tsx index ce10433fd..a2fb35a0b 100644 --- a/apps/mobile/src/lib/source-file.tsx +++ b/apps/mobile/src/lib/source-file.tsx @@ -28,12 +28,16 @@ export const SOURCE_FILE_PICKER_TYPES = [ let jobs = 0; +function nextId() { + return ++jobs; +} + // 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}`; + const id = `picked-${nextId()}`; picked.clear(); picked.set(id, asset); return id; @@ -84,7 +88,7 @@ export function useSourceFileReader(): { reader: ReactNode; read: (asset: Docume } // 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}`; + const id = `file-${nextId()}`; return new Promise((resolve) => { const timer = setTimeout(() => finish(id, { ok: false, code: 'timeout', message: 'The file reader did not answer' }), READ_TIMEOUT_MS); current.current = { id, asset, size, resolve, timer }; diff --git a/apps/mobile/src/lib/source-paragraphs.test.ts b/apps/mobile/src/lib/source-paragraphs.test.ts new file mode 100644 index 000000000..ee087b8dd --- /dev/null +++ b/apps/mobile/src/lib/source-paragraphs.test.ts @@ -0,0 +1,12 @@ +import { expect, it } from 'vitest'; + +import { sourceParagraphs } from './source-paragraphs'; + +it('keeps every character and bounds text layout for a maximum-size import without paragraph breaks', () => { + for (const content of ['', 'A short source', 'word '.repeat(24_000), 'x'.repeat(3999) + '🦉' + 'x'.repeat(116_000), 'First\n\nSecond\nline\n\nLast']) { + const paragraphs = sourceParagraphs(content); + expect(paragraphs.join('')).toBe(content); + expect(paragraphs.every((part) => part.length <= 4000)).toBe(true); + expect(paragraphs.every((part) => !/[\ud800-\udbff]$/.test(part))).toBe(true); + } +}); diff --git a/apps/mobile/src/lib/source-paragraphs.ts b/apps/mobile/src/lib/source-paragraphs.ts new file mode 100644 index 000000000..e2670a71b --- /dev/null +++ b/apps/mobile/src/lib/source-paragraphs.ts @@ -0,0 +1,19 @@ +/** Bound native Text layout work, including imported documents with no paragraph breaks. */ +export function sourceParagraphs(text: string): string[] { + if (text.length === 0) return [text]; + const parts: string[] = []; + for (let start = 0; start < text.length;) { + let end = Math.min(start + 4000, text.length); + const paragraph = text.indexOf('\n\n', start); + if (paragraph >= start && paragraph + 2 <= end) end = paragraph + 2; + else if (end < text.length) { + const space = text.lastIndexOf(' ', end - 1); + if (space > start + 2000) end = space + 1; + // Do not split a surrogate pair between native text views. + if (text.charCodeAt(end - 1) >= 0xd800 && text.charCodeAt(end - 1) <= 0xdbff) end -= 1; + } + parts.push(text.slice(start, end)); + start = end; + } + return parts; +} diff --git a/apps/mobile/src/lib/sources.ts b/apps/mobile/src/lib/sources.ts index 5904e7f99..0f0625039 100644 --- a/apps/mobile/src/lib/sources.ts +++ b/apps/mobile/src/lib/sources.ts @@ -98,8 +98,8 @@ function withDefaults(source: Source): 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).then((sources) => sources.map(withDefaults)); +export function fetchSources(userId: string, debateId: string, mark?: CopyMark, onCopy?: (value: Source[]) => void): Promise { + return cachedRead(userId, 'sources', debateId, () => loadSources(userId, debateId), mark, onCopy && ((sources) => onCopy(sources.map(withDefaults)))).then((sources) => sources.map(withDefaults)); } async function loadSources(userId: string, debateId: string): Promise { diff --git a/apps/mobile/src/lib/subscription.ts b/apps/mobile/src/lib/subscription.ts index 89dbd121f..5bf3a8a71 100644 --- a/apps/mobile/src/lib/subscription.ts +++ b/apps/mobile/src/lib/subscription.ts @@ -21,6 +21,7 @@ export interface Subscription { /** Charged AI units today, or null when the counter cannot be read. */ dailyUsed: number | null; trialEndsAt: string | null; + hasOpenStripeSubscription: boolean; } // The server's tier defaults (supabase/functions/_shared/usage-limits.ts). @@ -52,6 +53,7 @@ export async function fetchSubscription(userId: string): Promise { return { tier: billing.tier, + hasOpenStripeSubscription: billing.hasOpenSubscription, billingState: billing.state, billingDate: billing.date, billingDateKind: billing.dateKind, diff --git a/apps/mobile/src/lib/usage.ts b/apps/mobile/src/lib/usage.ts index 1d31562c7..9fedfba75 100644 --- a/apps/mobile/src/lib/usage.ts +++ b/apps/mobile/src/lib/usage.ts @@ -58,3 +58,10 @@ export function useDailyUsage(): DailyUsage | null { }, [userId]); return userId && current?.userId === userId ? current.usage : null; } + +/** Next midnight UTC, shown on the student's local clock, as on the website. */ +export function dailyResetTime(locale: string): string { + const now = new Date(); + const reset = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1)); + return reset.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit' }); +} diff --git a/apps/mobile/src/lib/use-inactive-debate-check.ts b/apps/mobile/src/lib/use-inactive-debate-check.ts index c6a9347fd..fb25bbe82 100644 --- a/apps/mobile/src/lib/use-inactive-debate-check.ts +++ b/apps/mobile/src/lib/use-inactive-debate-check.ts @@ -13,9 +13,10 @@ const asked = new Set(); */ export function useInactiveDebateCheck() { const router = useRouter(); - const { status, current } = useDebates(); + const { status, fresh, current } = useDebates(); useEffect(() => { - if (status !== 'ready' || !current || asked.has(current.id) || !isDebateInactive(current)) return; + // Only on the server's list: a saved copy can be days older than the student's last work on the website. + if (status !== 'ready' || !fresh || !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(() => { @@ -23,5 +24,5 @@ export function useInactiveDebateCheck() { router.push('/debate-check'); }, 700); return () => clearTimeout(timer); - }, [status, current, router]); + }, [status, fresh, current, router]); } diff --git a/apps/mobile/src/lib/website.ts b/apps/mobile/src/lib/website.ts index 826f8daa9..f975f964b 100644 --- a/apps/mobile/src/lib/website.ts +++ b/apps/mobile/src/lib/website.ts @@ -7,7 +7,7 @@ import Constants from 'expo-constants'; */ const websiteUrl = (Constants.expoConfig?.extra as { websiteUrl?: string } | undefined)?.websiteUrl ?? 'https://debateowl.com'; -export function websitePage(path: '/terms' | '/privacy'): string { +export function websitePage(path: '/terms' | '/privacy' | '/app/settings'): string { return `${websiteUrl}${path}`; } diff --git a/docs/apple-platform.md b/docs/apple-platform.md index d57963781..707c83310 100644 --- a/docs/apple-platform.md +++ b/docs/apple-platform.md @@ -6,6 +6,7 @@ Current. What the iOS and iPadOS app (`apps/mobile`, bundle id `com.pistack.deba - **Expo Go:** none of the native extras below, by design. The config Expo Go loads never changes. Time-up alerts, the review prompt and notification taps work there, because they need only modules Expo Go has. - **Development build** (`DEBATELAB_BUILD=dev`) signed with the borrowed profile: Live Activities and widgets when the profile carries the App Group (`DEBATELAB_APP_GROUP`), Siri and Shortcuts, Handoff, Google sign-in (once Supabase allows the redirect), StoreKit (once App Store Connect has the products). +- **Sideloaded release build** (`sideload` profile, re-signed with a free Apple ID): only the App Group entitlement is in the IPA, and only if the re-signer provisions it under the same name do Live Activities, widgets and the share extension turn on. Push, Sign in with Apple, Time Sensitive alerts, universal links, Handoff from Safari and in-app purchase stay off. [development.md](development.md#sideloaded-ipa) has the list. - **Development or release build signed by DebateOwl's own account** (`DEBATELAB_BUILD=dev DEBATELAB_APP_GROUP=group.com.pistack.debateowl DEBATELAB_APPLE_ACCOUNT=1`): everything, including Sign in with Apple, push, the Time Sensitive alert and Handoff from Safari to the app. `DEBATELAB_APPLE_ACCOUNT=1` keeps the push and Sign in with Apple entitlements that `withoutAccountEntitlements` in `app.config.ts` otherwise removes, and adds Time Sensitive Notifications and `activitycontinuation:debateowl.com`. ## The features diff --git a/docs/development.md b/docs/development.md index eefd2b9b7..b3badc0aa 100644 --- a/docs/development.md +++ b/docs/development.md @@ -139,13 +139,14 @@ Stop it with `$PREVIEW down "debatelab mobile expo-go"` when you are done. A development build is the app's own binary with `expo-dev-client`, so it loads JavaScript from Metro like Expo Go but carries native modules Expo Go lacks: speech recognition for voice rounds and dictation, iPad keyboard shortcuts (the app's own `apps/mobile/modules/key-commands`), Home Screen quick actions and Spotlight, the timer's and the generating Live Activities, the widgets and the share extension (with an App Group), Siri and Shortcuts, Handoff, StoreKit, and Google sign-in (Expo Go cannot receive OAuth redirects). [apple-platform.md](apple-platform.md) lists what each needs from the Apple account. Its Info.plist also gets the 120 Hz key, which Expo Go ignores. JavaScript-only changes never need a new build. -[`apps/mobile/app.config.ts`](../apps/mobile/app.config.ts) builds one config with two switches: +[`apps/mobile/app.config.ts`](../apps/mobile/app.config.ts) builds one config with these switches: - No `DEBATELAB_BUILD`: the Expo Go and web export config, unchanged. - `DEBATELAB_BUILD=dev`: adds `expo-dev-client`, `expo-speech-recognition` with the microphone and speech prompts (English in Info.plist, Spanish and Hungarian as `InfoPlist.strings` from `mobile.permissions` in the catalogs) and `CFBundleAllowMixedLocalizations`. The `development` profile in [`apps/mobile/eas.json`](../apps/mobile/eas.json) sets it. -- `DEBATELAB_APP_GROUP=group.`, only with `dev`: adds the `expo-widgets` extension (`ExpoWidgetsTarget`, bundle `com.pistack.debateowl.ExpoWidgetsTarget`) with the timer's lock-screen widget (`DebateTimerWidget`), the Current Debate widget and Live Activity, `NSSupportsLiveActivities`; the share extension from `expo-share-intent` (`DebateOwlShare`, bundle `com.pistack.debateowl.share-extension`, shown as "DebateOwl Share" in the share sheet); and the App Group `group.com.pistack.debateowl` (the only value the config accepts) on all three targets. Register that group for all three bundle ids in the Apple Developer account. For a device build, set it only when the provisioning profile carries that group; a borrowed wildcard profile does not. A simulator build is not signed, so the `development-simulator` profile always sets it. EAS does not see your shell, so it goes in the profile's `env` in `eas.json`, committed with the build. +- `DEBATELAB_BUILD=release`: the same native app without `expo-dev-client`, so it runs the JavaScript built into it (the `release-simulator` and `sideload` profiles). Without `DEBATELAB_APPLE_ACCOUNT` it also drops Associated Domains, so its only entitlement is the App Group, which a free Apple ID can sign (see [Sideloaded IPA](#sideloaded-ipa)). +- `DEBATELAB_APP_GROUP=group.`, only with `dev` or `release`: adds the `expo-widgets` extension (`ExpoWidgetsTarget`, bundle `com.pistack.debateowl.ExpoWidgetsTarget`) with the timer's lock-screen widget (`DebateTimerWidget`), the Current Debate widget and Live Activity, `NSSupportsLiveActivities`; the share extension from `expo-share-intent` (`DebateOwlShare`, bundle `com.pistack.debateowl.share-extension`, shown as "DebateOwl Share" in the share sheet); and the App Group `group.com.pistack.debateowl` (the only value the config accepts) on all three targets. Register that group for all three bundle ids in the Apple Developer account. For a device build, set it only when the provisioning profile carries that group; a borrowed wildcard profile does not. A simulator build is not signed, so the `development-simulator` profile always sets it. EAS does not see your shell, so it goes in the profile's `env` in `eas.json`, committed with the build. -- `DEBATELAB_APPLE_ACCOUNT=1`, only with `dev`: the build is signed by DebateOwl's own Apple account. It keeps the push and Sign in with Apple entitlements that every other development build drops (the borrowed profile has neither) and adds Time Sensitive Notifications and Handoff from Safari. See [apple-platform.md](apple-platform.md#checklist-for-debateowls-apple-account). +- `DEBATELAB_APPLE_ACCOUNT=1`, only with `dev` or `release`: the build is signed by DebateOwl's own Apple account. It keeps the push and Sign in with Apple entitlements that every other native build drops (the borrowed profile has neither) and adds Time Sensitive Notifications and Handoff from Safari. Without it, `extra.remotePush` is false and the You tab's Notifications switch shows as unavailable. See [apple-platform.md](apple-platform.md#checklist-for-debateowls-apple-account). `nativeCapabilities()` in `apps/mobile/src/lib/native-capabilities.ts` tells the app at runtime what the binary has. Feature code checks it and never imports a dev-build module at the top level, so Expo Go keeps working. @@ -259,6 +260,23 @@ eas build --platform ios --profile development-simulator --non-interactive --no- The artifact is a `.tar.gz` of the `.app`; drag the unpacked app onto a Simulator, then start Metro with `--dev-client`. +#### Sideloaded IPA + +The `sideload` profile builds the release app for iPhone and iPad against **production** (`EXPO_PUBLIC_SUPABASE_ENV=production`), signed by no Apple team, for someone to re-sign with a free Apple ID in Sideloadly or AltStore. `release-simulator` builds the same app for the Simulator against staging. A normal EAS device build refuses to run without credentials, so `sideload` is a custom build, [`apps/mobile/.eas/build/sideload-ios.yml`](../apps/mobile/.eas/build/sideload-ios.yml): prebuild, the `eas-build-post-install` patches (custom builds skip npm's `eas-build-*` hooks), `pod install`, `xcodebuild archive` with signing off, then each bundle is signed ad hoc with the entitlements prebuild wrote and packed as `DebateOwl-sideload.ipa`. It needs no credentials, only a logged-in `eas`, and counts as one iOS build: + +```bash +cd apps/mobile +eas build --platform ios --profile sideload --non-interactive --no-wait +``` + +EAS keeps the artifact for a limited time (the build's `expirationDate`), so attach the IPA to a GitHub release when it has to stay downloadable. A free Apple ID signs only some of the app. These stay off, without errors: + +- push (the switch says this version cannot receive notifications), Sign in with Apple (the button is hidden), the Time Sensitive alert (it arrives as a normal notification), universal links and Handoff from Safari; +- in-app purchase (`plans.tsx` shows the App Store as unavailable); +- Live Activities, the widgets and the share extension whenever the re-signer does not provision the App Group: `nativeCapabilities()` finds no container and the app runs without them. AltStore provisions it as `group.com.pistack.debateowl.` and writes that name into each bundle's `ALTAppGroups`; `scripts/patch-app-group-lookup.mjs` makes expo-widgets and expo-share-intent read that key first, so they work there. Sideloadly may not provision the group at all. + +Everything else works as in any build: email code, demo code and Google sign-in (once production allows the `debatelab://auth/callback` redirect), every tool, the timer with its local time-up alert, voice and dictation, quick actions, Spotlight, Siri and Shortcuts, iPad shortcuts and offline reading. A free signature lasts seven days. + #### Installing it The finished build's page on expo.dev has an Install button and a QR code. Adam opens it on the iPhone in Safari and installs. For an enterprise certificate, iOS then asks to trust the developer under Settings › General › VPN & Device Management. If iOS asks for Developer Mode, it is under Settings › Privacy & Security. diff --git a/packages/core/src/free-tools/custom-format.test.ts b/packages/core/src/free-tools/custom-format.test.ts index a13db3144..c120a4101 100644 --- a/packages/core/src/free-tools/custom-format.test.ts +++ b/packages/core/src/free-tools/custom-format.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { CUSTOM_LIMITS, parseCustomFormat } from './custom-format'; +import { CUSTOM_LIMITS, parseCustomFormat, customToRoundFormat, decodeCustomFormat, encodeCustomFormat, roundFormatToCustom } from './custom-format'; +import { FORMATS } from './formats'; const speech = { label: 'Opening', seconds: 300, kind: 'speech', side: 'a', poi: true }; @@ -23,3 +24,45 @@ describe('parseCustomFormat', () => { expect(parseCustomFormat({ name: 'x', prepSeconds: 0, segments: Array(CUSTOM_LIMITS.segments + 1).fill(speech) })).toBeNull(); }); }); + +describe('custom formats in the URL', () => { + it('round-trips a format through its share parameter', () => { + const custom = roundFormatToCustom(FORMATS.wsdc); + custom.name = 'Klub vita – őszi forduló'; + const encoded = encodeCustomFormat(custom); + expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/); + expect(decodeCustomFormat(encoded)).toEqual(custom); + const timer = customToRoundFormat(custom); + expect(timer.segments[0].poi).toEqual({ opensAt: 60, closesAt: 420 }); + expect(timer.segments[6].poi).toBeUndefined(); + }); + + it('rejects malformed or out-of-bounds parameters', () => { + const encode = (wire: unknown) => btoa(JSON.stringify(wire)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + const ok = { v: 1, n: 'Club', p: 120, s: [['Prop 1', 300, 's', 'a', 1]] }; + expect(decodeCustomFormat(encode(ok))).not.toBeNull(); + for (const bad of [ + { ...ok, v: 2 }, + { ...ok, p: CUSTOM_LIMITS.maxPrepSeconds + 1 }, + { ...ok, s: [] }, + { ...ok, s: Array.from({ length: CUSTOM_LIMITS.segments + 1 }, () => ok.s[0]) }, + { ...ok, s: [['Prop 1', 5, 's', 'a', 1]] }, + { ...ok, s: [['Prop 1', 300.5, 's', 'a', 1]] }, + { ...ok, s: [[' ', 300, 's', 'a', 1]] }, + { ...ok, s: [['Prop 1', 300, 'x', 'a', 1]] }, + { ...ok, s: [['Prop 1', 300, 's', 'c', 1]] }, + ]) expect(decodeCustomFormat(encode(bad))).toBeNull(); + expect(decodeCustomFormat('not base64!')).toBeNull(); + expect(decodeCustomFormat(encode(ok).repeat(200))).toBeNull(); + expect(decodeCustomFormat(null)).toBeNull(); + }); + + it('cleans labels and never gives questioning periods points of information', () => { + const encode = (wire: unknown) => btoa(JSON.stringify(wire)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + const decoded = decodeCustomFormat(encode({ v: 1, n: '', p: 0, s: [[`CX\n${'x'.repeat(80)}`, 180, 'q', 'both', 1]] })); + expect(decoded?.name).toBe('Custom format'); + expect(decoded?.segments[0].label).toHaveLength(CUSTOM_LIMITS.label); + expect(decoded?.segments[0].label.startsWith('CX x')).toBe(true); + expect(decoded?.segments[0].poi).toBe(false); + }); +}); diff --git a/packages/core/src/free-tools/custom-format.ts b/packages/core/src/free-tools/custom-format.ts index 52132047f..b29a24347 100644 --- a/packages/core/src/free-tools/custom-format.ts +++ b/packages/core/src/free-tools/custom-format.ts @@ -1,7 +1,7 @@ /** * A coach's own round format and the limits it must fit. The website shares - * one in a URL (`?custom=`, src/lib/free-tools/custom-format.ts); the app - * keeps them on the device. + * one in a URL (`?custom=`); the app uses the same codec and keeps formats + * on the device. */ import type { RoundFormat, RoundSegment, SegmentSide } from './formats'; @@ -104,3 +104,55 @@ export function roundFormatToCustom(format: RoundFormat): CustomFormat { })), }; } + +type WireSegment = [label: string, seconds: number, kind: 's' | 'q', side: SegmentSide, poi: 0 | 1]; +interface Wire { v: 1; n: string; p: number; s: WireSegment[] } + +function toBase64Url(text: string): string { + const bytes = new TextEncoder().encode(text); + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function fromBase64Url(value: string): string { + const binary = atob(value.replace(/-/g, '+').replace(/_/g, '/')); + return new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(binary, (char) => char.charCodeAt(0))); +} + +export function encodeCustomFormat(format: CustomFormat): string { + const wire: Wire = { + v: 1, + n: format.name, + p: format.prepSeconds, + s: format.segments.map((segment) => [segment.label, segment.seconds, segment.kind === 'speech' ? 's' : 'q', segment.side, segment.poi ? 1 : 0]), + }; + return toBase64Url(JSON.stringify(wire)); +} + +/** The custom format in a `?custom=` value, or null when it is malformed or out of bounds. */ +export function decodeCustomFormat(value: string | null | undefined): CustomFormat | null { + if (!value || value.length > CUSTOM_LIMITS.encoded || !/^[A-Za-z0-9_-]+$/.test(value)) return null; + let wire: unknown; + try { + wire = JSON.parse(fromBase64Url(value)); + } catch { + return null; + } + if (typeof wire !== 'object' || wire === null) return null; + const { v, n, p, s } = wire as Record; + if (v !== 1 || typeof n !== 'string' || !isWholeSeconds(p, 0, CUSTOM_LIMITS.maxPrepSeconds)) return null; + if (!Array.isArray(s) || s.length === 0 || s.length > CUSTOM_LIMITS.segments) return null; + const segments: CustomSegment[] = []; + for (const entry of s) { + if (!Array.isArray(entry) || entry.length !== 5) return null; + const [label, seconds, kind, side, poi] = entry as unknown[]; + if (typeof label !== 'string' || !cleanText(label, CUSTOM_LIMITS.label)) return null; + if (!isWholeSeconds(seconds, CUSTOM_LIMITS.minSeconds, CUSTOM_LIMITS.maxSeconds)) return null; + if (kind !== 's' && kind !== 'q') return null; + if (side !== 'a' && side !== 'b' && side !== 'both') return null; + if (poi !== 0 && poi !== 1) return null; + segments.push({ label: cleanText(label, CUSTOM_LIMITS.label), seconds, kind: kind === 's' ? 'speech' : 'cross-examination', side, poi: poi === 1 && kind === 's' }); + } + return { name: cleanText(n, CUSTOM_LIMITS.name) || 'Custom format', prepSeconds: p, segments }; +} diff --git a/packages/core/src/globals.d.ts b/packages/core/src/globals.d.ts index 74e39980f..446767741 100644 --- a/packages/core/src/globals.d.ts +++ b/packages/core/src/globals.d.ts @@ -4,6 +4,7 @@ declare function setTimeout(callback: () => void, ms?: number): number; declare function clearTimeout(id: number | undefined): void; declare class TextDecoder { + constructor(label?: string, options?: { fatal?: boolean }); decode(input?: Uint8Array, options?: { stream?: boolean }): string; } @@ -29,3 +30,9 @@ declare class URLSearchParams { forEach(callback: (value: string, key: string) => void): void; toString(): string; } + +declare class TextEncoder { + encode(input: string): Uint8Array; +} +declare function btoa(value: string): string; +declare function atob(value: string): string; diff --git a/src/lib/free-tools/custom-format.ts b/src/lib/free-tools/custom-format.ts index 6a3776ef8..183ccf0c1 100644 --- a/src/lib/free-tools/custom-format.ts +++ b/src/lib/free-tools/custom-format.ts @@ -1,63 +1,2 @@ -/** - * A coach's own round format, small enough to share in a URL (`?custom=`). - * The parameter is untrusted input: anything that does not fit the limits - * below is rejected rather than repaired. The format itself is shared with - * the app in @debatelab/core. - */ -import type { SegmentSide } from './formats'; -import { cleanText, CUSTOM_LIMITS, isWholeSeconds, type CustomFormat, type CustomSegment } from '@debatelab/core/free-tools/custom-format'; - +// Shared format validation and URL codec; kept for existing website imports. export * from '@debatelab/core/free-tools/custom-format'; - -type WireSegment = [label: string, seconds: number, kind: 's' | 'q', side: SegmentSide, poi: 0 | 1]; -interface Wire { v: 1; n: string; p: number; s: WireSegment[] } - -function toBase64Url(text: string): string { - const bytes = new TextEncoder().encode(text); - let binary = ''; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); -} - -function fromBase64Url(value: string): string { - const binary = atob(value.replace(/-/g, '+').replace(/_/g, '/')); - return new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(binary, (char) => char.charCodeAt(0))); -} - -export function encodeCustomFormat(format: CustomFormat): string { - const wire: Wire = { - v: 1, - n: format.name, - p: format.prepSeconds, - s: format.segments.map((segment) => [segment.label, segment.seconds, segment.kind === 'speech' ? 's' : 'q', segment.side, segment.poi ? 1 : 0]), - }; - return toBase64Url(JSON.stringify(wire)); -} - -/** The custom format in a `?custom=` value, or null when it is malformed or out of bounds. */ -export function decodeCustomFormat(value: string | null | undefined): CustomFormat | null { - if (!value || value.length > CUSTOM_LIMITS.encoded || !/^[A-Za-z0-9_-]+$/.test(value)) return null; - let wire: unknown; - try { - wire = JSON.parse(fromBase64Url(value)); - } catch { - return null; - } - if (typeof wire !== 'object' || wire === null) return null; - const { v, n, p, s } = wire as Record; - if (v !== 1 || typeof n !== 'string' || !isWholeSeconds(p, 0, CUSTOM_LIMITS.maxPrepSeconds)) return null; - if (!Array.isArray(s) || s.length === 0 || s.length > CUSTOM_LIMITS.segments) return null; - const segments: CustomSegment[] = []; - for (const entry of s) { - if (!Array.isArray(entry) || entry.length !== 5) return null; - const [label, seconds, kind, side, poi] = entry as unknown[]; - if (typeof label !== 'string' || !cleanText(label, CUSTOM_LIMITS.label)) return null; - if (!isWholeSeconds(seconds, CUSTOM_LIMITS.minSeconds, CUSTOM_LIMITS.maxSeconds)) return null; - if (kind !== 's' && kind !== 'q') return null; - if (side !== 'a' && side !== 'b' && side !== 'both') return null; - if (poi !== 0 && poi !== 1) return null; - segments.push({ label: cleanText(label, CUSTOM_LIMITS.label), seconds, kind: kind === 's' ? 'speech' : 'cross-examination', side, poi: poi === 1 && kind === 's' }); - } - return { name: cleanText(n, CUSTOM_LIMITS.name) || 'Custom format', prepSeconds: p, segments }; -} - diff --git a/src/locales/en.json b/src/locales/en.json index 271afc832..16980e9cd 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2663,6 +2663,18 @@ "promoFailed": "Unable to redeem promo code. Please try again." }, "mobile": { + "animation": { + "attackReady": "Rebuttals ready", + "brainstormReady": "Ideas ready", + "factoidsReady": "Factoids ready", + "rebuttals": "Rebuttals", + "ideas": "Ideas", + "factoids": "Factoids", + "roundReady": "Round complete", + "speechesTimed": "Speeches timed", + "totalTime": "Total time", + "keepPractising": "Keep practising — you’re getting there" + }, "tabs": { "debate": "Debate", "prepare": "Prepare", @@ -2738,7 +2750,12 @@ "openSettings": "Open Settings", "unavailable": "This version of the app cannot receive notifications yet.", "failed": "Could not change notifications. Try again." - } + }, + "signInApple": "Signed in with Apple", + "signInAppleOrEmail": "Signed in with Apple or email", + "signInAppleOrGoogle": "Signed in with Apple or Google", + "signInAppleGoogleOrEmail": "Signed in with Apple, Google or email", + "manageWebsiteSubscription": "Manage your subscription on debateowl.com" }, "deleteAccount": { "failedHint": "Your account is still here. Check your connection and try again." @@ -2997,7 +3014,8 @@ "speechTimeIntro": "Paste or type a speech to see how long it takes to deliver.", "calcPrivacy": "Counted on this device. The text is never uploaded or stored.", "motionsDesc": "Draw a practice motion to argue", - "motionsIntro": "Original practice motions, by topic, level and type." + "motionsIntro": "Original practice motions, by topic, level and type.", + "flowSheetDesc": "A column for each speech, ready to print or share as a PDF." }, "sidebar": { "dashboard": "Dashboard", @@ -3116,6 +3134,9 @@ "year_one": "{{count}} year", "year_other": "{{count}} years" } + }, + "flowSheet": { + "exportFailed": "Could not export the flow sheet." } } } diff --git a/src/locales/es.json b/src/locales/es.json index 6745d9011..8ebf9e676 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2663,6 +2663,18 @@ "promoFailed": "No se pudo canjear el código promocional. Inténtalo de nuevo." }, "mobile": { + "animation": { + "attackReady": "Refutaciones listas", + "brainstormReady": "Ideas listas", + "factoidsReady": "Datos listos", + "rebuttals": "Refutaciones", + "ideas": "Ideas", + "factoids": "Datos", + "roundReady": "Ronda completada", + "speechesTimed": "Discursos cronometrados", + "totalTime": "Tiempo total", + "keepPractising": "Sigue practicando, vas por buen camino" + }, "tabs": { "debate": "Debate", "prepare": "Preparar", @@ -2738,7 +2750,12 @@ "openSettings": "Abrir Ajustes", "unavailable": "Esta versión de la app todavía no puede recibir notificaciones.", "failed": "No se pudieron cambiar las notificaciones. Inténtalo de nuevo." - } + }, + "signInApple": "Sesión iniciada con Apple", + "signInAppleOrEmail": "Sesión iniciada con Apple o correo electrónico", + "signInAppleOrGoogle": "Sesión iniciada con Apple o Google", + "signInAppleGoogleOrEmail": "Sesión iniciada con Apple, Google o correo electrónico", + "manageWebsiteSubscription": "Gestiona tu suscripción en debateowl.com" }, "deleteAccount": { "failedHint": "Tu cuenta sigue activa. Comprueba tu conexión e inténtalo de nuevo." @@ -2997,7 +3014,8 @@ "speechTimeIntro": "Pega o escribe un discurso para ver cuánto tarda en pronunciarse.", "calcPrivacy": "Se cuenta en este dispositivo. El texto nunca se sube ni se guarda.", "motionsDesc": "Saca una moción de práctica", - "motionsIntro": "Mociones de práctica originales, por tema, nivel y tipo." + "motionsIntro": "Mociones de práctica originales, por tema, nivel y tipo.", + "flowSheetDesc": "Una columna por discurso, lista para imprimir o compartir en PDF." }, "sidebar": { "dashboard": "Panel", @@ -3116,6 +3134,9 @@ "year_one": "{{count}} año", "year_other": "{{count}} años" } + }, + "flowSheet": { + "exportFailed": "No se pudo exportar la hoja de debate." } } } diff --git a/src/locales/hu.json b/src/locales/hu.json index 9a7e2279e..86e31e799 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2663,6 +2663,18 @@ "promoFailed": "Nem sikerült beváltani a promóciós kódot. Próbáld újra." }, "mobile": { + "animation": { + "attackReady": "Elkészültek a cáfolatok", + "brainstormReady": "Elkészültek az ötletek", + "factoidsReady": "Elkészültek a tények", + "rebuttals": "Cáfolatok", + "ideas": "Ötletek", + "factoids": "Tények", + "roundReady": "A forduló véget ért", + "speechesTimed": "Mért beszédek", + "totalTime": "Összes idő", + "keepPractising": "Gyakorolj tovább, jó úton jársz" + }, "tabs": { "debate": "Vita", "prepare": "Felkészülés", @@ -2738,7 +2750,12 @@ "openSettings": "Beállítások megnyitása", "unavailable": "Az alkalmazás ezen verziója még nem tud értesítéseket fogadni.", "failed": "Nem sikerült módosítani az értesítéseket. Próbáld újra." - } + }, + "signInApple": "Bejelentkezve Apple-fiókkal", + "signInAppleOrEmail": "Bejelentkezve Apple-fiókkal vagy e-maillel", + "signInAppleOrGoogle": "Bejelentkezve Apple- vagy Google-fiókkal", + "signInAppleGoogleOrEmail": "Bejelentkezve Apple- vagy Google-fiókkal, illetve e-maillel", + "manageWebsiteSubscription": "Előfizetés kezelése a debateowl.com oldalon" }, "deleteAccount": { "failedHint": "A fiókod megmaradt. Ellenőrizd a kapcsolatot, és próbáld újra." @@ -2997,7 +3014,8 @@ "speechTimeIntro": "Illeszd be vagy írd be a beszédet, és megtudod, mennyi ideig tart elmondani.", "calcPrivacy": "Ezen az eszközön számoljuk. A szöveget nem töltjük fel és nem tároljuk.", "motionsDesc": "Gyakorló vitatétel húzása", - "motionsIntro": "Saját gyakorló vitatételek téma, szint és típus szerint." + "motionsIntro": "Saját gyakorló vitatételek téma, szint és típus szerint.", + "flowSheetDesc": "Minden beszédnek külön oszlop, nyomtatáshoz vagy PDF-ként megosztáshoz." }, "sidebar": { "dashboard": "Áttekintés", @@ -3116,6 +3134,9 @@ "year_one": "{{count}} évig", "year_other": "{{count}} évig" } + }, + "flowSheet": { + "exportFailed": "Nem sikerült exportálni a vita jegyzetlapját." } } } diff --git a/src/test/free-tools/round-timer.test.ts b/src/test/free-tools/round-timer.test.ts index 3d6f25ae1..48f7ce67b 100644 --- a/src/test/free-tools/round-timer.test.ts +++ b/src/test/free-tools/round-timer.test.ts @@ -13,7 +13,6 @@ import { togglePrep, toggleSpeech, } from '@/lib/free-tools/round-timer'; -import { CUSTOM_LIMITS, customToRoundFormat, decodeCustomFormat, encodeCustomFormat, roundFormatToCustom } from '@/lib/free-tools/custom-format'; const S = 1000; const MIN = 60 * S; @@ -129,45 +128,3 @@ describe('round timer', () => { expect(parseRoundState('nonsense', FORMATS.pf)).toBeNull(); }); }); - -describe('custom formats in the URL', () => { - it('round-trips a format through its share parameter', () => { - const custom = roundFormatToCustom(FORMATS.wsdc); - custom.name = 'Klub vita – őszi forduló'; - const encoded = encodeCustomFormat(custom); - expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/); - expect(decodeCustomFormat(encoded)).toEqual(custom); - const timer = customToRoundFormat(custom); - expect(timer.segments[0].poi).toEqual({ opensAt: 60, closesAt: 420 }); - expect(timer.segments[6].poi).toBeUndefined(); - }); - - it('rejects malformed or out-of-bounds parameters', () => { - const encode = (wire: unknown) => btoa(JSON.stringify(wire)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); - const ok = { v: 1, n: 'Club', p: 120, s: [['Prop 1', 300, 's', 'a', 1]] }; - expect(decodeCustomFormat(encode(ok))).not.toBeNull(); - for (const bad of [ - { ...ok, v: 2 }, - { ...ok, p: CUSTOM_LIMITS.maxPrepSeconds + 1 }, - { ...ok, s: [] }, - { ...ok, s: Array.from({ length: CUSTOM_LIMITS.segments + 1 }, () => ok.s[0]) }, - { ...ok, s: [['Prop 1', 5, 's', 'a', 1]] }, - { ...ok, s: [['Prop 1', 300.5, 's', 'a', 1]] }, - { ...ok, s: [[' ', 300, 's', 'a', 1]] }, - { ...ok, s: [['Prop 1', 300, 'x', 'a', 1]] }, - { ...ok, s: [['Prop 1', 300, 's', 'c', 1]] }, - ]) expect(decodeCustomFormat(encode(bad))).toBeNull(); - expect(decodeCustomFormat('not base64!')).toBeNull(); - expect(decodeCustomFormat(encode(ok).repeat(200))).toBeNull(); - expect(decodeCustomFormat(null)).toBeNull(); - }); - - it('cleans labels and never gives questioning periods points of information', () => { - const encode = (wire: unknown) => btoa(JSON.stringify(wire)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); - const decoded = decodeCustomFormat(encode({ v: 1, n: '', p: 0, s: [[`CX\n${'x'.repeat(80)}`, 180, 'q', 'both', 1]] })); - expect(decoded?.name).toBe('Custom format'); - expect(decoded?.segments[0].label).toHaveLength(CUSTOM_LIMITS.label); - expect(decoded?.segments[0].label.startsWith('CX x')).toBe(true); - expect(decoded?.segments[0].poi).toBe(false); - }); -}); diff --git a/src/test/i18n-untranslated.test.ts b/src/test/i18n-untranslated.test.ts index b6e396c26..4fd431da4 100644 --- a/src/test/i18n-untranslated.test.ts +++ b/src/test/i18n-untranslated.test.ts @@ -65,6 +65,7 @@ const SAME_AS_ENGLISH: Record = { 'es translation:askChat.pros', 'es translation:historyPage.tabChats', 'es translation:sourceQuality.types.social', 'es translation:debateContext.debateHeading', 'es tools:motions.types.actor', 'es translation:mobile.tabs.debate', 'es translation:mobile.brainstorm.ideaCount_one', 'es translation:mobile.brainstorm.ideaCount_other', + 'es translation:mobile.animation.ideas', ], 'Hungarian words spelled the same as the English ones': [ 'hu tools:motions.topics.sport', 'hu translation:mobile.library.types.url',