From 2361b759a15a27405860ab2f8fb611e50731e3cf Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 29 Oct 2026 19:50:28 +0000 Subject: [PATCH 01/14] Add a release build and an unsigned IPA for sideloading DEBATELAB_BUILD=release is the development build without the development client. Without DebateOwl's own Apple account it keeps only the App Group, so a free Apple ID can re-sign it, and the push switch shows as unavailable instead of failing. The sideload profile archives an unsigned Release build on EAS and packs it as an IPA; release-simulator builds the same app for the Simulator. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/.eas/build/sideload-ios.yml | 80 +++++++++++++++++++++++++ apps/mobile/app.config.ts | 58 +++++++++++------- apps/mobile/eas.json | 28 +++++++++ apps/mobile/src/lib/push.ts | 7 ++- 4 files changed, 148 insertions(+), 25 deletions(-) create mode 100644 apps/mobile/.eas/build/sideload-ios.yml diff --git a/apps/mobile/.eas/build/sideload-ios.yml b/apps/mobile/.eas/build/sideload-ios.yml new file mode 100644 index 00000000..4012c9a8 --- /dev/null +++ b/apps/mobile/.eas/build/sideload-ios.yml @@ -0,0 +1,80 @@ +# 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 + - eas/prebuild + # Custom builds skip npm's eas-build-* hooks; prebuild may reinstall + # node_modules, so the expo-widgets patch runs after it. + - run: + name: Patch expo-widgets for the workspace + command: npm run eas-build-post-install + - 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/app.config.ts b/apps/mobile/app.config.ts index 7a6039ca..56a4ff44 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 d77718c8..789ee818 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/src/lib/push.ts b/apps/mobile/src/lib/push.ts index ce3fe9f2..7dd4fdb2 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()); } From 4e86bfd0d1ab6a7de5711d8dea564a1dca15dd81 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 29 Oct 2026 19:54:10 +0000 Subject: [PATCH 02/14] Document the release build and the sideloaded IPA Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 4 ++-- docs/apple-platform.md | 1 + docs/development.md | 24 +++++++++++++++++++++--- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 43db914e..ae372801 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -12,9 +12,9 @@ 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. +- `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 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. ## Layout diff --git a/docs/apple-platform.md b/docs/apple-platform.md index d5796378..707c8331 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 eefd2b9b..c0ce2d56 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 expo-widgets patch (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 `group.com.pistack.debateowl` under that exact name: `nativeCapabilities()` finds no container and the app runs without them. AltStore renames groups to `.`, so they are off there too. + +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. From 3de2b213047b7f9e5293c76e3d004465fd6b9278 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 29 Oct 2026 19:55:39 +0000 Subject: [PATCH 03/14] Find the App Group AltStore renamed AltStore re-signs with a free Apple ID by registering the App Group as . and writing that name into ALTAppGroups. expo-widgets and expo-share-intent now read that key first, so Live Activities, widgets and the share extension work in an AltStore install; other builds are unchanged. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/.eas/build/sideload-ios.yml | 14 ++-- apps/mobile/AGENTS.md | 2 +- apps/mobile/package.json | 2 +- .../mobile/scripts/patch-app-group-lookup.mjs | 65 +++++++++++++++++++ docs/development.md | 4 +- 5 files changed, 79 insertions(+), 8 deletions(-) create mode 100644 apps/mobile/scripts/patch-app-group-lookup.mjs diff --git a/apps/mobile/.eas/build/sideload-ios.yml b/apps/mobile/.eas/build/sideload-ios.yml index 4012c9a8..a03e96b3 100644 --- a/apps/mobile/.eas/build/sideload-ios.yml +++ b/apps/mobile/.eas/build/sideload-ios.yml @@ -12,12 +12,18 @@ build: - eas/checkout - eas/install_node_modules - eas/resolve_build_config - - eas/prebuild - # Custom builds skip npm's eas-build-* hooks; prebuild may reinstall - # node_modules, so the expo-widgets patch runs after it. + # 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 expo-widgets for the workspace + 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 diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index ae372801..82bfade1 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -66,7 +66,7 @@ The iOS app DebateOwl (`@debatelab/mobile`; bundle id `com.pistack.debateowl`, E - `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. - `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/package.json b/apps/mobile/package.json index 47e36671..b9d9d026 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/patch-app-group-lookup.mjs b/apps/mobile/scripts/patch-app-group-lookup.mjs new file mode 100644 index 00000000..eaab71c0 --- /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/docs/development.md b/docs/development.md index c0ce2d56..b3badc0a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -262,7 +262,7 @@ The artifact is a `.tar.gz` of the `.app`; drag the unpacked app onto a Simulato #### 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 expo-widgets patch (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: +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 @@ -273,7 +273,7 @@ EAS keeps the artifact for a limited time (the build's `expirationDate`), so att - 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 `group.com.pistack.debateowl` under that exact name: `nativeCapabilities()` finds no container and the app runs without them. AltStore renames groups to `.`, so they are off there too. +- 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. From 63f7e4cc4a63cede2a6a4c1521b9d2dac144cdf5 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 29 Oct 2026 19:58:24 +0000 Subject: [PATCH 04/14] Close iOS feature parity gaps with website tools Add Sparring Word export, saved Crossfire deletion, Generate role parity, model selection, account and plan details, shared timer links, sign-in language selection, and printable flow sheets. Co-Authored-By: Codex --- apps/mobile/AGENTS.md | 16 ++-- .../src/app/(tabs)/(practice)/practice.tsx | 1 + apps/mobile/src/app/(tabs)/(you)/you.tsx | 15 +++- apps/mobile/src/app/_layout.tsx | 1 + apps/mobile/src/app/crossfire-round/[id].tsx | 44 ++++++++-- apps/mobile/src/app/flow-sheet.tsx | 81 +++++++++++++++++++ apps/mobile/src/app/generate.tsx | 8 +- apps/mobile/src/app/history.tsx | 30 +++++-- apps/mobile/src/app/sign-in.tsx | 19 ++++- apps/mobile/src/app/sparring/index.tsx | 13 +-- apps/mobile/src/app/timer-format.tsx | 8 +- apps/mobile/src/app/timer.tsx | 14 +++- apps/mobile/src/components/practice-tools.ts | 3 +- apps/mobile/src/components/sidebar-items.ts | 5 +- apps/mobile/src/components/sparring-views.tsx | 13 ++- apps/mobile/src/components/tool-form.tsx | 10 ++- apps/mobile/src/lib/app-intent-links.test.ts | 13 +++ apps/mobile/src/lib/app-intent-links.ts | 4 +- apps/mobile/src/lib/crossfire.ts | 10 ++- apps/mobile/src/lib/deep-links.test.ts | 19 +++++ apps/mobile/src/lib/deep-links.ts | 20 ++++- apps/mobile/src/lib/export-docx-content.ts | 5 ++ apps/mobile/src/lib/export-docx.ts | 7 +- apps/mobile/src/lib/export-docx.web.ts | 7 +- apps/mobile/src/lib/export-html.ts | 45 +++++++++++ apps/mobile/src/lib/export.ts | 4 +- apps/mobile/src/lib/export.web.ts | 2 +- apps/mobile/src/lib/history.ts | 12 +-- apps/mobile/src/lib/offline-cache.test.ts | 35 +++++++- apps/mobile/src/lib/sign-in-method.ts | 24 ++++++ apps/mobile/src/lib/subscription.ts | 2 + apps/mobile/src/lib/usage.ts | 7 ++ apps/mobile/src/lib/website.ts | 2 +- .../core/src/free-tools/custom-format.test.ts | 45 ++++++++++- packages/core/src/free-tools/custom-format.ts | 56 ++++++++++++- packages/core/src/globals.d.ts | 7 ++ src/lib/free-tools/custom-format.ts | 63 +-------------- src/locales/en.json | 13 ++- src/locales/es.json | 13 ++- src/locales/hu.json | 13 ++- src/test/free-tools/round-timer.test.ts | 43 ---------- 41 files changed, 574 insertions(+), 178 deletions(-) create mode 100644 apps/mobile/src/app/flow-sheet.tsx create mode 100644 apps/mobile/src/lib/sign-in-method.ts diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 43db914e..60e46248 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -19,7 +19,7 @@ The iOS app DebateOwl (`@debatelab/mobile`; bundle id `com.pistack.debateowl`, E ## 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. @@ -31,22 +31,24 @@ The iOS app DebateOwl (`@debatelab/mobile`; bundle id `com.pistack.debateowl`, E - 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`). - 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. @@ -59,7 +61,7 @@ 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. diff --git a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx index 4c03bdce..ee755c73 100644 --- a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx +++ b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx @@ -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' } }, }; diff --git a/apps/mobile/src/app/(tabs)/(you)/you.tsx b/apps/mobile/src/app/(tabs)/(you)/you.tsx index c815b29b..fc3ce283 100644 --- a/apps/mobile/src/app/(tabs)/(you)/you.tsx +++ b/apps/mobile/src/app/(tabs)/(you)/you.tsx @@ -31,6 +31,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 +86,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); @@ -126,7 +129,7 @@ export default function YouScreen() { - + {session?.user.email ?? ''} + {signInMethod ? {t(signInMethod)} : null} {supabaseEnv === 'staging' ? ( @@ -383,7 +387,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; @@ -444,14 +448,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} diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 6bfb31cb..289e9d8b 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -165,6 +165,7 @@ function Root() { + diff --git a/apps/mobile/src/app/crossfire-round/[id].tsx b/apps/mobile/src/app/crossfire-round/[id].tsx index 948a3aa3..da6c541d 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,7 +12,9 @@ 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 { useCopyHolder } from '@/lib/use-copy-holder'; @@ -27,13 +29,39 @@ 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; + try { + await deleteCrossfireRound(forUser, id); + if (live.current && owner.current === forUser && roundId.current === id) router.back(); + } catch { + if (live.current && owner.current === forUser && roundId.current === id) showNotice(t('historyPage.crossfireDeleteFailed')); + } + }; useEffect(() => { if (!userId) return; @@ -41,11 +69,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 +102,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/flow-sheet.tsx b/apps/mobile/src/app/flow-sheet.tsx new file mode 100644 index 00000000..a3bd0016 --- /dev/null +++ b/apps/mobile/src/app/flow-sheet.tsx @@ -0,0 +1,81 @@ +import { BUILT_IN_FORMAT_IDS, FORMATS, questioningSegments, type BuiltInFormatId } from '@debatelab/core/free-tools/formats'; +import { useLocalSearchParams } from 'expo-router'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { MainActionButton } from '@/components/buttons'; +import { DocumentScreen, DocumentTitle } from '@/components/document'; +import { DocumentHeader } from '@/components/document-header'; +import { Field } from '@/components/field'; +import { FormGroup, SelectRow, SwitchRow } from '@/components/form-rows'; +import { backLabel } from '@/lib/back-label'; +import { showNotice } from '@/lib/confirm'; +import { sharePdf } from '@/lib/export'; +import { flowSheetExportHtml } from '@/lib/export-html'; +import { useLayoutClass } from '@/lib/layout'; + +/** The website's flow sheet controls, with the result sent to the PDF share sheet. */ +export default function FlowSheetScreen() { + const { t } = useTranslation(); + const tools = (key: string) => 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); + try { + const [short, long] = paper === 'a4' ? [595.28, 841.89] : [612, 792]; + await sharePdf( + flowSheetExportHtml({ format, paper, orientation, strip, ...header }, tools), + `DebateFlowSheet_${formatId}.pdf`, + tools('flow.region'), + orientation === 'landscape' ? { width: long, height: short } : { width: short, height: long }, + ); + } catch { + showNotice(t('mobile.flowSheet.exportFailed')); + } finally { + 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/generate.tsx b/apps/mobile/src/app/generate.tsx index 309ca415..42be5d4e 100644 --- a/apps/mobile/src/app/generate.tsx +++ b/apps/mobile/src/app/generate.tsx @@ -1,5 +1,5 @@ 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'; @@ -100,7 +100,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. @@ -297,6 +300,7 @@ export default function GenerateScreen() { value={form.role} onChange={(role) => 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 70376b23..61a6dba6 100644 --- a/apps/mobile/src/app/history.tsx +++ b/apps/mobile/src/app/history.tsx @@ -35,6 +35,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 +44,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 +53,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 +83,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' } } }, @@ -171,10 +181,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), @@ -195,8 +205,8 @@ export default function HistoryScreen() { 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; }; @@ -254,7 +264,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 +360,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 +375,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} /> @@ -527,7 +538,7 @@ function HistoryRow({ item, first, onPress, onDelete }: { item: HistoryItem; fir renderRightActions={() => ( void remove()} style={[styles.deleteAction, { backgroundColor: colors.destructive }]}> @@ -573,6 +584,7 @@ function UnsortedRow({ busy, onChoose, onAttach, + onDelete, }: { entry: UnsortedEntry; debates: Debate[]; @@ -581,6 +593,7 @@ function UnsortedRow({ busy: boolean; onChoose: (debateId: string) => void; onAttach: (debateId: string) => void; + onDelete?: () => Promise; }) { const { t } = useTranslation(); const router = useRouter(); @@ -611,6 +624,7 @@ function UnsortedRow({ + {onDelete ? void onDelete()} /> : null} {href ? router.push(href)} /> : null} {suggestion ? ( diff --git a/apps/mobile/src/app/sign-in.tsx b/apps/mobile/src/app/sign-in.tsx index 3da8064d..5e28c304 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'; @@ -14,6 +15,7 @@ import { signInErrorKey } from '@/lib/sign-in-errors'; 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 +32,14 @@ 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 [status, setStatus] = useState('idle'); const [codeSent, setCodeSent] = useState(false); const [email, setEmail] = useState(''); @@ -139,7 +142,18 @@ 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 i18n.changeLanguage(language); }} + /> {t('login.title')} @@ -306,6 +320,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/sparring/index.tsx b/apps/mobile/src/app/sparring/index.tsx index 2ab012f4..ed11bcc0 100644 --- a/apps/mobile/src/app/sparring/index.tsx +++ b/apps/mobile/src/app/sparring/index.tsx @@ -47,7 +47,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'; @@ -108,7 +108,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 +181,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 +209,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 +220,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,7 +256,7 @@ 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. const timerRunning = session?.timerRunning ?? false; @@ -485,6 +485,7 @@ export default function SparringScreen() { ))} + diff --git a/apps/mobile/src/app/timer-format.tsx b/apps/mobile/src/app/timer-format.tsx index e19923d4..033c2e45 100644 --- a/apps/mobile/src/app/timer-format.tsx +++ b/apps/mobile/src/app/timer-format.tsx @@ -1,4 +1,4 @@ -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'; @@ -14,6 +14,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'; @@ -200,6 +202,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 12706729..0e88f438 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,8 +13,8 @@ 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 { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -53,6 +54,15 @@ 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(); diff --git a/apps/mobile/src/components/practice-tools.ts b/apps/mobile/src/components/practice-tools.ts index 7c809a24..5220b322 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/sidebar-items.ts b/apps/mobile/src/components/sidebar-items.ts index c0e79b6f..d0cdd7c2 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/sparring-views.tsx b/apps/mobile/src/components/sparring-views.tsx index a8798789..76eb554c 100644 --- a/apps/mobile/src/components/sparring-views.tsx +++ b/apps/mobile/src/components/sparring-views.tsx @@ -18,6 +18,7 @@ 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'; @@ -101,6 +102,15 @@ export function SparringScorecardScreen({ } }; + const exportWord = async () => { + try { + 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 () => { try { await sharePdf(sparringExportHtml(session, t), sparringExportFileName(session), winnerText(t, session.scorecard.winner)); @@ -119,7 +129,8 @@ export function SparringScorecardScreen({ backLabel={backLabel(t, from, 'practice')} onShare={() => 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/tool-form.tsx b/apps/mobile/src/components/tool-form.tsx index fe550a7b..c3b78628 100644 --- a/apps/mobile/src/components/tool-form.tsx +++ b/apps/mobile/src/components/tool-form.tsx @@ -8,7 +8,7 @@ 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'; @@ -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 })} ); } diff --git a/apps/mobile/src/lib/app-intent-links.test.ts b/apps/mobile/src/lib/app-intent-links.test.ts index 1692d483..ea3e6663 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 8ae9bf42..cc0dd581 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 af709295..17320f91 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/deep-links.test.ts b/apps/mobile/src/lib/deep-links.test.ts index 44d45034..e28150f5 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 b5113238..fc1f7e8b 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 3113582d..5038b679 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 6c4924c0..9b88cbbc 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 506eec0f..b95cf96b 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 2eb12234..bccc5f06 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 a87827cc..d5eba1f0 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 4c179b7c..b20a5e77 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 48309c12..1651fb05 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,22 +144,23 @@ async function fetchRounds(userId: string, scope: Scope): Promise }); } -/** The six lists of one debate. Offline, the saved copy. */ +/** The seven lists of one debate. Offline, the saved copy. */ export function fetchDebateHistory(userId: string, debateId: string, mark?: CopyMark): Promise { return cachedRead(userId, 'history', debateId, () => loadDebateHistory(userId, debateId), mark); } async function loadDebateHistory(userId: string, debateId: string): Promise { const scope = { debateId }; - const [cases, chats, attacks, brainstorms, factoids, sparring] = await readAll([ + 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. */ diff --git a/apps/mobile/src/lib/offline-cache.test.ts b/apps/mobile/src/lib/offline-cache.test.ts index 9bda6a68..f623fcbb 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', () => ({ @@ -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 }; } @@ -295,6 +297,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'); 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 00000000..8bba426c --- /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/subscription.ts b/apps/mobile/src/lib/subscription.ts index 89dbd121..5bf3a8a7 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 1d31562c..9fedfba7 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/website.ts b/apps/mobile/src/lib/website.ts index 826f8daa..f975f964 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/packages/core/src/free-tools/custom-format.test.ts b/packages/core/src/free-tools/custom-format.test.ts index a13db314..c120a410 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 52132047..b29a2434 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 74e39980..44676774 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 6a3776ef..183ccf0c 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 271afc83..da087d05 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2738,7 +2738,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 +3002,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 +3122,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 6745d901..5f45e549 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2738,7 +2738,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 +3002,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 +3122,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 9a7e2279..e1a0dffb 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2738,7 +2738,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 +3002,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 +3122,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 3d6f25ae..48f7ce67 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); - }); -}); From a7ce002581a57c775ecb5ec36325becfc31433db Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 29 Oct 2026 20:00:37 +0000 Subject: [PATCH 05/14] Polish mobile presses, Ollie reactions and result celebrations Celebrate fresh tool results and completed timer rounds, welcome new debates once, and show save confirmation after its sheet closes. Add shared spring presses and session entrances while retaining Reduce Motion and accessibility behavior. Keep timer feedback separate from clock logic. Co-Authored-By: Codex --- apps/mobile/AGENTS.md | 2 + apps/mobile/src/app/(tabs)/(debate)/index.tsx | 64 ++++++---- .../src/app/(tabs)/(practice)/practice.tsx | 6 +- .../src/app/(tabs)/(prepare)/prepare.tsx | 6 +- apps/mobile/src/app/(tabs)/(you)/you.tsx | 3 +- apps/mobile/src/app/_layout.tsx | 2 + apps/mobile/src/app/ask.tsx | 24 ++-- apps/mobile/src/app/attack/[id].tsx | 87 +++++++------ apps/mobile/src/app/attack/index.tsx | 2 + apps/mobile/src/app/brainstorm/[id].tsx | 106 +++++++++------- apps/mobile/src/app/brainstorm/index.tsx | 2 + apps/mobile/src/app/context-review.tsx | 16 +-- apps/mobile/src/app/crossfire.tsx | 18 +-- apps/mobile/src/app/debates.tsx | 8 +- apps/mobile/src/app/factoids/[id].tsx | 117 ++++++++++-------- apps/mobile/src/app/factoids/index.tsx | 2 + apps/mobile/src/app/flowchart/index.tsx | 10 +- apps/mobile/src/app/history.tsx | 22 ++-- apps/mobile/src/app/new-debate.tsx | 5 +- apps/mobile/src/app/plans.tsx | 7 +- apps/mobile/src/app/save-case.tsx | 25 +++- apps/mobile/src/app/sparring/index.tsx | 23 ++-- apps/mobile/src/app/timer-format.tsx | 9 +- apps/mobile/src/app/timer.tsx | 64 ++++++++-- .../mobile/src/components/action-menu.web.tsx | 5 +- .../src/components/announcement-banner.tsx | 10 +- apps/mobile/src/components/app-sidebar.tsx | 22 ++-- apps/mobile/src/components/app-tabs.web.tsx | 7 +- apps/mobile/src/components/app-text-input.tsx | 7 +- apps/mobile/src/components/ask-answer.tsx | 10 +- apps/mobile/src/components/ask-chat-list.tsx | 11 +- apps/mobile/src/components/buttons.tsx | 7 +- apps/mobile/src/components/card.tsx | 31 ++++- apps/mobile/src/components/case-document.tsx | 16 +-- apps/mobile/src/components/celebration.tsx | 16 ++- apps/mobile/src/components/chip.tsx | 11 +- apps/mobile/src/components/claim-panel.tsx | 20 +-- .../mobile/src/components/crossfire-views.tsx | 16 ++- apps/mobile/src/components/debate-form.tsx | 10 +- .../src/components/document-header.web.tsx | 19 +-- apps/mobile/src/components/document.tsx | 10 +- .../src/components/factoids-document.tsx | 10 +- .../mobile/src/components/flowchart-views.tsx | 11 +- apps/mobile/src/components/form-rows.tsx | 10 +- .../src/components/library-header.web.tsx | 15 +-- apps/mobile/src/components/library-parts.tsx | 10 +- apps/mobile/src/components/live-run.tsx | 4 +- apps/mobile/src/components/outline.tsx | 10 +- apps/mobile/src/components/owl.tsx | 26 +++- apps/mobile/src/components/press-scale.tsx | 41 +++--- apps/mobile/src/components/round-controls.tsx | 11 +- .../components/round-feedback-observer.tsx | 13 ++ .../src/components/segmented-control.tsx | 7 +- apps/mobile/src/components/source-row.tsx | 14 +-- apps/mobile/src/components/source-view.tsx | 16 +-- apps/mobile/src/components/timer-ring.tsx | 21 +++- apps/mobile/src/components/toast.tsx | 14 +-- apps/mobile/src/components/tool-form.tsx | 7 +- apps/mobile/src/components/trial-banner.tsx | 16 +-- apps/mobile/src/components/voice.tsx | 24 ++-- apps/mobile/src/components/work-row.tsx | 5 +- apps/mobile/src/lib/debate-arrival.ts | 14 +++ apps/mobile/src/lib/round-feedback.test.ts | 55 ++++++++ apps/mobile/src/lib/round-feedback.ts | 46 +++++++ src/locales/en.json | 12 ++ src/locales/es.json | 12 ++ src/locales/hu.json | 12 ++ 67 files changed, 843 insertions(+), 451 deletions(-) create mode 100644 apps/mobile/src/components/round-feedback-observer.tsx create mode 100644 apps/mobile/src/lib/debate-arrival.ts create mode 100644 apps/mobile/src/lib/round-feedback.test.ts create mode 100644 apps/mobile/src/lib/round-feedback.ts diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 43db914e..ffa9a332 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -65,6 +65,8 @@ The iOS app DebateOwl (`@debatelab/mobile`; bundle id `com.pistack.debateowl`, E - 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. diff --git a/apps/mobile/src/app/(tabs)/(debate)/index.tsx b/apps/mobile/src/app/(tabs)/(debate)/index.tsx index 78772f15..b87dccfe 100644 --- a/apps/mobile/src/app/(tabs)/(debate)/index.tsx +++ b/apps/mobile/src/app/(tabs)/(debate)/index.tsx @@ -4,10 +4,11 @@ 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 { 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,6 +19,8 @@ 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'; @@ -120,10 +123,8 @@ export default function DebateScreen() { - - {current.motion} - - + +
); @@ -135,15 +136,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 +195,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 +214,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')} - + ); } @@ -318,7 +341,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 +349,9 @@ function DebateDetails({ debateId, dashboard = false }: { debateId: string; dash {PREPARE_TOOLS.map((tool, index) => ( - + router.push(prepareToolHref(tool.key, 'debate'))} /> - + ))} @@ -467,8 +490,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 +602,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)/(practice)/practice.tsx b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx index 4c03bdce..db934b20 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'; @@ -85,7 +85,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 +98,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 5a2cde70..7ba33700 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'; @@ -76,7 +76,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 +89,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 c815b29b..63f43a7f 100644 --- a/apps/mobile/src/app/(tabs)/(you)/you.tsx +++ b/apps/mobile/src/app/(tabs)/(you)/you.tsx @@ -285,7 +285,7 @@ function PressRow({ divider = false, children, ...props }: Omit - {({ pressed }) => {children}} + {() => {children}} ); } @@ -478,6 +478,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 6bfb31cb..941dd50f 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'; @@ -182,6 +183,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/ask.tsx b/apps/mobile/src/app/ask.tsx index ab44b88d..a38aeaae 100644 --- a/apps/mobile/src/app/ask.tsx +++ b/apps/mobile/src/app/ask.tsx @@ -3,9 +3,10 @@ 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 { AppTextInput } from '@/components/app-text-input'; import { AskAnswer } from '@/components/ask-answer'; import { AskChatPane } from '@/components/ask-chat-list'; @@ -515,9 +516,9 @@ export default function AskScreen() { {split ? ( ) : ( - router.back()} style={styles.headerButton}> + router.back()} style={styles.headerButton}> - + )} @@ -532,7 +533,7 @@ export default function AskScreen() { {split ? ( ) : ( - - + )} @@ -583,6 +584,7 @@ export default function AskScreen() { {error ? ( + {t('ask.failed')} @@ -625,7 +627,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 ef69876e..60f9d8b7 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 4479c3d1..5b2095af 100644 --- a/apps/mobile/src/app/attack/index.tsx +++ b/apps/mobile/src/app/attack/index.tsx @@ -13,6 +13,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'; @@ -155,6 +156,7 @@ export default function AttackScreen() { 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]', diff --git a/apps/mobile/src/app/brainstorm/[id].tsx b/apps/mobile/src/app/brainstorm/[id].tsx index 2402109f..a9f6148f 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 08344fec..119bf3ea 100644 --- a/apps/mobile/src/app/brainstorm/index.tsx +++ b/apps/mobile/src/app/brainstorm/index.tsx @@ -14,6 +14,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'; @@ -145,6 +146,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]', diff --git a/apps/mobile/src/app/context-review.tsx b/apps/mobile/src/app/context-review.tsx index ad52e97e..fc01ea9a 100644 --- a/apps/mobile/src/app/context-review.tsx +++ b/apps/mobile/src/app/context-review.tsx @@ -4,8 +4,9 @@ import { useLocalSearchParams, useRouter } from 'expo-router'; import type { TFunction } from 'i18next'; import { useState, type ReactNode } 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 { SecondaryButton, TextButton } from '@/components/buttons'; import { ListItemIn, SkeletonTextRows } from '@/components/card'; import { Icon } from '@/components/icon'; @@ -170,7 +171,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 +196,7 @@ function CheckRow({ checked, label, meta, note, first, onChange, trailing }: { ) : null} - + {trailing} ); @@ -228,7 +229,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 +263,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.tsx b/apps/mobile/src/app/crossfire.tsx index d8803c2d..e28495a0 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/debates.tsx b/apps/mobile/src/app/debates.tsx index 5630bd95..460f0b1c 100644 --- a/apps/mobile/src/app/debates.tsx +++ b/apps/mobile/src/app/debates.tsx @@ -5,6 +5,7 @@ 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 { AppTextInput } from '@/components/app-text-input'; import { PrimaryButton, TextButton } from '@/components/buttons'; import { Card, ListItemIn, SkeletonTextRows, usePressScale } from '@/components/card'; @@ -204,7 +205,7 @@ export default function DebatesSheet() { overshootRight={false} containerStyle={divider} renderRightActions={() => ( - void removeDebate(debate)} @@ -213,7 +214,7 @@ export default function DebatesSheet() { {t('common.delete')} - + )}> {row} @@ -270,7 +271,7 @@ function DebateRowPress({ children, ...props }: Omit - {({ pressed }) => {children}} + {() => {children}} ); } @@ -296,7 +297,6 @@ const styles = StyleSheet.create({ 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 47b8d85e..687e0488 100644 --- a/apps/mobile/src/app/factoids/[id].tsx +++ b/apps/mobile/src/app/factoids/[id].tsx @@ -3,6 +3,9 @@ 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 { ActionMenu } from '@/components/action-menu'; import { PrimaryButton, SecondaryButton } from '@/components/buttons'; @@ -39,6 +42,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); @@ -122,56 +127,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 d1c72767..1c658317 100644 --- a/apps/mobile/src/app/factoids/index.tsx +++ b/apps/mobile/src/app/factoids/index.tsx @@ -15,6 +15,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'; @@ -153,6 +154,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]', diff --git a/apps/mobile/src/app/flowchart/index.tsx b/apps/mobile/src/app/flowchart/index.tsx index 1840e532..cfdaedfb 100644 --- a/apps/mobile/src/app/flowchart/index.tsx +++ b/apps/mobile/src/app/flowchart/index.tsx @@ -2,9 +2,10 @@ 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 { ActionMenu } from '@/components/action-menu'; import { TextButton } from '@/components/buttons'; import { ClaimInspector } from '@/components/claim-panel'; @@ -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/history.tsx b/apps/mobile/src/app/history.tsx index 70376b23..dabc53aa 100644 --- a/apps/mobile/src/app/history.tsx +++ b/apps/mobile/src/app/history.tsx @@ -5,6 +5,7 @@ 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 { ActionMenu } from '@/components/action-menu'; import { PrimaryButton, TextButton } from '@/components/buttons'; import { Card, IconTile, ListItemIn, usePressScale } from '@/components/card'; @@ -385,12 +386,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 +401,7 @@ function ScopePicker({ label, onPress }: { label: string; onPress: () => void }) - + ); } @@ -468,8 +469,8 @@ function HistoryRow({ item, first, onPress, onDelete }: { item: HistoryItem; fir onPressOut={press.onPressOut} testID="history-row" style={{ backgroundColor: colors.card }}> - {({ pressed }) => ( - + {() => ( + @@ -525,7 +526,7 @@ function HistoryRow({ item, first, onPress, onDelete }: { item: HistoryItem; fir overshootRight={false} containerStyle={divider} renderRightActions={() => ( - void remove()} @@ -534,7 +535,7 @@ function HistoryRow({ item, first, onPress, onDelete }: { item: HistoryItem; fir {t('common.delete')} - + )}> {body} @@ -624,18 +625,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')} - + { - 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/plans.tsx b/apps/mobile/src/app/plans.tsx index cadcea71..ac7139d3 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 e6c02955..2534d749 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/sparring/index.tsx b/apps/mobile/src/app/sparring/index.tsx index 2ab012f4..00dc3fb3 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'; @@ -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; @@ -489,9 +490,12 @@ export default function SparringScreen() { {formError ? ( - - {formError} - + + + + {formError} + + ) : null} - + {judging ? t('sparring.round.scoreFailed') : t('sparring.round.opponentFailed')} @@ -733,7 +738,7 @@ function LiveRound({ ) : null} {userTurn && lastAi ? ( - setExpanded((value) => !value)} @@ -748,7 +753,7 @@ function LiveRound({ {lastAi.content} - + ) : null} {userTurn ? ( diff --git a/apps/mobile/src/app/timer-format.tsx b/apps/mobile/src/app/timer-format.tsx index e19923d4..64dd3995 100644 --- a/apps/mobile/src/app/timer-format.tsx +++ b/apps/mobile/src/app/timer-format.tsx @@ -3,8 +3,9 @@ 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'; @@ -51,16 +52,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]}> - + ); } diff --git a/apps/mobile/src/app/timer.tsx b/apps/mobile/src/app/timer.tsx index 12706729..a68c100f 100644 --- a/apps/mobile/src/app/timer.tsx +++ b/apps/mobile/src/app/timer.tsx @@ -13,11 +13,15 @@ import { type PrepSide, } from '@debatelab/core/free-tools/round-timer'; import { useRouter } from 'expo-router'; -import { useEffect, useState } from 'react'; +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'; @@ -57,13 +61,34 @@ export default function TimerScreen() { 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 +177,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 +203,7 @@ export default function TimerScreen() { accessibilityRole="timer" accessibilityLabel={`${tools('timer.clockLabel')}: ${face.text}, ${status}`} testID="timer-display"> - + {status} @@ -192,6 +217,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 +256,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 +274,7 @@ export default function TimerScreen() { {formatDuration(item.seconds)} - + ); })} @@ -259,7 +287,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 +384,16 @@ export default function TimerScreen() { go(round.segmentIndex + 1)} disabled={isLast} testID="timer-next" /> + {celebration ? ( + setCelebration(null)} + /> + ) : null} ) : 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 81853e14..c67dc58f 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 9c4856a6..13b8625c 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 9ab534e7..83f1afb2 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 124c156b..e25e7a36 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 b1fd54a6..121ad9c9 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 88938bd6..fbbdede7 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 6c1963cb..bc1c34fa 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 aad4afd0..5211f5b2 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 f9afcfad..291fcbf8 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 ? ( diff --git a/apps/mobile/src/components/chip.tsx b/apps/mobile/src/components/chip.tsx index 01ca2521..4c5f9dfd 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 b2b534f2..4c0f24da 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 80409ab1..6a6380b9 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 8e952abc..24fa9b02 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'; @@ -105,7 +106,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 +189,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 df7f16c2..335108e2 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 4a0b2707..6f72480c 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 adbfe772..5f4901cf 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 65ef5c52..00a1465e 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 ac3e9171..069c2a7b 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 }]}> - + ; + return ; } const writing = run.phase === 'writing'; @@ -220,7 +220,7 @@ export function LiveToolRun({ return ( - + {writing ? t('tools.live.writingLive') : status} diff --git a/apps/mobile/src/components/outline.tsx b/apps/mobile/src/components/outline.tsx index 22423c9e..2421dfa6 100644 --- a/apps/mobile/src/components/outline.tsx +++ b/apps/mobile/src/components/outline.tsx @@ -1,7 +1,8 @@ import { createContext, useCallback, 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 { useTheme } from '@/theme/use-theme'; @@ -127,19 +128,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 +153,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 e59c1e8f..946c2804 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/press-scale.tsx b/apps/mobile/src/components/press-scale.tsx index f225339e..f3bd8a72 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 419607a9..dca25aff 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 00000000..e193d070 --- /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 66562b96..4911127c 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/source-row.tsx b/apps/mobile/src/components/source-row.tsx index 49452eb1..d17e5419 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 9e8b3ce0..35392593 100644 --- a/apps/mobile/src/components/source-view.tsx +++ b/apps/mobile/src/components/source-view.tsx @@ -2,8 +2,9 @@ import { useFocusEffect } from 'expo-router'; import * as WebBrowser from 'expo-web-browser'; import { useCallback, useEffect, 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 { MainActionButton, SecondaryButton, TextButton } from './buttons'; import { Card, IconTile } from './card'; @@ -258,17 +259,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} @@ -311,16 +312,16 @@ export function SourceView({ id, onDone, onRemoved }: { id: string; onDone?: () {view.content} - void remove()} testID="source-delete" - style={({ pressed }) => [styles.delete, { borderColor: colors.border }, pressed && styles.pressed]}> + style={[styles.delete, { borderColor: colors.border }]}> {t('common.delete')} - + )} @@ -338,7 +339,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/timer-ring.tsx b/apps/mobile/src/components/timer-ring.tsx index f788ac51..09aa97d8 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 ed3c19c9..1b8a6d46 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'; @@ -99,7 +100,7 @@ function ToastView({ toast, onClose }: { toast: ShownToast; onClose: () => void ) : toast.tone === 'error' ? ( ) : null} - + {toast.title} @@ -108,9 +109,9 @@ function ToastView({ toast, onClose }: { toast: ShownToast; onClose: () => void {toast.message} ) : null} - + {toast.action ? ( - { @@ -118,11 +119,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 +150,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 fe550a7b..dfd506e8 100644 --- a/apps/mobile/src/components/tool-form.tsx +++ b/apps/mobile/src/components/tool-form.tsx @@ -13,7 +13,7 @@ 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 }) { @@ -77,6 +77,7 @@ export function ToolError({ title, error, onRetry }: { title: string; error: AIE useEffect(() => haptics.warning(), [error]); return ( + {title} @@ -101,6 +102,7 @@ export function ToolProgress({ elapsed, onCancel, pose = 'thinking', + step, }: { messages: string[]; detail?: string; @@ -108,6 +110,7 @@ export function ToolProgress({ elapsed?: number; onCancel: () => void; pose?: OwlPose; + step?: string; }) { const { t } = useTranslation(); const { colors } = useTheme(); @@ -118,7 +121,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 7c2b46d6..d776d451 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 81572b9e..9aafa6d9 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 e80b187a..ca331e22 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/debate-arrival.ts b/apps/mobile/src/lib/debate-arrival.ts new file mode 100644 index 00000000..9a95a5b7 --- /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/round-feedback.test.ts b/apps/mobile/src/lib/round-feedback.test.ts new file mode 100644 index 00000000..27bc5941 --- /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 00000000..3b3eebc8 --- /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/src/locales/en.json b/src/locales/en.json index 271afc83..244ad7ac 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", diff --git a/src/locales/es.json b/src/locales/es.json index 6745d901..41aec306 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", diff --git a/src/locales/hu.json b/src/locales/hu.json index 9a7e2279..8fbdf43d 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", From 4e4264dec2ac2c8e742cdac576e3e694d81dea0c Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 29 Oct 2026 20:09:27 +0000 Subject: [PATCH 06/14] Improve mobile startup and isolate frequent renders Paint account-owned cached tab data while refreshing, defer unused catalogs, and isolate streamed drafts and deadline clocks. Virtualize growing sheet lists and source text, skip unchanged source copy writes, and remove inexpensive React Compiler blockers. Record focused verification and the protected remaining skips. Co-Authored-By: Codex --- apps/mobile/AGENTS.md | 9 +- apps/mobile/performance-audit.md | 262 ++++++++++++++ apps/mobile/scripts/compiler-audit.mjs | 38 +++ apps/mobile/src/app/(tabs)/(debate)/index.tsx | 21 +- .../src/app/(tabs)/(library)/library.tsx | 23 +- .../src/app/(tabs)/(prepare)/prepare.tsx | 4 +- apps/mobile/src/app/(tabs)/(you)/you.tsx | 15 +- apps/mobile/src/app/_layout.tsx | 6 +- apps/mobile/src/app/add-file.tsx | 14 +- apps/mobile/src/app/add-link.tsx | 22 +- apps/mobile/src/app/ask.tsx | 61 ++-- apps/mobile/src/app/attack/index.tsx | 17 +- apps/mobile/src/app/brainstorm/index.tsx | 27 +- apps/mobile/src/app/context-review.tsx | 9 +- apps/mobile/src/app/debate-check.tsx | 6 +- apps/mobile/src/app/debates.tsx | 321 +++++++++--------- apps/mobile/src/app/factoids/[id].tsx | 9 +- apps/mobile/src/app/factoids/index.tsx | 25 +- apps/mobile/src/app/flowchart/index.tsx | 34 +- apps/mobile/src/app/flowchart/maps.tsx | 134 ++++---- apps/mobile/src/app/generate.tsx | 43 +-- apps/mobile/src/app/history.tsx | 32 +- apps/mobile/src/app/limit.tsx | 7 +- apps/mobile/src/app/new-note.tsx | 7 +- apps/mobile/src/app/source/[id].tsx | 8 +- apps/mobile/src/app/sparring/index.tsx | 105 ++++-- apps/mobile/src/app/welcome.tsx | 2 +- apps/mobile/src/components/action-menu.tsx | 10 +- apps/mobile/src/components/celebration.tsx | 19 +- apps/mobile/src/components/debate-form.tsx | 9 +- apps/mobile/src/components/list-screen.tsx | 5 +- apps/mobile/src/components/live-run.tsx | 44 +-- apps/mobile/src/components/outline.tsx | 33 +- apps/mobile/src/components/sheet.tsx | 33 +- .../src/components/source-file-reader.tsx | 11 +- apps/mobile/src/components/source-view.tsx | 137 +++++--- apps/mobile/src/components/sparring-views.tsx | 13 +- apps/mobile/src/components/success-check.tsx | 7 +- apps/mobile/src/components/toast.tsx | 6 +- apps/mobile/src/lib/dates.ts | 8 +- apps/mobile/src/lib/debate-context.tsx | 22 +- apps/mobile/src/lib/debates.ts | 16 +- apps/mobile/src/lib/history.ts | 8 +- apps/mobile/src/lib/i18n.test.ts | 17 + apps/mobile/src/lib/i18n.ts | 44 ++- apps/mobile/src/lib/offline-cache.test.ts | 108 +++++- apps/mobile/src/lib/offline-cache.ts | 47 ++- apps/mobile/src/lib/run-request.test.ts | 16 + apps/mobile/src/lib/run-request.ts | 11 + apps/mobile/src/lib/settings-cache.test.ts | 30 ++ apps/mobile/src/lib/settings-cache.ts | 24 ++ apps/mobile/src/lib/settings.tsx | 43 +-- apps/mobile/src/lib/source-file.tsx | 8 +- apps/mobile/src/lib/source-paragraphs.test.ts | 12 + apps/mobile/src/lib/source-paragraphs.ts | 19 ++ apps/mobile/src/lib/sources.ts | 4 +- 56 files changed, 1411 insertions(+), 614 deletions(-) create mode 100644 apps/mobile/performance-audit.md create mode 100644 apps/mobile/scripts/compiler-audit.mjs create mode 100644 apps/mobile/src/lib/i18n.test.ts create mode 100644 apps/mobile/src/lib/run-request.test.ts create mode 100644 apps/mobile/src/lib/run-request.ts create mode 100644 apps/mobile/src/lib/settings-cache.test.ts create mode 100644 apps/mobile/src/lib/settings-cache.ts create mode 100644 apps/mobile/src/lib/source-paragraphs.test.ts create mode 100644 apps/mobile/src/lib/source-paragraphs.ts diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 43db914e..aa25b349 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -15,16 +15,17 @@ The iOS app DebateOwl (`@debatelab/mobile`; bundle id `com.pistack.debateowl`, E - 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. - 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. +- 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/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. @@ -48,7 +49,7 @@ The iOS app DebateOwl (`@debatelab/mobile`; bundle id `com.pistack.debateowl`, E - 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`. - 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. diff --git a/apps/mobile/performance-audit.md b/apps/mobile/performance-audit.md new file mode 100644 index 00000000..05a5f5ab --- /dev/null +++ b/apps/mobile/performance-audit.md @@ -0,0 +1,262 @@ +# Mobile startup and render performance audit + +Branch: `t3/mobile-startup-render-speed`. Base: `ebbc75bc6402e6cc2a30ee82d65346c3c5aeb9e7`. + +## 1. Done. Copy-first reads + +Optional `onCopy` paints an account-owned disk copy while the original promise refreshes. Pending refreshes do not mark data offline. Status-0 failure keeps the copy and raises the notice; server refusals still throw, including when storage is slow. Owner/generation, read tickets, CopyHolder release and write invalidation remain in place. Debate, Library, Prepare and History use the callback. + +Files: [src/lib/offline-cache.ts](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/offline-cache.ts), [src/lib/debates.ts](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/debates.ts), [src/lib/history.ts](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/history.ts), [src/lib/sources.ts](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/sources.ts), [src/lib/debate-context.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/debate-context.tsx), [src/app/(tabs)/(debate)/index.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/(tabs)/(debate)/index.tsx), [src/app/(tabs)/(library)/library.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/(tabs)/(library)/library.tsx), [src/app/(tabs)/(prepare)/prepare.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx), [src/app/history.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/history.tsx), [src/lib/offline-cache.test.ts](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/offline-cache.test.ts). + +## 2. Done. Date imports + +Functions and en-GB, Spanish and Hungarian locales use date-fns subpaths. + +Files: [src/lib/dates.ts](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/dates.ts). + +## 3. Done. Streamed drafts + +`useLiveRun` writes to a LiveValue store. Only LiveToolRun subscribes; section parsing runs there instead of in the parent screen render. Activity updates, cancellation and finished-result behavior remain. + +Files: [src/components/live-run.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/components/live-run.tsx), [src/app/generate.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/generate.tsx), [src/app/attack/index.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/attack/index.tsx), [src/app/brainstorm/index.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/brainstorm/index.tsx), [src/app/factoids/index.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/factoids/index.tsx). + +## 4. Done. Sparring clock + +Display components tick independently from the session. A deadline timeout and foreground check commit only expiry, while pause/reset/submit keep using the existing session helpers. The setup form and model picker were not edited. + +Files: [src/app/sparring/index.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/sparring/index.tsx). + +## 5. Done, with two protected skips. React Compiler + +Counters call module nextId functions. Render-time ref writes use useLatest or layout effects. Hooks suppressions use latest handlers with explicit dependencies. Request error handling and cleanup run through a module helper. Failed selection and deletion still stop before follow-up actions. The remaining skips are outside the allowed Sparring-clock and CountUp edits. + +Files: [src/lib/run-request.ts](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/run-request.ts), [src/lib/run-request.test.ts](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/run-request.test.ts), [scripts/compiler-audit.mjs](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/scripts/compiler-audit.mjs). + +## 6. Done. Unchanged source copies + +Sources have no updated_at column, so the cache compares the exact serialized value before writing it again. This avoids stale text from an ids-only signature. Unchanged full-text entry writes are skipped; the small LRU index still updates. Serialization and reading the existing value still cost work. + +Files: [src/lib/offline-cache.ts](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/offline-cache.ts), [src/lib/offline-cache.test.ts](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/offline-cache.test.ts). + +## 7. Done. Startup + +The root onLayout hides the splash after readiness. Settings preload alongside auth restore and are applied only to the matching confirmed owner. A tagged snapshot keeps another account settings out of context; the preload is consumed once and later account changes reread their own copy. + +Files: [src/app/_layout.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/_layout.tsx), [src/lib/settings.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/settings.tsx), [src/lib/settings-cache.ts](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/settings-cache.ts), [src/lib/settings-cache.test.ts](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/settings-cache.test.ts). + +## 8. Done. Catalog loading + +English and the device language initialize synchronously. changeAppLanguage requires the other bundled translation and tools namespaces before selecting that language; offline switching retains synchronous key resolution. + +Files: [src/lib/i18n.ts](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/i18n.ts), [src/lib/i18n.test.ts](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/i18n.test.ts). + +## 9. Done. Growing lists + +Debates and saved maps use SheetList, a FlatList whose native scroll view remains the direct sheet child. SourceView owns a virtualized paragraph list in both its sheet and iPad pane, with no outer scroll view. Chunks retain the text, cap native Text work at 4,000 characters, and preserve surrogate pairs. Copy/share/edit still use the full source. Native sheet position and phone layout have not been exercised. + +Files: [src/components/sheet.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/components/sheet.tsx), [src/components/list-screen.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/components/list-screen.tsx), [src/app/debates.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/debates.tsx), [src/app/flowchart/maps.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/flowchart/maps.tsx), [src/components/source-view.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/components/source-view.tsx), [src/app/source/[id].tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/source/[id].tsx), [src/app/(tabs)/(library)/library.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/(tabs)/(library)/library.tsx), [src/lib/source-paragraphs.ts](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/source-paragraphs.ts), [src/lib/source-paragraphs.test.ts](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/source-paragraphs.test.ts). + +## 10. Done. Scroll and count updates + +The outline retains scroll offsets in a ref and sets state only when the selected section changes. CountUp keeps its easing and final tick but updates text at most ten times per second. All other celebration code and timer-ring code were left alone. + +Files: [src/components/outline.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/components/outline.tsx), [src/components/celebration.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/components/celebration.tsx). + +## Compiler skips before and after + +The logger scanned 126 TSX files under the mobile source tree, excluding .web.tsx, with target React 19. This is a Babel compiler diagnostic run, not a Metro build or iPhone performance measurement. + +Before: 228 successful compilations, 48 diagnostics in 34 files. After: 267 successful compilations, 2 diagnostics in 2 files. The earlier progress note saying 33 files was a counting error. + +Before: + +- [src/app/(tabs)/(debate)/index.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/(tabs)/(debate)/index.tsx): Cannot access refs during render. +- [src/app/(tabs)/(library)/library.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/(tabs)/(library)/library.tsx): Cannot access refs during render. +- [src/app/(tabs)/(you)/you.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/(tabs)/(you)/you.tsx): (BuildHIR::lowerStatement) Handle TryStatement with a finalizer ('finally') clause. +- [src/app/add-file.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/add-file.tsx): React Compiler has skipped optimizing this component because one or more React ESLint rules were disabled. +- [src/app/add-link.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/add-link.tsx): React Compiler has skipped optimizing this component because one or more React ESLint rules were disabled. +- [src/app/ask.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/ask.tsx): (BuildHIR::lowerStatement) Handle TryStatement with a finalizer ('finally') clause; (BuildHIR::lowerStatement) Support ThrowStatement inside of try/catch; (BuildHIR::lowerExpression) Handle ??= operators in AssignmentExpression. +- [src/app/attack/index.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/attack/index.tsx): (BuildHIR::lowerStatement) Handle TryStatement with a finalizer ('finally') clause. +- [src/app/brainstorm/index.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/brainstorm/index.tsx): (BuildHIR::lowerStatement) Handle TryStatement with a finalizer ('finally') clause. +- [src/app/context-review.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/context-review.tsx): (BuildHIR::lowerStatement) Handle TryStatement with a finalizer ('finally') clause. +- [src/app/debate-check.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/debate-check.tsx): Cannot access refs during render. +- [src/app/debates.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/debates.tsx): (BuildHIR::lowerStatement) Handle TryStatement with a finalizer ('finally') clause. +- [src/app/factoids/[id].tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/factoids/[id].tsx): (BuildHIR::lowerStatement) Handle TryStatement with a finalizer ('finally') clause. +- [src/app/factoids/index.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/factoids/index.tsx): React Compiler has skipped optimizing this component because one or more React ESLint rules were disabled. +- [src/app/flowchart/index.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/flowchart/index.tsx): (BuildHIR::lowerStatement) Handle TryStatement without a catch clause. +- [src/app/flowchart/maps.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/flowchart/maps.tsx): (BuildHIR::lowerStatement) Handle TryStatement with a finalizer ('finally') clause. +- [src/app/generate.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/generate.tsx): (BuildHIR::lowerStatement) Handle TryStatement with a finalizer ('finally') clause. +- [src/app/history.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/history.tsx): (BuildHIR::lowerStatement) Handle TryStatement with a finalizer ('finally') clause. +- [src/app/limit.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/limit.tsx): Support value blocks (conditional, logical, optional chaining, etc) within a try/catch statement. +- [src/app/new-note.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/new-note.tsx): Support value blocks (conditional, logical, optional chaining, etc) within a try/catch statement. +- [src/app/sparring/index.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/sparring/index.tsx): Support value blocks (conditional, logical, optional chaining, etc) within a try/catch statement. +- [src/app/welcome.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/welcome.tsx): Cannot access refs during render. +- [src/components/action-menu.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/components/action-menu.tsx): React Compiler has skipped optimizing this component because one or more React ESLint rules were disabled. +- [src/components/celebration.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/components/celebration.tsx): React Compiler has skipped optimizing this component because one or more React ESLint rules were disabled. +- [src/components/debate-form.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/components/debate-form.tsx): (BuildHIR::lowerStatement) Handle TryStatement with a finalizer ('finally') clause. +- [src/components/live-run.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/components/live-run.tsx): Cannot access refs during render. +- [src/components/outline.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/components/outline.tsx): React Compiler has skipped optimizing this component because one or more React ESLint rules were disabled; Existing memoization could not be preserved. +- [src/components/source-file-reader.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/components/source-file-reader.tsx): React Compiler has skipped optimizing this component because one or more React ESLint rules were disabled. +- [src/components/source-view.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/components/source-view.tsx): React Compiler has skipped optimizing this component because one or more React ESLint rules were disabled. +- [src/components/sparring-views.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/components/sparring-views.tsx): Support value blocks (conditional, logical, optional chaining, etc) within a try/catch statement. +- [src/components/success-check.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/components/success-check.tsx): React Compiler has skipped optimizing this component because one or more React ESLint rules were disabled. +- [src/components/toast.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/components/toast.tsx): (BuildHIR::lowerExpression) Support UpdateExpression where argument is a global. +- [src/lib/debate-context.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/debate-context.tsx): Support value blocks (conditional, logical, optional chaining, etc) within a try/catch statement. +- [src/lib/settings.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/settings.tsx): (BuildHIR::lowerStatement) Handle TryStatement without a catch clause. +- [src/lib/source-file.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/source-file.tsx): (BuildHIR::lowerExpression) Support UpdateExpression where argument is a global. + +After: + +- [src/app/sparring/index.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/sparring/index.tsx):187: Support value blocks (conditional, logical, optional chaining, etc) within a try/catch statement. The request bodies are outside the clock-only scope. +- [src/components/celebration.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/components/celebration.tsx):130: React Compiler has skipped optimizing this component because one or more React ESLint rules were disabled. The trigger effect is outside the CountUp-only scope. + +## Verification + +All checks used Node 22.23.2 with NODE_OPTIONS=--max-old-space-size=3072. Dependencies were already installed; npm ci was not run. + +- Mobile typecheck: passed. +- ESLint on every changed TypeScript file: passed with no warnings. +- Focused Vitest: 11 files, 68 tests passed. The paragraph test passed again after adding the empty-source case. +- Compiler audit: passed, with the two protected skips listed above. +- git diff --check: passed. + +The new early-copy cases failed before implementation, then passed. An additional slow-disk/fast-401 case reproduced delayed error delivery and passed after racing the disk and network answers. Intermediate typechecks found declaration ordering, closure narrowing and nullable-preload typing errors; those were corrected before the final pass. + +Exact final commands: + +```sh +export NVM_DIR=$HOME/.nvm +. $NVM_DIR/nvm.sh +nvm use 22 +export NODE_OPTIONS=--max-old-space-size=3072 +npm run -w @debatelab/mobile typecheck +node apps/mobile/scripts/compiler-audit.mjs +npx eslint \ + 'apps/mobile/src/app/(tabs)/(debate)/index.tsx' \ + 'apps/mobile/src/app/(tabs)/(library)/library.tsx' \ + 'apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx' \ + 'apps/mobile/src/app/(tabs)/(you)/you.tsx' \ + apps/mobile/src/app/_layout.tsx \ + apps/mobile/src/app/add-file.tsx \ + apps/mobile/src/app/add-link.tsx \ + apps/mobile/src/app/ask.tsx \ + apps/mobile/src/app/attack/index.tsx \ + apps/mobile/src/app/brainstorm/index.tsx \ + apps/mobile/src/app/context-review.tsx \ + apps/mobile/src/app/debate-check.tsx \ + apps/mobile/src/app/debates.tsx \ + 'apps/mobile/src/app/factoids/[id].tsx' \ + apps/mobile/src/app/factoids/index.tsx \ + apps/mobile/src/app/flowchart/index.tsx \ + apps/mobile/src/app/flowchart/maps.tsx \ + apps/mobile/src/app/generate.tsx \ + apps/mobile/src/app/history.tsx \ + apps/mobile/src/app/limit.tsx \ + apps/mobile/src/app/new-note.tsx \ + 'apps/mobile/src/app/source/[id].tsx' \ + apps/mobile/src/app/sparring/index.tsx \ + apps/mobile/src/app/welcome.tsx \ + apps/mobile/src/components/action-menu.tsx \ + apps/mobile/src/components/celebration.tsx \ + apps/mobile/src/components/debate-form.tsx \ + apps/mobile/src/components/list-screen.tsx \ + apps/mobile/src/components/live-run.tsx \ + apps/mobile/src/components/outline.tsx \ + apps/mobile/src/components/sheet.tsx \ + apps/mobile/src/components/source-file-reader.tsx \ + apps/mobile/src/components/source-view.tsx \ + apps/mobile/src/components/sparring-views.tsx \ + apps/mobile/src/components/success-check.tsx \ + apps/mobile/src/components/toast.tsx \ + apps/mobile/src/lib/dates.ts \ + apps/mobile/src/lib/debate-context.tsx \ + apps/mobile/src/lib/debates.ts \ + apps/mobile/src/lib/history.ts \ + apps/mobile/src/lib/i18n.ts \ + apps/mobile/src/lib/offline-cache.test.ts \ + apps/mobile/src/lib/offline-cache.ts \ + apps/mobile/src/lib/settings.tsx \ + apps/mobile/src/lib/source-file.tsx \ + apps/mobile/src/lib/sources.ts \ + apps/mobile/src/lib/i18n.test.ts \ + apps/mobile/src/lib/run-request.test.ts \ + apps/mobile/src/lib/run-request.ts \ + apps/mobile/src/lib/settings-cache.test.ts \ + apps/mobile/src/lib/settings-cache.ts \ + apps/mobile/src/lib/source-paragraphs.test.ts \ + apps/mobile/src/lib/source-paragraphs.ts +npx vitest run \ + apps/mobile/src/lib/offline-cache.test.ts \ + apps/mobile/src/lib/live-value.test.ts \ + apps/mobile/src/lib/sources.test.ts \ + apps/mobile/src/lib/settings-cache.test.ts \ + apps/mobile/src/lib/i18n.test.ts \ + apps/mobile/src/lib/source-paragraphs.test.ts \ + apps/mobile/src/lib/run-request.test.ts \ + apps/mobile/src/lib/ask.test.ts \ + apps/mobile/src/lib/results.test.ts \ + apps/mobile/src/lib/offline-queue.test.ts \ + packages/core/src/sparring/session.test.ts --maxWorkers=1 +npx vitest run apps/mobile/src/lib/source-paragraphs.test.ts --maxWorkers=1 +git diff --check +``` + +No iPhone, Expo Go, simulator, browser, bundle-size, startup-time or frame-rate proof was collected. No build, export, Metro, Expo, EAS, Playwright, Docker, Supabase stack, full verify, hosted check, push, PR, merge or rebase was run. + +## Changed files + +- [AGENTS.md](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/AGENTS.md) +- [scripts/compiler-audit.mjs](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/scripts/compiler-audit.mjs) +- [src/app/(tabs)/(debate)/index.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/(tabs)/(debate)/index.tsx) +- [src/app/(tabs)/(library)/library.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/(tabs)/(library)/library.tsx) +- [src/app/(tabs)/(prepare)/prepare.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx) +- [src/app/(tabs)/(you)/you.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/(tabs)/(you)/you.tsx) +- [src/app/_layout.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/_layout.tsx) +- [src/app/add-file.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/add-file.tsx) +- [src/app/add-link.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/add-link.tsx) +- [src/app/ask.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/ask.tsx) +- [src/app/attack/index.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/attack/index.tsx) +- [src/app/brainstorm/index.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/brainstorm/index.tsx) +- [src/app/context-review.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/context-review.tsx) +- [src/app/debate-check.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/debate-check.tsx) +- [src/app/debates.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/debates.tsx) +- [src/app/factoids/[id].tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/factoids/[id].tsx) +- [src/app/factoids/index.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/factoids/index.tsx) +- [src/app/flowchart/index.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/flowchart/index.tsx) +- [src/app/flowchart/maps.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/flowchart/maps.tsx) +- [src/app/generate.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/generate.tsx) +- [src/app/history.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/history.tsx) +- [src/app/limit.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/limit.tsx) +- [src/app/new-note.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/new-note.tsx) +- [src/app/source/[id].tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/source/[id].tsx) +- [src/app/sparring/index.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/sparring/index.tsx) +- [src/app/welcome.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/app/welcome.tsx) +- [src/components/action-menu.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/components/action-menu.tsx) +- [src/components/celebration.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/components/celebration.tsx) +- [src/components/debate-form.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/components/debate-form.tsx) +- [src/components/list-screen.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/components/list-screen.tsx) +- [src/components/live-run.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/components/live-run.tsx) +- [src/components/outline.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/components/outline.tsx) +- [src/components/sheet.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/components/sheet.tsx) +- [src/components/source-file-reader.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/components/source-file-reader.tsx) +- [src/components/source-view.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/components/source-view.tsx) +- [src/components/sparring-views.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/components/sparring-views.tsx) +- [src/components/success-check.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/components/success-check.tsx) +- [src/components/toast.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/components/toast.tsx) +- [src/lib/dates.ts](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/dates.ts) +- [src/lib/debate-context.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/debate-context.tsx) +- [src/lib/debates.ts](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/debates.ts) +- [src/lib/history.ts](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/history.ts) +- [src/lib/i18n.test.ts](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/i18n.test.ts) +- [src/lib/i18n.ts](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/i18n.ts) +- [src/lib/offline-cache.test.ts](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/offline-cache.test.ts) +- [src/lib/offline-cache.ts](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/offline-cache.ts) +- [src/lib/run-request.test.ts](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/run-request.test.ts) +- [src/lib/run-request.ts](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/run-request.ts) +- [src/lib/settings-cache.test.ts](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/settings-cache.test.ts) +- [src/lib/settings-cache.ts](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/settings-cache.ts) +- [src/lib/settings.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/settings.tsx) +- [src/lib/source-file.tsx](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/source-file.tsx) +- [src/lib/source-paragraphs.test.ts](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/source-paragraphs.test.ts) +- [src/lib/source-paragraphs.ts](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/source-paragraphs.ts) +- [src/lib/sources.ts](/home/codex/.t3/worktrees/DebateLab/t3-a50e262e/apps/mobile/src/lib/sources.ts) diff --git a/apps/mobile/scripts/compiler-audit.mjs b/apps/mobile/scripts/compiler-audit.mjs new file mode 100644 index 00000000..5189cb1b --- /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/src/app/(tabs)/(debate)/index.tsx b/apps/mobile/src/app/(tabs)/(debate)/index.tsx index 78772f15..cafcdd28 100644 --- a/apps/mobile/src/app/(tabs)/(debate)/index.tsx +++ b/apps/mobile/src/app/(tabs)/(debate)/index.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { Pressable, StyleSheet, View } from 'react-native'; import Animated from 'react-native-reanimated'; +import { runRequest } from '@/lib/run-request'; import { AnnouncementBanner } from '@/components/announcement-banner'; import { TrialBanner } from '@/components/trial-banner'; import { MainActionButton, TextButton } from '@/components/buttons'; @@ -21,6 +22,7 @@ import { WorkRow } from '@/components/work-row'; 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 { @@ -218,8 +220,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 +243,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 +269,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( diff --git a/apps/mobile/src/app/(tabs)/(library)/library.tsx b/apps/mobile/src/app/(tabs)/(library)/library.tsx index 31a074c7..998fa77b 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)/(prepare)/prepare.tsx b/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx index 5a2cde70..68bd4d98 100644 --- a/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx +++ b/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx @@ -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 })); diff --git a/apps/mobile/src/app/(tabs)/(you)/you.tsx b/apps/mobile/src/app/(tabs)/(you)/you.tsx index c815b29b..7b3d3fb3 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'; @@ -102,16 +103,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) => { @@ -392,16 +393,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. diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 6bfb31cb..0633d1ed 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -59,10 +59,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 +114,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} diff --git a/apps/mobile/src/app/add-file.tsx b/apps/mobile/src/app/add-file.tsx index b6b8177d..7bf2374a 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 2ce3d37e..d4291e46 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 ab44b88d..88e31f49 100644 --- a/apps/mobile/src/app/ask.tsx +++ b/apps/mobile/src/app/ask.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'; import { ActivityIndicator, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +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 +214,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 +252,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 +287,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 +296,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 +304,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 +351,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 +361,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 +416,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 +432,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 +449,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 +473,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'); diff --git a/apps/mobile/src/app/attack/index.tsx b/apps/mobile/src/app/attack/index.tsx index 4479c3d1..dc61d77c 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'; @@ -121,7 +122,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,12 +146,12 @@ 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 }); @@ -165,7 +166,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 +176,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 +194,7 @@ export default function AttackScreen() { { abortActive(); setPhase('form'); diff --git a/apps/mobile/src/app/brainstorm/index.tsx b/apps/mobile/src/app/brainstorm/index.tsx index 08344fec..e5c07641 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'; @@ -102,7 +103,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 +126,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({ @@ -155,7 +156,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 +167,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 +196,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 +231,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 ( diff --git a/apps/mobile/src/app/debate-check.tsx b/apps/mobile/src/app/debate-check.tsx index c3c1828e..7db22546 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 5630bd95..b9759f53 100644 --- a/apps/mobile/src/app/debates.tsx +++ b/apps/mobile/src/app/debates.tsx @@ -5,13 +5,15 @@ import { ActivityIndicator, Pressable, StyleSheet, Switch, View, type PressableP import ReanimatedSwipeable from 'react-native-gesture-handler/ReanimatedSwipeable'; import Animated from 'react-native-reanimated'; +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 +76,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 +98,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')} /> + + } + /> ); } @@ -276,7 +287,6 @@ function DebateRowPress({ children, ...props }: Omit { await addSource(forUser, { title: factoid.shortClaim, content: `${factoid.context}\n\nKey Figure: ${factoid.keyFigure}\nLimitations: ${factoid.limitations}`, @@ -104,12 +105,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; diff --git a/apps/mobile/src/app/factoids/index.tsx b/apps/mobile/src/app/factoids/index.tsx index d1c72767..c962de0f 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'; @@ -110,7 +112,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 +135,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({ @@ -163,7 +165,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 +175,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 +210,7 @@ export default function FactoidsScreen() { (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 +147,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 +174,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 +210,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 +225,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 +254,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 +262,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) => { diff --git a/apps/mobile/src/app/flowchart/maps.tsx b/apps/mobile/src/app/flowchart/maps.tsx index d53ca2be..e15bc4fd 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 309ca415..63c7ee93 100644 --- a/apps/mobile/src/app/generate.tsx +++ b/apps/mobile/src/app/generate.tsx @@ -2,10 +2,11 @@ import { generateDraftSections } from '@debatelab/core/ai/live-sections'; import 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(() => { @@ -122,7 +123,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 +146,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 +173,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 +184,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 +213,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 +248,7 @@ export default function GenerateScreen() { 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. @@ -183,13 +189,15 @@ 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; @@ -227,15 +235,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 +251,7 @@ export default function HistoryScreen() { return next; }); } - } + }); }; // The attach notice belongs to the list it was shown for. diff --git a/apps/mobile/src/app/limit.tsx b/apps/mobile/src/app/limit.tsx index dead9fd2..c79ea92c 100644 --- a/apps/mobile/src/app/limit.tsx +++ b/apps/mobile/src/app/limit.tsx @@ -5,6 +5,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { StyleSheet, View } from 'react-native'; +import { runRequest } from '@/lib/run-request'; import { PrimaryButton, TextButton } from '@/components/buttons'; import { Owl } from '@/components/owl'; import { SheetScrollView } from '@/components/sheet'; @@ -67,12 +68,12 @@ export default function LimitSheet() { const switchToLow = async () => { 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-note.tsx b/apps/mobile/src/app/new-note.tsx index 8ffa0e3d..7a2ebd75 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/source/[id].tsx b/apps/mobile/src/app/source/[id].tsx index e69ae023..852e7c7a 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 2ab012f4..3aae85ae 100644 --- a/apps/mobile/src/app/sparring/index.tsx +++ b/apps/mobile/src/app/sparring/index.tsx @@ -258,26 +258,30 @@ export default function SparringScreen() { } }, [commit, outputLanguage, 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()); @@ -629,8 +633,6 @@ function LiveRound({ const failure = request.status === 'failed' ? request.failure : null; const waiting = !failure && (judging || !userTurn); const total = turn?.timeLimitSeconds ?? 0; - const remaining = judging ? 0 : session.timerRemainingSeconds; - const warning = userTurn && remaining <= 30; const timeExpired = userTurn && (session.timerRemainingSeconds <= 0 || (session.timerDeadline !== null && session.timerDeadline <= Date.now())); const lastAi = [...session.speeches].reverse().find((speech) => speech.speaker === 'ai'); const turnLabel = turn ? t(`sparring.turns.${turn.id}`) : t('sparring.round.complete'); @@ -677,28 +679,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} @@ -787,9 +768,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 +788,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/welcome.tsx b/apps/mobile/src/app/welcome.tsx index 3e85d1ae..b3741818 100644 --- a/apps/mobile/src/app/welcome.tsx +++ b/apps/mobile/src/app/welcome.tsx @@ -90,7 +90,7 @@ function Intro({ onDone }: { onDone: (tourDone: boolean) => 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 90adbea1..a1e3e783 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/celebration.tsx b/apps/mobile/src/components/celebration.tsx index f9afcfad..6090bbfd 100644 --- a/apps/mobile/src/components/celebration.tsx +++ b/apps/mobile/src/components/celebration.tsx @@ -206,7 +206,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 +214,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/debate-form.tsx b/apps/mobile/src/components/debate-form.tsx index 8e952abc..c631796e 100644 --- a/apps/mobile/src/components/debate-form.tsx +++ b/apps/mobile/src/components/debate-form.tsx @@ -19,6 +19,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 +63,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 ( diff --git a/apps/mobile/src/components/list-screen.tsx b/apps/mobile/src/components/list-screen.tsx index 67bb56e9..5e193bde 100644 --- a/apps/mobile/src/components/list-screen.tsx +++ b/apps/mobile/src/components/list-screen.tsx @@ -15,6 +15,7 @@ import { useTheme } from '@/theme/use-theme'; type ListScreenProps = 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(); @@ -52,7 +53,7 @@ export function ListScreen({ header, safeTop, wide, contentStyle, animateLayo refreshControl={refresh ? : undefined} {...list} ListHeaderComponent={ - + {header} diff --git a/apps/mobile/src/components/live-run.tsx b/apps/mobile/src/components/live-run.tsx index 4555e93c..37cbeeb1 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,15 +210,17 @@ 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'; + 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 ( diff --git a/apps/mobile/src/components/outline.tsx b/apps/mobile/src/components/outline.tsx index 22423c9e..4a12c40e 100644 --- a/apps/mobile/src/components/outline.tsx +++ b/apps/mobile/src/components/outline.tsx @@ -1,8 +1,9 @@ -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 { Text } from './text'; +import { useLatest } from '@/lib/live-value'; import { useTheme } from '@/theme/use-theme'; @@ -43,16 +44,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 +68,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 +87,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 +96,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 +119,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 }) { diff --git a/apps/mobile/src/components/sheet.tsx b/apps/mobile/src/components/sheet.tsx index 788e8819..4fc4ecc8 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/source-file-reader.tsx b/apps/mobile/src/components/source-file-reader.tsx index 0b50beab..8be34fea 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-view.tsx b/apps/mobile/src/components/source-view.tsx index 9e8b3ce0..804b9567 100644 --- a/apps/mobile/src/components/source-view.tsx +++ b/apps/mobile/src/components/source-view.tsx @@ -1,12 +1,12 @@ 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, Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native'; 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 +14,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 +42,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 +56,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 +74,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 +147,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 +165,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 = ( <> @@ -306,12 +318,32 @@ 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" @@ -320,14 +352,17 @@ export function SourceView({ id, onDone, onRemoved }: { id: string; onDone?: () {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 }, diff --git a/apps/mobile/src/components/sparring-views.tsx b/apps/mobile/src/components/sparring-views.tsx index a8798789..28700295 100644 --- a/apps/mobile/src/components/sparring-views.tsx +++ b/apps/mobile/src/components/sparring-views.tsx @@ -14,6 +14,7 @@ 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'; @@ -92,22 +93,22 @@ 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 () => { - try { + return await runRequest(async () => { await sharePdf(sparringExportHtml(session, t), sparringExportFileName(session), winnerText(t, session.scorecard.winner)); - } catch (cause) { + }, async (cause) => { console.warn('Could not export the round:', cause instanceof Error ? cause.message : cause); if (live.current) setFailed(t('mobile.sparring.exportFailed')); - } + }); }; return ( diff --git a/apps/mobile/src/components/success-check.tsx b/apps/mobile/src/components/success-check.tsx index eee1557d..de277be5 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/toast.tsx b/apps/mobile/src/components/toast.tsx index ed3c19c9..e46cf5cd 100644 --- a/apps/mobile/src/components/toast.tsx +++ b/apps/mobile/src/components/toast.tsx @@ -41,6 +41,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 +58,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(() => { diff --git a/apps/mobile/src/lib/dates.ts b/apps/mobile/src/lib/dates.ts index 98ea19c9..fccba7e7 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-context.tsx b/apps/mobile/src/lib/debate-context.tsx index 23f8d3e3..d8a6b9b0 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'; @@ -65,19 +66,26 @@ export function DebateProvider({ children }: { children: ReactNode }) { 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; + return await runRequest(async () => { + const gen = generation.current; + const show = (next: Awaited>) => { + if (generation.current !== gen || owner.current !== forUser || refreshes.current !== request || writes.current !== writesBefore) return; + setDebates((previous) => keepIfSame(previous, next.debates)); + setCurrentId(next.currentId); + setStatus('ready'); + }; + const next = await fetchDebates(forUser, show); + if (generation.current !== gen || owner.current !== forUser || refreshes.current !== request || writes.current !== writesBefore) 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) { + }, async (cause) => { if (owner.current !== forUser || refreshes.current !== request) 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')); - } + // A copy painted during refresh must not hide a server refusal. + setStatus('error'); + }); }, []); useEffect(() => { diff --git a/apps/mobile/src/lib/debates.ts b/apps/mobile/src/lib/debates.ts index 29261ad5..6be10bb6 100644 --- a/apps/mobile/src/lib/debates.ts +++ b/apps/mobile/src/lib/debates.ts @@ -108,8 +108,8 @@ 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); } async function loadDebates(userId: string): Promise { @@ -274,8 +274,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 +325,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 +404,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/history.ts b/apps/mobile/src/lib/history.ts index 48309c12..f5915242 100644 --- a/apps/mobile/src/lib/history.ts +++ b/apps/mobile/src/lib/history.ts @@ -144,8 +144,8 @@ 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); +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 { @@ -162,8 +162,8 @@ async function loadDebateHistory(userId: string, debateId: string): 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 00000000..be874ca5 --- /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 3fe5c8a7..4e24c528 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 9bda6a68..b14e6945 100644 --- a/apps/mobile/src/lib/offline-cache.test.ts +++ b/apps/mobile/src/lib/offline-cache.test.ts @@ -29,7 +29,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; }); @@ -358,3 +358,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 3932b14a..836cf99f 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,16 +317,18 @@ 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 { 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; @@ -329,12 +336,34 @@ export async function cachedRead(userId: string, group: CacheGroup, key: stri } 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; } 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 00000000..3b4e2d77 --- /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 00000000..2285a1af --- /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 00000000..5fdd3559 --- /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 00000000..6e441c12 --- /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 71135c94..4f6cb4aa 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/source-file.tsx b/apps/mobile/src/lib/source-file.tsx index ce10433f..a2fb35a0 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 00000000..ee087b8d --- /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 00000000..e2670a71 --- /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 5904e7f9..0f062503 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 { From af57794da65a9bab2eb161e2c11198620ce87079 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 29 Oct 2026 20:13:50 +0000 Subject: [PATCH 07/14] Keep the saved Crossfire round and the flow sheet compiled Their new requests ran inside try blocks the React Compiler skips; they go through runRequest like the rest of the app. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/crossfire-round/[id].tsx | 15 ++++++----- apps/mobile/src/app/flow-sheet.tsx | 26 ++++++++++---------- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/apps/mobile/src/app/crossfire-round/[id].tsx b/apps/mobile/src/app/crossfire-round/[id].tsx index da6c541d..7298d20f 100644 --- a/apps/mobile/src/app/crossfire-round/[id].tsx +++ b/apps/mobile/src/app/crossfire-round/[id].tsx @@ -17,6 +17,7 @@ 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 }; @@ -55,12 +56,14 @@ export default function SavedCrossfireScreen() { cancel: t('common.cancel'), }); if (!confirmed || !live.current || owner.current !== forUser || roundId.current !== id) return; - try { - await deleteCrossfireRound(forUser, id); - if (live.current && owner.current === forUser && roundId.current === id) router.back(); - } catch { - if (live.current && owner.current === forUser && roundId.current === id) showNotice(t('historyPage.crossfireDeleteFailed')); - } + 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(() => { diff --git a/apps/mobile/src/app/flow-sheet.tsx b/apps/mobile/src/app/flow-sheet.tsx index a3bd0016..562d6dc4 100644 --- a/apps/mobile/src/app/flow-sheet.tsx +++ b/apps/mobile/src/app/flow-sheet.tsx @@ -14,6 +14,7 @@ import { showNotice } from '@/lib/confirm'; import { sharePdf } from '@/lib/export'; import { flowSheetExportHtml } from '@/lib/export-html'; import { useLayoutClass } from '@/lib/layout'; +import { runRequest } from '@/lib/run-request'; /** The website's flow sheet controls, with the result sent to the PDF share sheet. */ export default function FlowSheetScreen() { @@ -32,19 +33,18 @@ export default function FlowSheetScreen() { const exportPdf = async () => { if (exporting) return; setExporting(true); - try { - const [short, long] = paper === 'a4' ? [595.28, 841.89] : [612, 792]; - await sharePdf( - flowSheetExportHtml({ format, paper, orientation, strip, ...header }, tools), - `DebateFlowSheet_${formatId}.pdf`, - tools('flow.region'), - orientation === 'landscape' ? { width: long, height: short } : { width: short, height: long }, - ); - } catch { - showNotice(t('mobile.flowSheet.exportFailed')); - } finally { - setExporting(false); - } + 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 ( From b14b33238073a8b1d1ea83a0c59ebe7462f0087a Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 29 Oct 2026 20:17:28 +0000 Subject: [PATCH 08/14] Load the language picked on the sign-in screen The sign-in menu called i18next directly, so a language whose catalog was not loaded yet fell back to English; it now goes through changeAppLanguage. VoiceOver hears what the button chooses, and the screenshot script presses Send code by name now that the menu comes first. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/scripts/screenshots.ts | 3 ++- apps/mobile/src/app/sign-in.tsx | 10 ++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index 891da9c8..5434b4b4 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -2768,7 +2768,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/sign-in.tsx b/apps/mobile/src/app/sign-in.tsx index 5e28c304..8ced6c91 100644 --- a/apps/mobile/src/app/sign-in.tsx +++ b/apps/mobile/src/app/sign-in.tsx @@ -12,6 +12,7 @@ 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'; @@ -40,6 +41,7 @@ export default function SignInScreen() { 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(''); @@ -144,7 +146,11 @@ export default function SignInScreen() { ]}> - setLanguageMenu(true)} /> + 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 i18n.changeLanguage(language); }} + onSelect={(language) => { if (isAppLanguage(language)) void changeAppLanguage(language); }} /> From de4a202410489320508bb592724c96f96277fc77 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 29 Oct 2026 20:23:05 +0000 Subject: [PATCH 09/14] Keep list headers above their rows; shoot the new celebrations A row's entrance animation drew over the Library's web menu, hiding its last items. The screenshot script now shoots and dismisses the celebrations Attack, Brainstorm and Factoids results open with. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/scripts/screenshots.ts | 4 +++- apps/mobile/src/components/list-screen.tsx | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index 5434b4b4..67f7cfeb 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) { @@ -801,6 +801,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 +858,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. diff --git a/apps/mobile/src/components/list-screen.tsx b/apps/mobile/src/components/list-screen.tsx index 5e193bde..84dffc25 100644 --- a/apps/mobile/src/components/list-screen.tsx +++ b/apps/mobile/src/components/list-screen.tsx @@ -52,6 +52,8 @@ export function ListScreen({ header, gap = 16, safeTop, wide, contentStyle, a 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={ @@ -97,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 }, From db4554b5c81fd4a9c9064bde2fcb3d4ad7a46913 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 29 Oct 2026 20:24:45 +0000 Subject: [PATCH 10/14] Ignore a debates refresh that a newer switch has overtaken A refresh that failed after a newer switch or create still set the Debate tab to its error state, and any failed refresh replaced a list the server had already given. The failure now obeys the same guards as the answer and only shows the error when this refresh had painted the saved copy. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/lib/debate-context.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/lib/debate-context.tsx b/apps/mobile/src/lib/debate-context.tsx index d8a6b9b0..e890e175 100644 --- a/apps/mobile/src/lib/debate-context.tsx +++ b/apps/mobile/src/lib/debate-context.tsx @@ -66,25 +66,30 @@ export function DebateProvider({ children }: { children: ReactNode }) { if (!forUser) return; const writesBefore = writes.current; const request = ++refreshes.current; + const gen = generation.current; + const current = () => generation.current === gen && owner.current === forUser && refreshes.current === request && writes.current === writesBefore; + // Whether this request painted the saved copy, which a failed refresh must not leave on screen. + let paintedCopy = false; return await runRequest(async () => { - const gen = generation.current; const show = (next: Awaited>) => { - if (generation.current !== gen || owner.current !== forUser || refreshes.current !== request || writes.current !== writesBefore) return; + if (!current()) return; + paintedCopy = true; setDebates((previous) => keepIfSame(previous, next.debates)); setCurrentId(next.currentId); setStatus('ready'); }; const next = await fetchDebates(forUser, show); - if (generation.current !== gen || owner.current !== forUser || refreshes.current !== request || writes.current !== writesBefore) return; + 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'); }, async (cause) => { - if (owner.current !== forUser || refreshes.current !== request) return; + // 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); - // A copy painted during refresh must not hide a server refusal. - setStatus('error'); + // Keep a list the server already gave; a copy painted by this refresh must not hide its refusal. + setStatus((previous) => (previous === 'ready' && !paintedCopy ? previous : 'error')); }); }, []); From 0b89ddedc53481463bf6f38ab927b28ddf2f915a Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 29 Oct 2026 20:26:00 +0000 Subject: [PATCH 11/14] Ask the five-day question only from the server's list With saved copies painted first, the check could open from a copy whose last activity was days older than the student's latest work on the website. The debates provider now says whether its list is fresh, and the check waits for that. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/lib/debate-context.tsx | 9 ++++++++- apps/mobile/src/lib/use-inactive-debate-check.ts | 7 ++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/lib/debate-context.tsx b/apps/mobile/src/lib/debate-context.tsx index e890e175..5a56f190 100644 --- a/apps/mobile/src/lib/debate-context.tsx +++ b/apps/mobile/src/lib/debate-context.tsx @@ -22,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. */ @@ -48,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); @@ -74,6 +77,7 @@ export function DebateProvider({ children }: { children: ReactNode }) { const show = (next: Awaited>) => { if (!current()) return; paintedCopy = true; + setFresh(false); setDebates((previous) => keepIfSame(previous, next.debates)); setCurrentId(next.currentId); setStatus('ready'); @@ -84,6 +88,7 @@ export function DebateProvider({ children }: { children: ReactNode }) { setDebates((previous) => keepIfSame(previous, next.debates)); setCurrentId(next.currentId); setStatus('ready'); + setFresh(true); }, async (cause) => { // A switch or create since this refresh began already holds newer state. if (!current()) return; @@ -100,6 +105,7 @@ export function DebateProvider({ children }: { children: ReactNode }) { setCurrentId(null); setOneOff(false); setStatus('loading'); + setFresh(false); if (!userId) return; void refresh(); // Pick up debates started or switched on the website while the app was in the background. @@ -130,6 +136,7 @@ export function DebateProvider({ children }: { children: ReactNode }) { const current = debates.find((debate) => debate.id === currentId) ?? null; return { status, + fresh, debates, current, oneOff, @@ -203,7 +210,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/use-inactive-debate-check.ts b/apps/mobile/src/lib/use-inactive-debate-check.ts index c6a9347f..fb25bbe8 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]); } From f1a5677e35f7fe2e79575c5066d78281fd87c8b2 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 29 Oct 2026 20:43:14 +0000 Subject: [PATCH 12/14] Bring the screenshot script up to date Dismiss a judged voice round's celebration before its scorecard, and check the iPad outline against a part the case still has. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/scripts/screenshots.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index 67f7cfeb..a4b91ecb 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -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. @@ -1747,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); From 2c981749f727e8d496ec0ca756ab0c6c1e6227b9 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 29 Oct 2026 20:46:16 +0000 Subject: [PATCH 13/14] Spanish says Ideas too Co-Authored-By: Claude Opus 5.5 --- src/test/i18n-untranslated.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/i18n-untranslated.test.ts b/src/test/i18n-untranslated.test.ts index b6e396c2..4fd431da 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', From 58825f39418370bfc18639fa83a5b0342ae6bb3f Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 29 Oct 2026 20:49:43 +0000 Subject: [PATCH 14/14] Tell a saved debates list from the server's An offline or unreachable read answered with the saved copy looked like a server answer, so the five-day question could still open from it; and a failed refresh kept a copy another refresh had painted. cachedReadFrom says where its answer came from, and the debates provider tracks whether the list on screen is a copy. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/lib/debate-context.tsx | 20 +++++++++++--------- apps/mobile/src/lib/debates.ts | 7 ++++++- apps/mobile/src/lib/offline-cache.test.ts | 8 ++++++++ apps/mobile/src/lib/offline-cache.ts | 11 ++++++++--- 4 files changed, 33 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/lib/debate-context.tsx b/apps/mobile/src/lib/debate-context.tsx index 5a56f190..bf6e344e 100644 --- a/apps/mobile/src/lib/debate-context.tsx +++ b/apps/mobile/src/lib/debate-context.tsx @@ -9,7 +9,7 @@ import { continueDebate, createDebate, deleteDebate, - fetchDebates, + fetchDebatesFrom, sortDebates, switchDebate, updateDebate, @@ -63,6 +63,8 @@ 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; @@ -71,30 +73,29 @@ export function DebateProvider({ children }: { children: ReactNode }) { const request = ++refreshes.current; const gen = generation.current; const current = () => generation.current === gen && owner.current === forUser && refreshes.current === request && writes.current === writesBefore; - // Whether this request painted the saved copy, which a failed refresh must not leave on screen. - let paintedCopy = false; return await runRequest(async () => { - const show = (next: Awaited>) => { + const show = (next: Awaited>['value']) => { if (!current()) return; - paintedCopy = true; + showingCopy.current = true; setFresh(false); setDebates((previous) => keepIfSame(previous, next.debates)); setCurrentId(next.currentId); setStatus('ready'); }; - const next = await fetchDebates(forUser, show); + 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'); - setFresh(true); + 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 a list the server already gave; a copy painted by this refresh must not hide its refusal. - setStatus((previous) => (previous === 'ready' && !paintedCopy ? 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')); }); }, []); @@ -106,6 +107,7 @@ export function DebateProvider({ children }: { children: ReactNode }) { 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. diff --git a/apps/mobile/src/lib/debates.ts b/apps/mobile/src/lib/debates.ts index 6be10bb6..7fcc5748 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'; /** @@ -112,6 +112,11 @@ export function fetchDebates(userId: string, onCopy?: (value: DebateList) => voi 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 { const [debatesResult, currentResult] = await Promise.all([ supabase.from('debates').select(DEBATE_COLUMNS).eq('user_id', userId).order('last_activity_at', { ascending: false }), diff --git a/apps/mobile/src/lib/offline-cache.test.ts b/apps/mobile/src/lib/offline-cache.test.ts index 738bb3a2..4beb0343 100644 --- a/apps/mobile/src/lib/offline-cache.test.ts +++ b/apps/mobile/src/lib/offline-cache.test.ts @@ -184,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'); diff --git a/apps/mobile/src/lib/offline-cache.ts b/apps/mobile/src/lib/offline-cache.ts index 836cf99f..a95c3ddf 100644 --- a/apps/mobile/src/lib/offline-cache.ts +++ b/apps/mobile/src/lib/offline-cache.ts @@ -322,6 +322,11 @@ function noteShown(storageKey: string, group: CacheGroup, fromCopy: boolean, mar * 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, 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); @@ -331,7 +336,7 @@ export async function cachedRead(userId: string, group: CacheGroup, key: stri 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; @@ -365,11 +370,11 @@ export async function cachedRead(userId: string, group: CacheGroup, key: stri const copy = await readCopy(userId, group, key).catch(() => undefined); 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 }; } /**