diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 40c7d579..ebcd1f65 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -20,6 +20,9 @@ The iOS app DebateOwl (`@debatelab/mobile`; bundle id `com.pistack.debateowl`, E - `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; the form sheets `debates.tsx` (switcher), `new-debate.tsx`, `delete-account.tsx`, `delete-data.tsx`, `add-link.tsx`, `new-note.tsx`, `add-file.tsx` (Import file), `source/[id].tsx`, `edit-debate.tsx` (Edit settings), `debate-check.tsx` (the five-day check), `limit.tsx` (the website's limit-reached and model-restricted dialogs), `save-case.tsx` (Save case with a title), `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. - `src/lib/`: auth (`auth.tsx`), `native-capabilities.ts` (what the running binary has beyond Expo Go), the Supabase client, session storage (AES key in SecureStore, ciphertext in AsyncStorage) and i18n. `debates.ts`, `settings.tsx`, `subscription.ts` and `account.ts` read and write the website's tables the way its stores do (`src/lib/stores/debate-store.ts`, `settings-store.ts`, `src/hooks/use-subscription.ts`, `src/lib/account.ts`), so the app and the website share one Current Debate and one set of settings. Change them together with the website code they mirror. - `sources.ts` mirrors the session store's source actions (`addSource` with Undo's restore, `updateSource`, `removeSource`, `toggleSource` and `setFolderActive` as `setSourcesActive`, the folder actions and `clearLibrary`) and `src/lib/source-import.ts`. Switch changes run in one serial queue, so they reach the server in the order the student made them; a screen shows a change at once and puts it back when the write fails (`source-actions.ts`, with the website's messages). The Library always shows the Current Debate, also in one-off mode, as on the website. Its folder list, search and eight sort orders are the website's, in `packages/core/src/library.ts`. Scan pages is not in the app yet. - File import runs the website's reader (`packages/core/src/source-file-import.ts`, with its 8 MB and 120,000-character limits) inside an Expo DOM component, `components/source-file-reader.tsx`, because pdf.js and mammoth need a browser. `pick-source-file.ts` opens the Files picker, `source-file.tsx` checks size (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. @@ -50,8 +53,9 @@ The iOS app DebateOwl (`@debatelab/mobile`; bundle id `com.pistack.debateowl`, E - iPad layouts (iPad mock A, with mock C's docked claim) come from one hook, `useLayoutClass()` in `src/lib/layout.ts`, which also holds every width they use: `phone` on every iPhone and in iPad windows under 700 points, `regular` from 700, `wide` from 1100. Phone layouts must not change, so each screen keeps its phone JSX and branches before it; compare iPhone screenshots before and after. A full-screen iPad window from the regular width shows the app's own sidebar (`components/app-sidebar.tsx`, rows in `sidebar-items.ts`) beside the root stack, so it stays while a tool is open: the Current Debate switcher, then every tool under collapsible Debate, Prepare, Practice, Library and You sections (closed sections are kept on the device). NativeTabs hides its bar there (`hidden`), and `app-tabs.web.tsx` hides the preview's; Split View, Slide Over, Stage Manager windows and widths under 700 keep the tab bar (`sidebarWidthFor`). A sidebar row closes the screens above the tabs before it opens its own, so the sidebar never stacks tools. Screens lay out for the room beside the sidebar: `useLayoutClass()` and `useContentWidth()` subtract `ReservedWidth`, which sheets reset to 0; never size a screen from `useWindowDimensions()` directly. The Prepare and Practice tab pages are not in the sidebar: the Debate dashboard and History cover their lists. The Library opens a source beside its list (`SourceView`, shared with the source sheet), Ask lists its conversations beside the chat (`ask-chat-list.tsx`), the Debate tab is a two-column dashboard, saved documents get an "On this page" rail from 900 points (`DocumentScreen`'s `outline`, `components/outline.tsx`), and the Flowchart docks a card's claim beside the map (`ClaimPanel` in `claim-panel.tsx`, shared with the claim sheet). A docked claim or a source open beside its list is dropped when that map, debate, account or row is no longer the one on screen. All are plain views sized from the window, so they run in Expo Go. - Share links (`share-links.ts`, `use-share-link.ts`): a saved case, attack, brainstorm or factoids document's menu calls the `share` Edge Function with the session and reuses a link made this session for the same unchanged result, because every call makes a new link and counts toward the daily limit. With no browser Origin the function builds debateowl.com links, so links made against staging open production's site. `share/[code].tsx` reads a link back with the public `get_shared_item` RPC. Universal links (`deep-links.ts`, a tested parser that honours only https debateowl.com and www) need the Associated Domains entitlement, which `app.config.ts` adds to development builds, and the website's apple-app-site-association file, which is not deployed. - The first run (`first-run.ts`, `use-first-run.ts`, `welcome.tsx`) uses the website's keys: Ollie's welcome once per phone, the four-step tour once per account. The web preview opens it only with `?intro=show` (`first-run.web.ts`). `components/trial-banner.tsx` is the website's trial reminder (days left from `packages/core/src/trial.ts`); `auth.tsx`'s `sessionExpired` is set when the server ends a session (a refused refresh), never for a sign-out the student asked for, and the sign-in screen shows the website's notice. -- 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. iPad shortcuts (⌘N, ⌘K, ⌘↩) go through `key-commands.ts`, which works in the web preview only until a UIKeyCommand module is in the development build; 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`) and Spotlight (`spotlight.ts`) switch on only when `expo-quick-actions` or `expo-core-spotlight` is installed; neither is yet. `app.config.ts`'s round-4 section adds their native config for development builds only. +- 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. - `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. @@ -69,4 +73,4 @@ ONLY=offline PORT=18080 STAGING_DEMO_CODE=... npx tsx apps/mobile/scripts/screen Screenshots go to `.verify/mobile-shots/`. For signed-in screens, see the header of `scripts/screenshots.ts`. To load the app on a phone, see [docs/development.md](../../docs/development.md#mobile-app). -knip ignores `expo-updates`: its Expo plugin expects the package whenever the app config does not disable updates, and EAS Update is not set up yet. It also ignores `expo-speech-recognition` and `expo-widgets`, which the app loads with `require` only in a build that has them, and `@expo/dom-webview`, which Expo's DOM components load by name. +knip ignores `expo-updates`: its Expo plugin expects the package whenever the app config does not disable updates, and EAS Update is not set up yet. It also ignores `expo-speech-recognition`, `expo-widgets`, `expo-quick-actions`, `expo-core-spotlight` and `expo-share-intent`, which the app reaches only by `require` or native module name in a build that has them, and `@expo/dom-webview`, which Expo's DOM components load by name. diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 895c28c1..bc5554cc 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -10,9 +10,10 @@ import { type ConfigPlugin, withEntitlementsPlist } from 'expo/config-plugins'; // 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 -// Activity, Dynamic Island clock and lock-screen widget), which needs the App -// Group `group.com.pistack.debateowl`, registered for both -// `com.pistack.debateowl` and `com.pistack.debateowl.ExpoWidgetsTarget`. On a +// 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 +// `com.pistack.debateowl.share-extension`. On a // device, set it only when the provisioning profile carries that group; a // simulator build is not signed and always can. // See docs/development.md#development-build. @@ -256,6 +257,25 @@ export default ({ projectRoot }: ConfigContext): ExpoConfig => { }, ]); } + if (appGroup) { + // The share extension: Safari and other apps share a web page or text into DebateOwl + // (src/lib/share-intent.ts). Its bundle id is `${bundleIdentifier}.share-extension`, which + // needs the same App Group registered on the Apple account. Text and pages only; files stay + // with Import file. + config.plugins!.push([ + 'expo-share-intent', + { + iosActivationRules: { + NSExtensionActivationSupportsWebURLWithMaxCount: 1, + NSExtensionActivationSupportsWebPageWithMaxCount: 1, + NSExtensionActivationSupportsText: true, + }, + iosAppGroupIdentifier: APP_GROUP, + iosShareExtensionName: 'DebateOwl Share', + disableAndroid: true, + }, + ]); + } // The Current Debate widget (with the App Group), quick actions and Spotlight; see withRound4 above. withRound4(config, { appGroup, projectRoot }); // Registered before Expo applies the plugins list. Mods registered later act diff --git a/apps/mobile/modules/key-commands/expo-module.config.json b/apps/mobile/modules/key-commands/expo-module.config.json new file mode 100644 index 00000000..e7c0303d --- /dev/null +++ b/apps/mobile/modules/key-commands/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["apple"], + "apple": { + "modules": ["KeyCommandsModule"] + } +} diff --git a/apps/mobile/modules/key-commands/ios/KeyCommands.podspec b/apps/mobile/modules/key-commands/ios/KeyCommands.podspec new file mode 100644 index 00000000..62f34e49 --- /dev/null +++ b/apps/mobile/modules/key-commands/ios/KeyCommands.podspec @@ -0,0 +1,19 @@ +Pod::Spec.new do |s| + s.name = 'KeyCommands' + s.version = '1.0.0' + s.summary = "DebateOwl's iPad hardware-keyboard shortcuts" + s.description = 'UIKeyCommand shortcuts (⌘N, ⌘K, ⌘↩) for the DebateOwl app, set from JavaScript.' + s.author = '' + s.homepage = 'https://debateowl.com' + s.platforms = { :ios => '16.4' } + s.source = { git: '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + } + + s.source_files = "**/*.{h,m,mm,swift}" +end diff --git a/apps/mobile/modules/key-commands/ios/KeyCommandsModule.swift b/apps/mobile/modules/key-commands/ios/KeyCommandsModule.swift new file mode 100644 index 00000000..72957ad2 --- /dev/null +++ b/apps/mobile/modules/key-commands/ios/KeyCommandsModule.swift @@ -0,0 +1,99 @@ +import ExpoModulesCore +import ObjectiveC +import UIKit + +// iPad hardware-keyboard shortcuts for src/lib/key-commands.ts. JavaScript sets +// the commands it listens for (an id, the key's input and the title the ⌘ overlay +// shows); a press sends `onKeyCommand` with the id. +// +// The commands hang off UIApplication, near the end of the responder chain, so +// they work wherever the student is: in a text field or not, on a screen or in +// a sheet. UIApplication's `keyCommands` getter is replaced once with one that +// adds ours to whatever it returned before (React Native's own dev shortcuts). + +public class KeyCommandsModule: Module { + public func definition() -> ModuleDefinition { + Name("DebateOwlKeyCommands") + + Events("onKeyCommand") + + OnCreate { + KeyCommandCenter.shared.onCommand = { [weak self] id in + self?.sendEvent("onKeyCommand", ["id": id]) + } + DispatchQueue.main.async { KeyCommandCenter.install() } + } + + OnDestroy { + KeyCommandCenter.shared.onCommand = nil + } + + Function("setCommands") { (commands: [[String: Any]]) in + DispatchQueue.main.async { KeyCommandCenter.shared.set(commands) } + } + } +} + +final class KeyCommandCenter { + static let shared = KeyCommandCenter() + private static var installed = false + + var onCommand: ((String) -> Void)? + private(set) var commands: [UIKeyCommand] = [] + private var ids: [String: String] = [:] + + private static func key(_ input: String?, _ flags: UIKeyModifierFlags) -> String { + "\(input ?? ""):\(flags.rawValue)" + } + + func set(_ list: [[String: Any]]) { + var next: [UIKeyCommand] = [] + var nextIds: [String: String] = [:] + for entry in list { + guard let id = entry["id"] as? String, let input = entry["input"] as? String, !input.isEmpty else { continue } + let title = entry["title"] as? String ?? "" + let command = UIKeyCommand( + title: title, + action: #selector(UIApplication.debateOwlKeyCommand(_:)), + input: input, + modifierFlags: .command + ) + command.discoverabilityTitle = title + command.wantsPriorityOverSystemBehavior = true + next.append(command) + nextIds[Self.key(input, .command)] = id + } + commands = next + ids = nextIds + } + + func handle(_ command: UIKeyCommand) { + guard let id = ids[Self.key(command.input, command.modifierFlags)] else { return } + onCommand?(id) + } + + static func install() { + guard !installed else { return } + installed = true + let cls: AnyClass = UIApplication.self + let selector = #selector(getter: UIResponder.keyCommands) + guard let method = class_getInstanceMethod(cls, selector) else { return } + typealias Getter = @convention(c) (AnyObject, Selector) -> NSArray? + let original = unsafeBitCast(method_getImplementation(method), to: Getter.self) + let block: @convention(block) (AnyObject) -> NSArray? = { receiver in + let earlier = (original(receiver, selector) as? [UIKeyCommand]) ?? [] + return (earlier + KeyCommandCenter.shared.commands) as NSArray + } + let replacement = imp_implementationWithBlock(block) + // UIApplication inherits the getter from UIResponder unless something already overrides it. + if !class_addMethod(cls, selector, replacement, method_getTypeEncoding(method)) { + method_setImplementation(method, replacement) + } + } +} + +extension UIApplication { + @objc func debateOwlKeyCommand(_ command: UIKeyCommand) { + KeyCommandCenter.shared.handle(command) + } +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index ca6152dc..25a3ba8a 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -26,6 +26,7 @@ "expo-audio": "~57.0.5", "expo-clipboard": "~57.0.2", "expo-constants": "~57.0.21", + "expo-core-spotlight": "~2.0.2", "expo-crypto": "~57.0.3", "expo-dev-client": "~57.0.20", "expo-document-picker": "~57.0.3", @@ -38,8 +39,10 @@ "expo-network": "~57.0.2", "expo-notifications": "~57.0.22", "expo-print": "~57.0.2", + "expo-quick-actions": "~6.0.2", "expo-router": "~57.0.25", "expo-secure-store": "~57.0.4", + "expo-share-intent": "~8.0.1", "expo-sharing": "~57.0.22", "expo-speech": "~57.0.3", "expo-speech-recognition": "^57.1.1", diff --git a/apps/mobile/src/app/(tabs)/(debate)/index.tsx b/apps/mobile/src/app/(tabs)/(debate)/index.tsx index 7db1f34a..78772f15 100644 --- a/apps/mobile/src/app/(tabs)/(debate)/index.tsx +++ b/apps/mobile/src/app/(tabs)/(debate)/index.tsx @@ -1,5 +1,5 @@ import { Link, useFocusEffect, useRouter } from 'expo-router'; -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Pressable, StyleSheet, View } from 'react-native'; import Animated from 'react-native-reanimated'; @@ -19,11 +19,13 @@ import { SOURCE_ICONS, sourceMeta } from '@/components/source-display'; import { Text } from '@/components/text'; import { WorkRow } from '@/components/work-row'; import { publishCurrentDebate } from '@/lib/debate-widget'; -import { indexForSpotlight } from '@/lib/spotlight'; +import { indexForSpotlight, spotlightAvailable } from '@/lib/spotlight'; +import { SPOTLIGHT_LIMIT, spotlightWorkItems } from '@/lib/spotlight-items'; import { useAuth } from '@/lib/auth'; import { useDebates } from '@/lib/debate-context'; import { fetchDebateCounts, + fetchDocumentsForSpotlight, fetchRecentWork, ROLE_LABEL_KEYS, type Debate, @@ -218,6 +220,15 @@ function DebateDetails({ debateId, dashboard = false }: { debateId: string; dash const { current } = useDebates(); const currentRef = useRef(current); currentRef.current = 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); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); const [view, setView] = useState('pro'); const [counts, setCounts] = useState(null); const [recent, setRecent] = useState | null>(null); @@ -243,9 +254,16 @@ function DebateDetails({ debateId, dashboard = false }: { debateId: string; dash const shown = currentRef.current; if (shown?.id === debateId) { publishCurrentDebate({ debateId, motion: shown.motion, counts: nextCounts }); - // Spotlight finds this debate's cases (development build with expo-core-spotlight; a no-op otherwise). - const cases = [...pro, ...con].filter((item, index, all) => item.kind === 'case' && all.findIndex((other) => other.id === item.id) === index); - void indexForSpotlight('cases', cases.map((item) => ({ url: `debatelab://case/${item.id}` as const, title: item.title, description: shown.motion }))); + // Spotlight finds this debate's documents of every side (development build with expo-core-spotlight only). + if (spotlightAvailable()) { + const motion = shown.motion; + fetchDocumentsForSpotlight(userId, debateId, SPOTLIGHT_LIMIT) + .then((documents) => { + if (!mounted.current || !isCurrent() || currentRef.current?.id !== debateId) return; + void indexForSpotlight('work', spotlightWorkItems(documents, motion, (kind) => t(`debate.kinds.${kind}`))); + }) + .catch((cause: unknown) => console.warn('Could not read documents for Spotlight:', cause instanceof Error ? cause.message : cause)); + } } setRecent({ pro, con }); setSources(nextSources?.slice(0, DASHBOARD_SOURCES) ?? null); @@ -255,7 +273,7 @@ function DebateDetails({ debateId, dashboard = false }: { debateId: string; dash console.warn('Could not load the debate:', cause instanceof Error ? cause.message : cause); setFailed(true); } - }, [userId, debateId, dashboard, copies]); + }, [userId, debateId, dashboard, copies, t]); // Reload on focus, so work saved on the website shows up. Out of view, its saved copies no longer count. useFocusEffect( @@ -431,9 +449,19 @@ function RecentSourceRow({ source, first, onPress }: { source: Source; first: bo const { t } = useTranslation(); const { colors } = useTheme(); const press = usePressScale(); + const actions = useSourceActions(); return ( { + if (event.nativeEvent.actionName === 'copy') void actions.copy(source); + else if (event.nativeEvent.actionName === 'share') actions.share(source); + }} onPress={onPress} onPressIn={press.onPressIn} onPressOut={press.onPressOut} diff --git a/apps/mobile/src/app/(tabs)/(library)/library.tsx b/apps/mobile/src/app/(tabs)/(library)/library.tsx index 74e3da21..31a074c7 100644 --- a/apps/mobile/src/app/(tabs)/(library)/library.tsx +++ b/apps/mobile/src/app/(tabs)/(library)/library.tsx @@ -1,14 +1,15 @@ import { GENERAL_FOLDER, matchesLibrarySearch, normalizeFolder, sortLibrarySources, type LibrarySort } from '@debatelab/core/library'; import { useFocusEffect, useRouter } from 'expo-router'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { ScrollView, StyleSheet, View } from 'react-native'; +import { ScrollView, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native'; import { TextButton } from '@/components/buttons'; import { Card, ListItemIn } from '@/components/card'; import { Chip } from '@/components/chip'; import { EmptyState } from '@/components/empty-state'; import { LibraryHeader } from '@/components/library-header'; +import { CardRowFrame, ListScreen, useRowArrivals } from '@/components/list-screen'; import type { AddSourceKind, SourceTypeFilter } from '@/components/library-options'; import { FolderBar, GroundingBanner } from '@/components/library-parts'; import { OfflineNotice } from '@/components/offline-notice'; @@ -141,19 +142,23 @@ function LibraryView({ debateId, layout }: { debateId: string | null; layout: Re router.push('/debates')} /> ) : null; - const body = debateId ? ( - setSelected({ userId, id: source.id })} onRemoved={(id) => setSelected((current) => (current && currentSourceId(library.queued, current.id) === id ? null : current))} /> - ) : null; + const select = layout === 'phone' || !userId ? undefined : (source: Source) => setSelected({ userId, id: source.id }); + const removed = (id: string) => setSelected((current) => (current && currentSourceId(library.queued, current.id) === id ? null : current)); + // New sources still go to the Current Debate in one-off mode, as on the website, so this only says the mode is on. + const top = ( + <> + {header} + + + ); if (layout === 'phone') { + if (debateId) return ; return ( - {header} - {/* New sources still go to the Current Debate in one-off mode, as on the website, so this only says the mode is on. */} - - {status === 'loading' && !debateId ? : null} + {top} + {status === 'loading' ? : null} {noDebate} - {body} ); } @@ -164,20 +169,27 @@ function LibraryView({ debateId, layout }: { debateId: string | null; layout: Re const listWidth = Math.max(available >= LIBRARY_ROOMY_WIDTH ? LIST_WIDTH.libraryRoomy : LIST_WIDTH.library, Math.round(available * 0.4)); return ( - - - {header} - - {status === 'loading' && !debateId ? : null} - {noDebate} - {body} - + {debateId ? ( + + ) : ( + + + {top} + {status === 'loading' ? : null} + {noDebate} + + )} {shown && selected && userId ? ( {/* Keyed by the id that was opened: a note that syncs swaps its local id for the row's, and the @@ -272,25 +284,56 @@ function useLibrary(debateId: string | null) { return { userId, debateId: debateId!, sources, setSources, queued, waiting, failed, load, query, setQuery, filter, setFilter, sort, setSort, folder, setFolder, folders, inFolder, visible, patch }; } -function LibraryBody({ library, selectedId, onSelect, onRemoved }: { library: Library; selectedId: string | null; onSelect?: (source: Source) => void; onRemoved?: (id: string) => void }) { +function LibraryBody({ + library, + top, + selectedId, + onSelect, + onRemoved, + style, + contentStyle, +}: { + library: Library; + /** The tab's header above the list. */ + top: ReactNode; + selectedId: string | null; + onSelect?: (source: Source) => void; + onRemoved?: (id: string) => void; + /** The iPad list pane's size and its column. */ + style?: StyleProp; + contentStyle?: StyleProp; +}) { const { t } = useTranslation(); const router = useRouter(); const toast = useToast(); const actions = useSourceActions(); const activeWrites = useRef({ seq: 0, latest: new Map(), confirmed: new Map() }); const { sources, failed, folders, folder, inFolder, visible, patch, userId, debateId, waiting } = library; + const arrivals = useRowArrivals(visible.map((source) => source.id)); - if (failed && !sources) { + if (!sources) { return ( - - - {t('mobile.library.loadFailed')} - - void library.load()} /> - + null} + style={style} + contentStyle={contentStyle} + ListEmptyComponent={ + failed ? ( + + + {t('mobile.library.loadFailed')} + + void library.load()} /> + + ) : ( + + ) + } + /> ); } - if (!sources) return ; const active = sources.filter((source) => source.isActive).length; const chipFolders = Array.from(new Set([...folders, ...sources.map((source) => source.folder)])).sort(); @@ -360,75 +403,87 @@ function LibraryBody({ library, selectedId, onSelect, onRemoved }: { library: Li }; return ( - <> - - {t('libraryPage.summary', { - sources: t('libraryPage.sourceCount', { count: sources.length }), - active: t('libraryPage.activeCount', { count: active }), - folders: t('libraryPage.folderCount', { count: chipFolders.length }), - })} - - {sources.length > 0 ? 0} /> : null} - - - {[ALL, ...chipFolders].map((option) => ( - library.setFolder(option)} - /> - ))} - - {folder !== ALL ? ( - void toggle(inFolder, isActive, folder)} - onRename={() => void rename()} - onDelete={() => void removeFolder()} - /> - ) : null} - - {sources.length === 0 ? ( - - ) : visible.length === 0 ? ( - - ) : ( - - {visible.map((source, index) => ( - - (onSelect ? onSelect(source) : router.push({ pathname: '/source/[id]', params: { id: source.id } })), - onToggle: (isActive) => void toggle([source], isActive), - onDelete: () => remove(source), - onCopy: () => void actions.copy(source), - onShare: () => actions.share(source), - onMove: (target) => { - patch([source.id], { folder: target }); - void actions.move(source, target).then((moved) => { - if (!moved) patch([source.id], { folder: source.folder }); - }); - }, - }} + + {top} + + {t('libraryPage.summary', { + sources: t('libraryPage.sourceCount', { count: sources.length }), + active: t('libraryPage.activeCount', { count: active }), + folders: t('libraryPage.folderCount', { count: chipFolders.length }), + })} + + {sources.length > 0 ? 0} /> : null} + + + {[ALL, ...chipFolders].map((option) => ( + library.setFolder(option)} /> - - ))} - + ))} + + {folder !== ALL ? ( + void toggle(inFolder, isActive, folder)} + onRename={() => void rename()} + onDelete={() => void removeFolder()} + /> + ) : null} + + } + data={visible} + keyExtractor={(source) => source.id} + extraData={`${selectedId ?? ''}:${folder}:${chipFolders.join('/')}:${[...waiting].join(',')}`} + animateLayout + onRefresh={library.load} + style={style} + contentStyle={contentStyle} + ListEmptyComponent={ + sources.length === 0 ? ( + + ) : ( + + ) + } + renderItem={({ item: source, index }) => ( + + + (onSelect ? onSelect(source) : router.push({ pathname: '/source/[id]', params: { id: source.id } })), + onToggle: (isActive) => void toggle([source], isActive), + onDelete: () => remove(source), + onCopy: () => void actions.copy(source), + onShare: () => actions.share(source), + onMove: (target) => { + patch([source.id], { folder: target }); + void actions.move(source, target).then((moved) => { + if (!moved) patch([source.id], { folder: source.folder }); + }); + }, + }} + /> + + )} - + /> ); } @@ -437,12 +492,12 @@ const styles = StyleSheet.create({ summary: { fontSize: 14.5, marginHorizontal: 2, marginTop: -6 }, chipRow: { marginHorizontal: -20, flexGrow: 0 }, chips: { gap: 8, paddingHorizontal: 20 }, - list: { padding: 0, gap: 0, overflow: 'hidden' }, split: { flex: 1, flexDirection: 'row' }, listPane: { flexGrow: 0, flexShrink: 0, borderRightWidth: StyleSheet.hairlineWidth }, listPaneFull: { flex: 1 }, listContentFull: { width: '100%', maxWidth: 880, alignSelf: 'center' }, - listContent: { paddingHorizontal: 20, paddingBottom: 32, gap: 16 }, + 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 }, }); diff --git a/apps/mobile/src/app/+native-intent.tsx b/apps/mobile/src/app/+native-intent.tsx index 84d06964..254fc92b 100644 --- a/apps/mobile/src/app/+native-intent.tsx +++ b/apps/mobile/src/app/+native-intent.tsx @@ -1,4 +1,6 @@ import { appPathForUrl } from '@/lib/deep-links'; +import { receiveShareUrl } from '@/lib/share-inbox'; +import { isReadableShareUrl, isShareIntentUrl } from '@/lib/share-intent-parse'; /** * Every URL the system hands the app passes here first (Expo Router's native @@ -6,10 +8,17 @@ import { appPathForUrl } from '@/lib/deep-links'; * become app routes: https://debateowl.com/share/ opens the shared item, * /app/ the matching tool. The app's own scheme (debatelab://), Expo Go's * exp:// and a plain launch pass through unchanged. Any other web address opens - * the Debate tab, never a route taken from it. + * the Debate tab, never a route taken from it. The share extension's + * debatelab://dataUrl=… is not a route: share-intent.ts reads what was shared + * and opens its sheet over the Debate tab. */ export function redirectSystemPath({ path }: { path: string; initial: boolean }): string { try { + if (isShareIntentUrl(path)) { + // Only the extension's own URL is read; anything else in its shape just opens the Debate tab. + if (isReadableShareUrl(path)) receiveShareUrl(path); + return '/'; + } if (!/^https?:/i.test(path)) return path; return appPathForUrl(path) ?? '/'; } catch { diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 24726bc4..1a591149 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -18,6 +18,7 @@ import { DebateProvider } from '@/lib/debate-context'; import { useFirstRun } from '@/lib/use-first-run'; import { useKeyCommand } from '@/lib/key-commands'; import { useQuickActions } from '@/lib/quick-actions'; +import { useShareIntent } from '@/lib/share-intent'; import { useOfflineQueue } from '@/lib/use-offline-queue'; import { ReservedWidth, useSidebarWidth } from '@/lib/layout'; import { watchNetwork } from '@/lib/network-status'; @@ -70,6 +71,8 @@ function Root() { useFirstRun(ready ? userId ?? null : null); // Home Screen quick actions (development build with expo-quick-actions; nothing in Expo Go). useQuickActions(ready); + // Text and pages shared from other apps (development build with the share extension; nothing in Expo Go). + useShareIntent(ready); // iPad hardware keyboard: ⌘N starts a debate, ⌘K opens the switcher (key-commands.ts). const router = useRouter(); useKeyCommand('cmd+n', () => router.push('/new-debate'), ready && Boolean(userId)); diff --git a/apps/mobile/src/app/add-link.tsx b/apps/mobile/src/app/add-link.tsx index dea10c09..2ce3d37e 100644 --- a/apps/mobile/src/app/add-link.tsx +++ b/apps/mobile/src/app/add-link.tsx @@ -1,6 +1,6 @@ import type { SourceImportOutput } from '@debatelab/core/ai/schemas/source-import'; -import { useRouter } from 'expo-router'; -import { useState } from 'react'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { StyleSheet, View } from 'react-native'; @@ -14,6 +14,7 @@ import { useToast } from '@/components/toast'; import { useAuth } from '@/lib/auth'; import { useDebates } from '@/lib/debate-context'; import { haptics } from '@/lib/haptics'; +import { forgetShared, sharedItem } from '@/lib/share-intent'; import { addSource, fetchSources, importSourceFromUrl, isDuplicateOrigin } from '@/lib/sources'; import { useCopyHolder } from '@/lib/use-copy-holder'; import { useLibraryFolders } from '@/lib/use-library-folders'; @@ -22,7 +23,8 @@ import { useTheme } from '@/theme/use-theme'; /** * Paste a link: the import-source Edge Function reads the page into an * editable draft, and saving adds it to the Current Debate, as the website's - * Import from URL does. + * Import from URL does. A page shared from another app (share-intent.ts) opens + * this sheet with its address, and the import starts by itself. */ export default function AddLinkSheet() { const { t, i18n } = useTranslation(); @@ -30,7 +32,10 @@ export default function AddLinkSheet() { const { colors } = useTheme(); const { session } = useAuth(); const { current } = useDebates(); - const [url, setUrl] = useState(''); + // Only the share extension's hand-off fills the sheet in (share-intent.ts); route parameters never do. + const { shared: fromShare } = useLocalSearchParams<{ shared?: string }>(); + const [shared] = useState(() => (fromShare ? sharedItem('link') : null)); + const [url, setUrl] = useState(() => shared?.url ?? ''); const [draft, setDraft] = useState(null); const [title, setTitle] = useState(''); const [busy, setBusy] = useState<'import' | 'save' | null>(null); @@ -51,7 +56,7 @@ export default function AddLinkSheet() { try { const imported = await importSourceFromUrl(trimmed); setDraft(imported); - setTitle(imported.title); + setTitle(imported.title || shared?.title || ''); } catch (cause) { console.warn('Could not import the source:', cause instanceof Error ? cause.message : cause); setError(`${t('libraryPage.importFailed')}. ${t('libraryPage.importFailedDesc')}`); @@ -60,6 +65,19 @@ export default function AddLinkSheet() { } }; + // A shared page is read once the Current Debate is known; the student still checks and saves it. + const autoImported = useRef(false); + useEffect(() => { + if (fromShare) forgetShared(); + }, [fromShare]); + useEffect(() => { + if (autoImported.current || !shared || !current) return; + autoImported.current = true; + void runImport(); + // runImport reads the address the hand-off filled in; it runs once per opening. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shared, current]); + const save = async () => { const userId = session?.user.id; if (!draft || !userId || !current || !title.trim()) return; @@ -109,7 +127,7 @@ export default function AddLinkSheet() { keyboardType="url" autoCapitalize="none" autoCorrect={false} - autoFocus + autoFocus={!shared} returnKeyType="go" onSubmitEditing={() => void runImport()} editable={busy === null} diff --git a/apps/mobile/src/app/ask.tsx b/apps/mobile/src/app/ask.tsx index e729272b..da881193 100644 --- a/apps/mobile/src/app/ask.tsx +++ b/apps/mobile/src/app/ask.tsx @@ -1,7 +1,7 @@ import { askDisplayContent, askMessageWithFiles } from '@debatelab/core/ai/ask-files'; import type { AskOutput } from '@debatelab/core/ai/schemas/ask'; import { useLocalSearchParams, useRouter } from 'expo-router'; -import { useCallback, useEffect, useRef, useState } from 'react'; +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 { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -21,7 +21,7 @@ import { ContextLine } from '@/components/context-line'; import { DailyUsageLine } from '@/components/tool-form'; import { Text } from '@/components/text'; import { appendDictation, DictationButton } from '@/components/voice'; -import { useKeyCommand } from '@/lib/key-commands'; +import { useFocusedKeyCommand } from '@/lib/key-commands'; import { announceAnswer } from '@/lib/a11y'; import { AIError, isAbortError, type AIErrorKind, type ResponseExtras } from '@/lib/ai-request'; import { askOnce, canStream, isFinalAskError, isMessageWithinBudget, streamAsk, type AskMode, type AskOptions } from '@/lib/ask'; @@ -37,6 +37,7 @@ import { openRefusal } from '@/lib/refusal'; import { keepSourceAssessments } from '@/lib/results'; import { useSettings } from '@/lib/settings'; import { addSource, fetchSources, type Source } from '@/lib/sources'; +import { useLatest, useLiveValue, useLiveValueStore, type LiveValue } from '@/lib/live-value'; import { useCopyHolder } from '@/lib/use-copy-holder'; import { useSpeechCapture } from '@/lib/use-speech-capture'; import { radius } from '@/theme/colors'; @@ -87,7 +88,21 @@ export default function AskScreen() { const { language, outputLanguage, readFeatureConfig } = useSettings(); const [messages, setMessages] = useState([]); - const [streaming, setStreaming] = useState(null); + // The answer as it streams in. Only StreamingAnswer reads the text, so a token repaints the answer alone. + // Whether one is under way (`streamPhase`) is plain state, so it changes in the same render as the + // finished message, the error or Stop. Ending a stream changes only the phase: the text stays until the + // next answer starts, so the answer never blinks out before the finished message takes its place. + const stream = useLiveValueStore(''); + const [streamPhase, setStreamPhase] = useState<'none' | 'empty' | 'text'>('none'); + const phase = useLatest(streamPhase); + const setStreaming = useCallback((next: string | null | ((previous: string) => string)) => { + if (next === null) { + setStreamPhase('none'); + return; + } + stream.set(next); + setStreamPhase(stream.get() === '' ? 'empty' : 'text'); + }, [stream]); const [loading, setLoading] = useState(false); const [opening, setOpening] = useState(false); const [record, setRecordState] = useState(NEW_CHAT); @@ -109,6 +124,8 @@ export default function AskScreen() { const [library, setLibrary] = useState([]); const scroll = useRef(null); + // The handlers the effects above them call, filled in after each render (see the end of the component). + const actions = useRef(null); // The answer's text so far, kept by Stop as the website keeps it. const partial = useRef(''); const requests = useRef(0); @@ -120,8 +137,7 @@ export default function AskScreen() { const chatCopies = useCopyHolder(); const recordRef = useRef(record); const saves = useRef>(Promise.resolve()); - const activeDebateRef = useRef(activeDebateId); - activeDebateRef.current = activeDebateId; + const activeDebateRef = useLatest(activeDebateId); const setRecord = (next: ChatRecord) => { recordRef.current = next; @@ -162,9 +178,7 @@ export default function AskScreen() { if (scopeRef.current === scope) return; scopeRef.current = scope; const open = recordRef.current; - if (!(open.chatId && open.debateId === scope)) startConversation([], NEW_CHAT); - // startConversation only touches refs and setters. - // eslint-disable-next-line react-hooks/exhaustive-deps + if (!(open.chatId && open.debateId === scope)) actions.current?.startConversation([], NEW_CHAT); }, [scope]); // The debate this conversation reads from: a loaded chat keeps its own. @@ -192,7 +206,10 @@ export default function AskScreen() { const lastRole = messages[messages.length - 1]?.role; useEffect(() => { if (loading || lastRole !== 'assistant' || !userId) return; - const snapshot = messages; + actions.current?.saveExchange(userId, messages); + }, [loading, lastRole, messages, userId]); + + const saveExchange = (userId: string, snapshot: ChatMessage[]) => { const owner = conversation.current; saves.current = saves.current.then(async () => { if (owner !== conversation.current || recordRef.current.persisted >= snapshot.length) return; @@ -206,11 +223,10 @@ export default function AskScreen() { setNotice(t('debate.recordFailed')); } }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [loading, lastRole, messages, userId]); + }; // ⌘↩ sends, as the send button does. - useKeyCommand('cmd+enter', () => { + useFocusedKeyCommand('cmd+enter', () => { if (!busy && input.trim()) void send(input, messages, attachments); }); @@ -288,7 +304,7 @@ export default function AskScreen() { flush = null; const batch = pending; pending = ''; - if (isCurrent()) setStreaming((previous) => (previous ?? '') + batch); + if (isCurrent()) setStreaming((previous) => previous + batch); }, 30); }, }); @@ -363,9 +379,8 @@ export default function AskScreen() { useEffect(() => { if (prompted.current || !prompt?.trim() || !userId) return; prompted.current = true; - void send(prompt, []); - // send reads refs and state setters; it runs once per opening. - // eslint-disable-next-line react-hooks/exhaustive-deps + // Once per opening. + void actions.current?.send(prompt, []); }, [prompt, userId]); // Retrying replaces the unanswered question instead of asking it twice. @@ -406,6 +421,10 @@ export default function AskScreen() { } }; + // Stable, so the saved messages do not render again for a keystroke or a token. + const saveAnswerRef = useLatest(saveAnswer); + const onSaveAnswer = useCallback((content: string) => saveAnswerRef.current(content), [saveAnswerRef]); + const attach = async () => { try { const picked = await pickAskFiles(attachments); @@ -442,9 +461,8 @@ export default function AskScreen() { useEffect(() => { if (resumed.current || !resumeChatId || !userId) return; resumed.current = true; - void openChat(resumeChatId); - // openChat reads refs and state setters; it runs once per opening. - // eslint-disable-next-line react-hooks/exhaustive-deps + // Once per opening. + void actions.current?.openChat(resumeChatId); }, [resumeChatId, userId]); const subtitle = oneOff ? t('debate.oneOffActive') : current?.motion ?? ''; @@ -523,9 +541,9 @@ export default function AskScreen() { keyboardShouldPersistTaps="handled" keyboardDismissMode="interactive" // A streamed answer grows every 30 ms; restarting an animated scroll each time stutters. - onContentSizeChange={() => scroll.current?.scrollToEnd({ animated: streaming === null })}> + onContentSizeChange={() => scroll.current?.scrollToEnd({ animated: phase.current === 'none' })}> - {messages.length === 0 && streaming === null && !busy ? ( + {messages.length === 0 && streamPhase === 'none' && !busy ? ( @@ -538,20 +556,10 @@ export default function AskScreen() { ) : null} - {messages.map((message) => - message.role === 'user' ? ( - - - {message.content} - - - ) : ( - saveAnswer(message.content)} /> - ), - )} + - {streaming !== null && streaming !== '' ? : null} - {loading && !streaming ? : null} + {streamPhase === 'text' ? : null} + {loading && streamPhase !== 'text' ? : null} {opening ? : null} {/* What the next question sends, below the conversation as the website shows it above its input. */} @@ -667,6 +675,10 @@ export default function AskScreen() { startConversation([], NEW_CHAT); }; + useLayoutEffect(() => { + actions.current = { startConversation, saveExchange, send, openChat }; + }); + if (split) { return ( @@ -701,6 +713,40 @@ export default function AskScreen() { ); } +type AskActions = { + startConversation: (messages: ChatMessage[], record: ChatRecord) => void; + saveExchange: (userId: string, snapshot: ChatMessage[]) => void; + send: (text: string, history: ChatMessage[], files?: AskAttachment[]) => Promise; + openChat: (chatId: string) => Promise; +}; + +/** + * The conversation so far. AskScreen is not compiled by React Compiler (its request code uses + * try/finally), so this is memoized by hand: it renders again only when a message is added or + * the Library's sources (for citations) change, never for typing or a streamed token. + */ +const AskMessages = memo(function AskMessages({ messages, library, onSave }: { messages: ChatMessage[]; library: Source[]; onSave: (content: string) => Promise }) { + const { colors } = useTheme(); + return messages.map((message) => + message.role === 'user' ? ( + + + {message.content} + + + ) : ( + onSave(message.content)} /> + ), + ); +}); + +/** The answer while it streams in: the one part of Ask that renders for every batch of tokens. */ +function StreamingAnswer({ stream, library }: { stream: LiveValue; library: Source[] }) { + const text = useLiveValue(stream); + if (!text) return null; + return ; +} + /** What the coach is doing while a whole answer is prepared, cycling like the website's. */ function Thinking() { const { t } = useTranslation(); diff --git a/apps/mobile/src/app/crossfire.tsx b/apps/mobile/src/app/crossfire.tsx index e6455306..d8803c2d 100644 --- a/apps/mobile/src/app/crossfire.tsx +++ b/apps/mobile/src/app/crossfire.tsx @@ -11,7 +11,7 @@ import { import { formatRoundTime } from '@debatelab/core/sparring/formats'; import { combineDelivery, measureDelivery } from '@debatelab/core/voice/delivery'; import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; -import { useCallback, useEffect, useReducer, useRef, useState } from 'react'; +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 { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -33,7 +33,7 @@ import { Text } from '@/components/text'; import { TimerRing } from '@/components/timer-ring'; import { ToolTitle, WorkingIn } from '@/components/tool-form'; import { appendDictation, DeliverySummary, OpponentVoiceBar, VoiceMicControl, VoiceModeSwitch } from '@/components/voice'; -import { useKeyCommand } from '@/lib/key-commands'; +import { useFocusedKeyCommand } from '@/lib/key-commands'; import { announceAnswer } from '@/lib/a11y'; import { abortError, AIError, isAbortError, type ResponseExtras, untilAborted } from '@/lib/ai-request'; import { useAuth } from '@/lib/auth'; @@ -65,6 +65,7 @@ import { chooseMotionTarget } from '@/lib/motion-gate'; import { keepSourceAssessments } from '@/lib/results'; import { useSettings } from '@/lib/settings'; import { formatClock } from '@/lib/timer'; +import { useLatest } from '@/lib/live-value'; import { fontFamily } from '@/theme/fonts'; import { radius } from '@/theme/colors'; import { useTheme } from '@/theme/use-theme'; @@ -146,13 +147,11 @@ export default function CrossfireScreen() { draftRef.current = value; setDraftTurnState(value); }, []); - const stateRef = useRef(state); - stateRef.current = state; + const stateRef = useLatest(state); // A voice round falls back to typing when the microphone is lost. const voiceAvailability = useVoiceAvailability(); const voiceActive = Boolean(state.voice) && voiceAvailability.available; - const voiceActiveRef = useRef(voiceActive); - voiceActiveRef.current = voiceActive; + const voiceActiveRef = useLatest(voiceActive); const capture = useSpeechCapture(outputLanguage, (text) => setDraftTurn(appendDictation(draftRef.current, text, MAX_CROSSFIRE_TURN_CHARS))); // Each round is measured on its own, and a start still waiting for permission does not carry over. const captureScope = state.phase === 'setup' ? 'setup' : state.sessionId; @@ -164,7 +163,6 @@ export default function CrossfireScreen() { useEffect(() => { if (!awaitingUserTurn) void stopCapture(); }, [awaitingUserTurn, stopCapture]); - const [now, setNow] = useState(() => Date.now()); const [formError, setFormError] = useState(''); // A case or an attack opened in Crossfire while a round runs is not brought in, as on the website; say so. const [notice, setNotice] = useState(() => (imported && state.phase !== 'setup' ? t('mobile.crossfire.prefillHeld') : '')); @@ -231,19 +229,6 @@ export default function CrossfireScreen() { }, [userId, sourceDebate]), ); - // The round's clock reads its end timestamp; the tick only redraws it, and coming back from the background redraws at once. - useEffect(() => { - if (state.phase !== 'active') return; - setNow(Date.now()); - const timer = setInterval(() => setNow(Date.now()), 500); - const subscription = AppState.addEventListener('change', (next) => { - if (next === 'active') setNow(Date.now()); - }); - return () => { - clearInterval(timer); - subscription.remove(); - }; - }, [state.phase]); /** What the website does with every whole answer: save the source rankings, and say when the input was shortened. */ const afterResponse = useCallback((forUser: string, debateId: string | null, extras: ResponseExtras) => { @@ -310,7 +295,7 @@ export default function CrossfireScreen() { // Crossfire runs on Ask's model settings, so a model refusal switches Ask's. openRefusal(router, cause, 'ask'); } - }, [afterResponse, outputLanguage, refresh, requestContext, router]); + }, [afterResponse, outputLanguage, refresh, requestContext, router, voiceActiveRef]); const runDebrief = useCallback(async (snapshot: Debriefing, forUser: string) => { requestRef.current?.abort(); @@ -371,9 +356,6 @@ export default function CrossfireScreen() { if (next.phase === 'debriefing') void runDebrief(next, userId); }, [capture, runDebrief, state, t, userId]); - useEffect(() => { - if (state.phase === 'active' && now >= state.endsAt) endSession('timer'); - }, [endSession, now, state]); const beginSession = (debateId: string | null, forUser: string) => { if (state.phase !== 'setup') return; @@ -437,9 +419,6 @@ export default function CrossfireScreen() { if (live.current) beginSession(debateId, forUser); }; - // ⌘↩ submits the turn on an iPad keyboard. - useKeyCommand('cmd+enter', () => void submitTurn()); - const submitTurn = async () => { if (state.phase !== 'active' || !userId) return; // Stopping also withdraws a start still waiting for permission; the last words land in the box first. @@ -462,6 +441,9 @@ export default function CrossfireScreen() { if (next.phase === 'active') void runNextTurn(next, expectedReply, userId); }; + // ⌘↩ submits the turn on an iPad keyboard. + useFocusedKeyCommand('cmd+enter', () => void submitTurn()); + const startSpeaking = () => { // Speaking over the opponent ends its audio. opponentVoice.stop(); @@ -623,7 +605,7 @@ export default function CrossfireScreen() { {spokenDelivery ? : null} - + {celebrate ? ( - - 0 ? remaining / total : 0} warning={state.phase === 'active' && remaining < 30}> - - {formatClock(remaining)} - - - + endSession('timer')} /> {t('crossfire.timerLabel')} @@ -693,7 +668,7 @@ export default function CrossfireScreen() { keyboardShouldPersistTaps="handled" keyboardDismissMode="interactive" onContentSizeChange={() => scroll.current?.scrollToEnd({ animated: true })}> - + {voiceActive ? : null} @@ -798,6 +773,45 @@ export default function CrossfireScreen() { ); } +/** The transcript, drawn again only when a turn is added: not for typing, the clock or the microphone. */ +const Transcript = memo(CrossfireTranscript); + +/** + * The round's clock. It reads the end timestamp and ticks on its own, so the + * rest of the round does not render twice a second; coming back from the + * background redraws it at once, and it ends the round when time is up. + */ +function CrossfireClock({ active, endsAt, total, onExpired }: { active: boolean; endsAt: number; total: number; onExpired: () => void }) { + const { t } = useTranslation(); + const [now, setNow] = useState(() => Date.now()); + const expired = useLatest(onExpired); + useEffect(() => { + if (!active) return; + setNow(Date.now()); + const timer = setInterval(() => setNow(Date.now()), 500); + const subscription = AppState.addEventListener('change', (next) => { + if (next === 'active') setNow(Date.now()); + }); + return () => { + clearInterval(timer); + subscription.remove(); + }; + }, [active]); + useEffect(() => { + if (active && now >= endsAt) expired.current(); + }, [active, now, endsAt, expired]); + const remaining = active ? Math.max(0, Math.ceil((endsAt - now) / 1000)) : 0; + return ( + + 0 ? remaining / total : 0} warning={active && remaining < 30}> + + {formatClock(remaining)} + + + + ); +} + const styles = StyleSheet.create({ root: { flex: 1 }, flex: { flex: 1 }, diff --git a/apps/mobile/src/app/delete-data.tsx b/apps/mobile/src/app/delete-data.tsx index cc5812af..13bf60a4 100644 --- a/apps/mobile/src/app/delete-data.tsx +++ b/apps/mobile/src/app/delete-data.tsx @@ -12,6 +12,7 @@ import { useAuth } from '@/lib/auth'; import { showNotice } from '@/lib/confirm'; import { useDebates } from '@/lib/debate-context'; import { forgetSessionMemory } from '@/lib/session-memory'; +import { clearSpotlight } from '@/lib/spotlight'; import { useTheme } from '@/theme/use-theme'; /** @@ -63,6 +64,7 @@ export default function DeleteDataSheet() { // gone. They are cleared here first, so a reload that fails leaves nothing deleted on screen or targeted. forgetSessionMemory(); forgetDebates(forUser); + void clearSpotlight(); void refresh(); showNotice(t('settings.deleteSuccess'), t('settings.deleteSuccessDesc')); if (live.current) router.back(); diff --git a/apps/mobile/src/app/history.tsx b/apps/mobile/src/app/history.tsx index f3c17d65..70376b23 100644 --- a/apps/mobile/src/app/history.tsx +++ b/apps/mobile/src/app/history.tsx @@ -11,7 +11,7 @@ import { Card, IconTile, ListItemIn, usePressScale } from '@/components/card'; import { Chip } from '@/components/chip'; import { DocumentHeader } from '@/components/document-header'; import { Icon } from '@/components/icon'; -import { Screen } from '@/components/screen'; +import { CardRowFrame, ListScreen, useRowArrivals } from '@/components/list-screen'; import { SkeletonRows } from '@/components/skeleton'; import { Text } from '@/components/text'; import { KIND_ICONS, workHref } from '@/components/work-row'; @@ -33,6 +33,7 @@ import { type UnsortedEntry, } from '@/lib/history'; import { keepIfSame } from '@/lib/keep-if-same'; +import { useLatest } from '@/lib/live-value'; import { suggestDebate } from '@/lib/motion'; import { deleteResult } from '@/lib/results'; import { deleteSparringRound, sparringDraft } from '@/lib/sparring'; @@ -99,6 +100,7 @@ function historyHref(item: HistoryItem): Href | null { /** The website's History (src/pages/HistoryPage.tsx): saved work per debate, and work outside every debate. */ export default function HistoryScreen() { const { t } = useTranslation(); + const router = useRouter(); const { from } = useLocalSearchParams<{ from?: string }>(); const { session } = useAuth(); const userId = session?.user.id ?? null; @@ -108,8 +110,10 @@ export default function HistoryScreen() { const scope = scopeChoice && (scopeChoice === OUTSIDE || debates.some((debate) => debate.id === scopeChoice)) ? scopeChoice : current?.id ?? OUTSIDE; + const shownScope = useLatest(scope); const [tab, setTab] = useState('cases'); const [loaded, setLoaded] = useState(null); + const shownLoaded = useLatest(loaded); const [failed, setFailed] = useState(null); const [scopeOpen, setScopeOpen] = useState(false); @@ -197,76 +201,186 @@ export default function HistoryScreen() { }; const attached = (entry: UnsortedEntry) => { - loads.current += 1; - setLoaded((previous) => previous?.kind === 'unsorted' - ? { ...previous, entries: previous.entries.filter((candidate) => !(candidate.kind === entry.kind && candidate.id === entry.id)) } - : previous); + // The unsorted list on screen loses the item at once, and a load running now (which read it before + // the attach) is dropped. Back on the unsorted scope before its list has loaded, it is read again + // instead, so the item cannot come back. On a debate's History, that debate's load is left alone and + // the unsorted list is read when it is opened. + if (shownScope.current === OUTSIDE) { + if (shownLoaded.current?.kind === 'unsorted') { + loads.current += 1; + setLoaded((previous) => previous?.kind === 'unsorted' + ? { ...previous, entries: previous.entries.filter((candidate) => !(candidate.kind === entry.kind && candidate.id === entry.id)) } + : previous); + } else { + void load(OUTSIDE); + } + } // The debate's last activity moved; the switcher and the Debate tab show the new order. void refreshDebates(); }; + // "Add to debate" lives here, not in its row: a virtualized row can unmount while scrolled away, + // and the debate picked for it and a request in flight must outlast that. + const [attachChoices, setAttachChoices] = useState>({}); + const [attaching, setAttaching] = useState>(() => new Set()); + const attachEntry = async (key: string, entry: UnsortedEntry, debateId: string) => { + const forUser = owner.current; + if (!forUser || attaching.has(key)) return; + setAttaching((previous) => new Set(previous).add(key)); + try { + 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) { + 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); + next.delete(key); + return next; + }); + } + } + }; + + // The attach notice belongs to the list it was shown for. + const [attachNotice, setAttachNotice] = useState<{ scope: string; text: string } | null>(null); + const notice = attachNotice?.scope === scope ? attachNotice.text : null; + + const rows: HistoryListRow[] = !shown + ? [] + : 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 })); + const arrivals = useRowArrivals(rows.map((row) => row.key)); + + const header = ( + <> + + {t('historyPage.title')} + + setScopeOpen(true)} /> + ({ + key: debate.id, + label: `${debate.id === current?.id ? `${t('nav.currentDebate')}: ` : ''}${debate.motion}`, + })), + { key: OUTSIDE, label: t('debate.outsideDebates') }, + ]} + cancelLabel={t('common.cancel')} + onClose={() => setScopeOpen(false)} + onSelect={(key) => { + if (key !== scope) haptics.select(); + setScopeChoice(key); + }} + testID="history-scope-menu" + /> + + {scope !== OUTSIDE ? ( + + {HISTORY_TABS.map((entry) => ( + setTab(entry.key)} + testID={`history-tab-${entry.key}`} + /> + ))} + + ) : null} + + {shown?.kind === 'unsorted' && shown.entries.length > 0 ? ( + <> + + {t('debate.unsortedDesc')} + + {notice ? ( + + {notice} + + ) : null} + + ) : null} + + ); + + const empty = + failed === scope && !shown ? ( + + + {t('mobile.history.loadFailed')} + + void load(scope)} /> + + ) : !shown ? ( + + ) : shown.kind === 'unsorted' ? ( + + ) : ( + + ); + return ( - - - {t('historyPage.title')} - - setScopeOpen(true)} /> - ({ - key: debate.id, - label: `${debate.id === current?.id ? `${t('nav.currentDebate')}: ` : ''}${debate.motion}`, - })), - { key: OUTSIDE, label: t('debate.outsideDebates') }, - ]} - cancelLabel={t('common.cancel')} - onClose={() => setScopeOpen(false)} - onSelect={(key) => { - if (key !== scope) haptics.select(); - setScopeChoice(key); - }} - testID="history-scope-menu" - /> - - {scope !== OUTSIDE ? ( - - {HISTORY_TABS.map((entry) => ( - setTab(entry.key)} - testID={`history-tab-${entry.key}`} - /> - ))} - - ) : null} - - {failed === scope && !shown ? ( - - - {t('mobile.history.loadFailed')} - - void load(scope)} /> - - ) : !shown ? ( - - ) : shown.kind === 'unsorted' ? ( - - ) : ( - - )} - + row.key} + extraData={{ notice, attachChoices, attaching }} + animateLayout + onRefresh={() => load(scope)} + ListEmptyComponent={empty} + ItemSeparatorComponent={shown?.kind === 'unsorted' ? UnsortedGap : undefined} + renderItem={({ item: row, index }) => { + if (row.type === 'unsorted') { + return ( + + setAttachChoices((previous) => ({ ...previous, [row.key]: debateId }))} + onAttach={(debateId) => void attachEntry(row.key, row.entry, debateId)} + /> + + ); + } + const href = historyHref(row.item); + return ( + + + router.push(href) : undefined} + onDelete={row.item.kind === 'case' || row.item.kind === 'sparring' ? () => remove(row.item) : undefined} + /> + + + ); + }} + /> ); } +type HistoryListRow = { type: 'item'; key: string; item: HistoryItem } | { type: 'unsorted'; key: string; entry: UnsortedEntry }; + +function UnsortedGap() { + return ; +} + function ScopePicker({ label, onPress }: { label: string; onPress: () => void }) { const { t } = useTranslation(); const { colors } = useTheme(); @@ -290,33 +404,14 @@ function ScopePicker({ label, onPress }: { label: string; onPress: () => void }) ); } -function TabList({ tab, items, onDelete }: { tab: HistoryTab; items: HistoryItem[]; onDelete: (item: HistoryItem) => Promise }) { +function TabEmpty({ tab }: { tab: HistoryTab }) { const { t } = useTranslation(); const router = useRouter(); - if (items.length === 0) { - const empty = EMPTY[tab]; - return ( - - {empty.href ? router.push(empty.href!)} /> : null} - - ); - } + const empty = EMPTY[tab]; return ( - - {items.map((item, index) => { - const href = historyHref(item); - return ( - - router.push(href) : undefined} - onDelete={item.kind === 'case' || item.kind === 'sparring' ? () => onDelete(item) : undefined} - /> - - ); - })} - + + {empty.href ? router.push(empty.href!)} /> : null} + ); } @@ -350,13 +445,22 @@ function HistoryRow({ item, first, onPress, onDelete }: { item: HistoryItem; fir if (!removed) swipeable.current?.close(); }; + // VoiceOver offers the long-press menu's actions on the row itself (swipe up or down). + const document = href !== null && hasDocumentText(item); + const a11yActions = [ + ...(document ? [{ name: 'discuss', label: t('common.discuss') }, { name: 'share', label: t('mobile.library.share') }] : []), + ...(onDelete ? [{ name: 'delete', label: t('common.delete') }] : []), + ]; const row = ( 0 ? a11yActions : undefined} onAccessibilityAction={(event) => { - if (event.nativeEvent.actionName === 'delete') void remove(); + const name = event.nativeEvent.actionName; + if (name === 'discuss') void work.discuss(item); + else if (name === 'share') void work.share(item); + else if (name === 'delete') void remove(); }} disabled={!onPress} onPress={onPress} @@ -437,92 +541,61 @@ function HistoryRow({ item, first, onPress, onDelete }: { item: HistoryItem; fir ); } -/** The website's UnsortedWork: one-off and older work outside every debate, each with "Add to debate". */ -function UnsortedList({ - entries, - debates, - userId, - onAttached, -}: { - entries: UnsortedEntry[]; - debates: Debate[]; - userId: string | null; - onAttached: (entry: UnsortedEntry) => void; -}) { +/** The website's UnsortedWork with nothing in it. Its rows ("Add to debate") are UnsortedRow. */ +function UnsortedEmpty({ notice }: { notice: string | null }) { const { t } = useTranslation(); - const [notice, setNotice] = useState(null); - if (entries.length === 0) { - return ( - - - {t('debate.unsortedEmpty')} - - - {t('debate.unsortedEmptyDesc')} - - {notice ? ( - - {notice} - - ) : null} - - ); - } return ( - <> + + + {t('debate.unsortedEmpty')} + - {t('debate.unsortedDesc')} + {t('debate.unsortedEmptyDesc')} {notice ? ( - + {notice} ) : null} - {entries.map((entry, index) => ( - - { - setNotice(t('debate.attached')); - onAttached(entry); - }} - /> - - ))} - + ); } -function UnsortedRow({ entry, debates, userId, onAttached }: { entry: UnsortedEntry; debates: Debate[]; userId: string | null; onAttached: () => void }) { +/** + * One unsorted item with "Add to debate". The list is virtualized, so the row may unmount while + * scrolled away: the debate picked and the request in flight belong to the screen (`choice`, `busy`, + * `onAttach`), and only the open menu is the row's own. + */ +function UnsortedRow({ + entry, + debates, + choice, + busy, + onChoose, + onAttach, +}: { + entry: UnsortedEntry; + debates: Debate[]; + /** The debate picked for it, if the student changed the suggestion. */ + choice: string | undefined; + busy: boolean; + onChoose: (debateId: string) => void; + onAttach: (debateId: string) => void; +}) { const { t } = useTranslation(); const router = useRouter(); const { colors } = useTheme(); const suggestion = suggestDebate(debates, entry.kind === 'source' ? [entry.title] : [entry.motion, entry.title]); - const [target, setTarget] = useState(suggestion?.id ?? ''); + const target = choice ?? suggestion?.id ?? ''; + const setTarget = onChoose; const [picking, setPicking] = useState(false); - const [busy, setBusy] = useState(false); - const live = useRef(true); - useEffect(() => () => { - live.current = false; - }, []); const chosen = debates.find((debate) => debate.id === target); const href = entry.kind === 'source' ? null : historyHref(entry); const title = entry.title || t(`debate.kinds.${entry.kind}`); - const attach = async () => { - if (!chosen || !userId || busy) return; - setBusy(true); - try { - await attachToDebate(userId, entry, chosen.id); - if (live.current) onAttached(); - } catch (cause) { - console.warn('Could not add to the debate:', cause instanceof Error ? cause.message : cause); - if (live.current) showNotice(t('debate.attachFailed')); - } finally { - if (live.current) setBusy(false); - } + const attach = () => { + if (!chosen || busy) return; + onAttach(chosen.id); }; return ( @@ -574,7 +647,7 @@ function UnsortedRow({ entry, debates, userId, onAttached }: { entry: UnsortedEn setTarget(key); }} /> - void attach()} /> + ) : null} @@ -589,7 +662,7 @@ const styles = StyleSheet.create({ scope: { flexDirection: 'row', alignItems: 'center', gap: 12, borderWidth: 2, borderRadius: 14, paddingVertical: 11, paddingHorizontal: 14 }, tabRow: { marginHorizontal: -20, flexGrow: 0 }, tabs: { gap: 8, paddingHorizontal: 20 }, - list: { padding: 0, gap: 0, overflow: 'hidden' }, + gap: { height: 16 }, row: { flexDirection: 'row', alignItems: 'center', gap: 13, paddingVertical: 12, paddingHorizontal: 14 }, rowText: { flex: 1, gap: 2 }, rowTitle: { fontSize: 16, lineHeight: 21 }, diff --git a/apps/mobile/src/app/new-note.tsx b/apps/mobile/src/app/new-note.tsx index 212a886d..8ffa0e3d 100644 --- a/apps/mobile/src/app/new-note.tsx +++ b/apps/mobile/src/app/new-note.tsx @@ -1,5 +1,5 @@ -import { useRouter } from 'expo-router'; -import { useState } from 'react'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { StyleSheet, View } from 'react-native'; @@ -13,12 +13,14 @@ import { useAuth } from '@/lib/auth'; import { useDebates } from '@/lib/debate-context'; import { haptics } from '@/lib/haptics'; import { queueAddSource } from '@/lib/offline-queue'; +import { forgetShared, sharedItem } from '@/lib/share-intent'; import { useLibraryFolders } from '@/lib/use-library-folders'; import { useTheme } from '@/theme/use-theme'; /** * Write a note: a text source in the Current Debate, like the website's manual * entry. Offline it is kept on the phone and added once the connection is back. + * Text shared from another app (share-intent.ts) opens it filled in. */ export default function NewNoteSheet() { const { t } = useTranslation(); @@ -26,8 +28,12 @@ export default function NewNoteSheet() { const { colors } = useTheme(); const { session } = useAuth(); const { current } = useDebates(); - const [title, setTitle] = useState(''); - const [content, setContent] = useState(''); + const { shared } = useLocalSearchParams<{ shared?: string }>(); + const [title, setTitle] = useState(() => (shared ? sharedItem('text')?.title ?? '' : '')); + const [content, setContent] = useState(() => (shared ? sharedItem('text')?.text ?? '' : '')); + useEffect(() => { + if (shared) forgetShared(); + }, [shared]); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const toast = useToast(); diff --git a/apps/mobile/src/app/sparring/index.tsx b/apps/mobile/src/app/sparring/index.tsx index 162ab358..36d1494e 100644 --- a/apps/mobile/src/app/sparring/index.tsx +++ b/apps/mobile/src/app/sparring/index.tsx @@ -40,7 +40,8 @@ import { Text } from '@/components/text'; import { TimerRing } from '@/components/timer-ring'; import { ToolTitle, WorkingIn } from '@/components/tool-form'; import { appendDictation, OpponentVoiceBar, VoiceMicControl, VoiceModeSwitch } from '@/components/voice'; -import { useKeyCommand } from '@/lib/key-commands'; +import { useFocusedKeyCommand } from '@/lib/key-commands'; +import { useLatest } from '@/lib/live-value'; import { AIError, isAbortError, untilAborted } from '@/lib/ai-request'; import { useAuth } from '@/lib/auth'; import { backLabel } from '@/lib/back-label'; @@ -135,8 +136,7 @@ export default function SparringScreen() { // A voice round falls back to typing when the microphone is lost. const voiceAvailability = useVoiceAvailability(); const voiceActive = Boolean(session?.voice) && voiceAvailability.available; - const voiceActiveRef = useRef(voiceActive); - voiceActiveRef.current = voiceActive; + const voiceActiveRef = useLatest(voiceActive); // The round judged while this screen watched celebrates once; a round reopened or kept from earlier does not. const [celebrating, setCelebrating] = useState(null); @@ -254,7 +254,7 @@ export default function SparringScreen() { setRequest({ status: 'failed', failure: classify(cause, false) }); openRefusal(router, cause, 'sparring'); } - }, [commit, outputLanguage, refresh, router, runJudge]); + }, [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. const timerRunning = session?.timerRunning ?? false; @@ -342,8 +342,45 @@ export default function SparringScreen() { if (live.current && owner.current === forUser && startAttempt.current === attempt) begin(side, debateId, forUser); }; - // ⌘↩ submits the speech on an iPad keyboard. - useKeyCommand('cmd+enter', () => void submit()); + const move = (change: (round: SparringSession, now: number) => SparringSession) => { + const latest = sessionRef.current; + if (!latest) return; + const next = change(latest, Date.now()); + if (next !== latest) commit(next); + }; + + // The first keystroke or word of a turn starts its clock, and talks over the opponent's voice. + const updateDraft = (draft: string) => move((round, now) => { + const turn = currentSparringTurn(round); + const key = `${round.id}:${turn?.id ?? ''}`; + const awaitingStart = startedTurn.current !== key && !round.timerRunning + && round.timerRemainingSeconds === turn?.timeLimitSeconds; + if (awaitingStart) { + startedTurn.current = key; + opponentVoice.stop(); + } + return { ...(awaitingStart ? resumeSparringTimer(round, now) : round), draft: draft.slice(0, MAX_SPARRING_SPEECH_CHARS), updatedAt: now }; + }); + const resume = () => { + opponentVoice.stop(); + move((round, now) => { + startedTurn.current = `${round.id}:${currentSparringTurn(round)?.id ?? ''}`; + return resumeSparringTimer(round, now); + }); + }; + + // Recognised words join the draft, so the student can see and fix them. They + // never start the clock: speaking starts it (onStart), and the last words + // after a Reset must not set the reset turn running again. + const capture = useSpeechCapture(outputLanguage, (text) => move((round, now) => ({ + ...round, + draft: appendDictation(round.draft, text, MAX_SPARRING_SPEECH_CHARS), + updatedAt: now, + }))); + // Each turn of each round is measured on its own, and a start still waiting for permission does not carry over. + const turnKey = session ? `${session.id}:${currentSparringTurn(session)?.id ?? ''}` : ''; + const cancelCapture = capture.cancel; + useEffect(() => cancelCapture, [cancelCapture, turnKey]); const submit = async () => { const before = sessionRef.current; @@ -370,6 +407,9 @@ export default function SparringScreen() { else void runTurn(next, userId); }; + // ⌘↩ submits the speech on an iPad keyboard. + useFocusedKeyCommand('cmd+enter', () => void submit()); + const retry = () => { const latest = sessionRef.current; if (!latest || !userId) return; @@ -388,46 +428,6 @@ export default function SparringScreen() { commit(null); }; - const move = (change: (round: SparringSession, now: number) => SparringSession) => { - const latest = sessionRef.current; - if (!latest) return; - const next = change(latest, Date.now()); - if (next !== latest) commit(next); - }; - - // The first keystroke or word of a turn starts its clock, and talks over the opponent's voice. - const updateDraft = (draft: string) => move((round, now) => { - const turn = currentSparringTurn(round); - const key = `${round.id}:${turn?.id ?? ''}`; - const awaitingStart = startedTurn.current !== key && !round.timerRunning - && round.timerRemainingSeconds === turn?.timeLimitSeconds; - if (awaitingStart) { - startedTurn.current = key; - opponentVoice.stop(); - } - return { ...(awaitingStart ? resumeSparringTimer(round, now) : round), draft: draft.slice(0, MAX_SPARRING_SPEECH_CHARS), updatedAt: now }; - }); - const resume = () => { - opponentVoice.stop(); - move((round, now) => { - startedTurn.current = `${round.id}:${currentSparringTurn(round)?.id ?? ''}`; - return resumeSparringTimer(round, now); - }); - }; - - // Recognised words join the draft, so the student can see and fix them. They - // never start the clock: speaking starts it (onStart), and the last words - // after a Reset must not set the reset turn running again. - const capture = useSpeechCapture(outputLanguage, (text) => move((round, now) => ({ - ...round, - draft: appendDictation(round.draft, text, MAX_SPARRING_SPEECH_CHARS), - updatedAt: now, - }))); - // Each turn of each round is measured on its own, and a start still waiting for permission does not carry over. - const turnKey = session ? `${session.id}:${currentSparringTurn(session)?.id ?? ''}` : ''; - const cancelCapture = capture.cancel; - useEffect(() => cancelCapture, [cancelCapture, turnKey]); - if (!session) { const turns = buildSparringTurns(format, 'pro'); return ( diff --git a/apps/mobile/src/components/ask-chat-list.tsx b/apps/mobile/src/components/ask-chat-list.tsx index f4630134..88938bd6 100644 --- a/apps/mobile/src/components/ask-chat-list.tsx +++ b/apps/mobile/src/components/ask-chat-list.tsx @@ -1,11 +1,12 @@ import { useFocusEffect } from 'expo-router'; -import { useCallback, useState } from 'react'; +import { useCallback, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { FlatList, Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Card, ListItemIn, SkeletonTextRows } from './card'; import { Icon } from './icon'; +import { CardRowFrame, useRowArrivals } from './list-screen'; import { PressScale } from './press-scale'; import { Text } from './text'; @@ -16,13 +17,20 @@ import { haptics } from '@/lib/haptics'; import { useCopyHolder } from '@/lib/use-copy-holder'; import { useTheme } from '@/theme/use-theme'; -/** Saved chats as rows, with their loading, failed and empty states. */ -export function ChatRows({ +/** + * Saved chats as a list with their loading, failed and empty states, below `header`. It is the + * scroll view itself (a FlatList), so a debate with hundreds of chats draws only those on screen. + */ +export function ChatList({ chats, failed, openChatId, onPick, highlightOpen = false, + header, + style, + contentContainerStyle, + testID, }: { chats: ChatSummary[] | null; failed: boolean; @@ -30,19 +38,23 @@ export function ChatRows({ onPick: (chatId: string) => void; /** The iPad list marks the open chat with a tinted row instead of a check. */ highlightOpen?: boolean; + header: ReactNode; + style?: StyleProp; + contentContainerStyle?: StyleProp; + testID?: string; }) { const { t } = useTranslation(); const { colors } = useTheme(); - if (failed && !chats) { - return ( + const rows = chats ?? []; + const arrivals = useRowArrivals(rows.map((chat) => chat.id)); + const empty = + failed && !chats ? ( {t('mobile.ask.historyFailed')} - ); - } - if (chats === null) return ; - if (chats.length === 0) { - return ( + ) : chats === null ? ( + + ) : ( {t('askChat.noSavedChats')} @@ -52,36 +64,45 @@ export function ChatRows({ ); - } return ( - - {chats.map((chat, index) => { + chat.id} + extraData={openChatId} + style={style} + contentContainerStyle={contentContainerStyle} + testID={testID} + ListHeaderComponent={{header}} + ListEmptyComponent={empty} + renderItem={({ item: chat, index }) => { const open = chat.id === openChatId; return ( - 0 && { borderTopWidth: 1, borderTopColor: colors.line }}> - { - if (!open) haptics.select(); - onPick(chat.id); - }} - testID="ask-chat-row" - style={[styles.row, highlightOpen && open && { backgroundColor: colors.tile }]}> - - - {chat.title} - - - {`${formatDate(new Date(chat.createdAt).toISOString())} · ${t('askChat.messageCount', { count: chat.messageCount })}`} - - - {open && !highlightOpen ? : null} - - + + 0 && { borderTopWidth: 1, borderTopColor: colors.line }}> + { + if (!open) haptics.select(); + onPick(chat.id); + }} + testID="ask-chat-row" + style={[styles.row, highlightOpen && open && { backgroundColor: colors.tile }]}> + + + {chat.title} + + + {`${formatDate(new Date(chat.createdAt).toISOString())} · ${t('askChat.messageCount', { count: chat.messageCount })}`} + + + {open && !highlightOpen ? : null} + + + ); - })} - + }} + /> ); } @@ -152,19 +173,26 @@ export function AskChatPane({ - - - {t('nav.ask')} - - - + + {t('nav.ask')} + + } + /> ); } const styles = StyleSheet.create({ body: { fontSize: 15, lineHeight: 21 }, - list: { padding: 0, gap: 0 }, + listHeader: { gap: 14, paddingBottom: 14 }, row: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 13, paddingHorizontal: 16 }, rowText: { flex: 1, gap: 3 }, rowTitle: { fontSize: 16, lineHeight: 21 }, @@ -172,6 +200,6 @@ const styles = StyleSheet.create({ pane: { borderRightWidth: StyleSheet.hairlineWidth }, paneTop: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 12, paddingBottom: 4 }, iconButton: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, - paneContent: { paddingHorizontal: 18, gap: 14 }, + paneContent: { paddingHorizontal: 18 }, paneTitle: { fontSize: 32, lineHeight: 38, letterSpacing: -0.9 }, }); diff --git a/apps/mobile/src/components/ask-history.tsx b/apps/mobile/src/components/ask-history.tsx index ebed11e4..1aabb7a0 100644 --- a/apps/mobile/src/components/ask-history.tsx +++ b/apps/mobile/src/components/ask-history.tsx @@ -1,9 +1,9 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Modal, ScrollView, StyleSheet, View } from 'react-native'; +import { Modal, StyleSheet, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { ChatRows } from './ask-chat-list'; +import { ChatList } from './ask-chat-list'; import { PrimaryButton, TextButton } from './buttons'; import { Text } from './text'; @@ -60,25 +60,32 @@ export function AskHistory({ return ( - - - - {t('askChat.savedChats')} - - - - - - + testID="ask-history" + header={ + <> + + + {t('askChat.savedChats')} + + + + + + } + /> ); } const styles = StyleSheet.create({ - content: { paddingHorizontal: 20, paddingTop: 22, gap: 14, width: '100%', maxWidth: 640, alignSelf: 'center' }, + content: { paddingHorizontal: 20, paddingTop: 22, width: '100%', maxWidth: 640, alignSelf: 'center' }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, title: { fontSize: 21, lineHeight: 26, letterSpacing: -0.3, flexShrink: 1 }, }); diff --git a/apps/mobile/src/components/card.tsx b/apps/mobile/src/components/card.tsx index 3ae84c7c..6c1963cb 100644 --- a/apps/mobile/src/components/card.tsx +++ b/apps/mobile/src/components/card.tsx @@ -31,12 +31,14 @@ export function IconTile({ name, size = 40 }: { name: IconName; size?: number }) * mounts. With `layout`, rows slide into a gap a removed row leaves. Both are Reanimated layout * animations on the UI thread and follow the system's Reduce Motion (ReduceMotion.System), so * under it the row is simply there. Keep press feedback on a view inside it, never on this one. + * In a virtualized list, `enter` says whether the row is arriving (row-arrivals.ts) or only + * scrolled back into view, and the list animates layout itself (`itemLayoutAnimation`). */ -export function ListItemIn({ index, layout = false, style, children }: { index: number; layout?: boolean; style?: StyleProp; children: ReactNode }) { +export function ListItemIn({ index, layout = false, enter = true, style, children }: { index: number; layout?: boolean; enter?: boolean; style?: StyleProp; children: ReactNode }) { const reduce = useReduceMotion(); return ( {children} diff --git a/apps/mobile/src/components/list-screen.tsx b/apps/mobile/src/components/list-screen.tsx new file mode 100644 index 00000000..67bb56e9 --- /dev/null +++ b/apps/mobile/src/components/list-screen.tsx @@ -0,0 +1,102 @@ +import { useState, type ReactElement, type ReactNode } from 'react'; +import { RefreshControl, StyleSheet, View, type FlatListProps, type StyleProp, type ViewStyle } from 'react-native'; +import Animated, { LinearTransition } from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { OfflineNotice } from './offline-notice'; + +import { HOME_MAX_WIDTH } from '@/lib/layout'; +import { createRowArrivals } from '@/lib/row-arrivals'; +import { radius } from '@/theme/colors'; +import { motionSetting, useReduceMotion } from '@/theme/motion'; +import { useTheme } from '@/theme/use-theme'; + +// Reanimated's FlatList draws its own cells (for itemLayoutAnimation). +type ListScreenProps = Omit, 'ListHeaderComponent' | 'contentContainerStyle' | 'CellRendererComponent' | 'onRefresh' | 'refreshing'> & { + /** What `Screen` would show above the list: titles, pickers, search. */ + header?: ReactNode; + safeTop?: boolean; + wide?: boolean; + /** The column's style when the list sits in an iPad pane instead of a tab. */ + contentStyle?: StyleProp; + /** Rows slide into the gap a removed row leaves (Reanimated, on the UI thread; still under Reduce Motion). */ + animateLayout?: boolean; + /** Pull to refresh: the spinner shows until the reload settles. */ + onRefresh?: () => Promise; +}; + +/** + * `Screen` for a list that can grow without bound (History, the Library, saved + * chats): the same column, insets, keyboard handling and offline notice, but + * 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 { + const insets = useSafeAreaInsets(); + const reduce = useReduceMotion(); + const { colors } = useTheme(); + const [refreshing, setRefreshing] = useState(false); + const refresh = onRefresh + ? () => { + setRefreshing(true); + void onRefresh().finally(() => setRefreshing(false)); + } + : undefined; + return ( + : undefined} + {...list} + ListHeaderComponent={ + + + {header} + + } + contentContainerStyle={contentStyle ?? [styles.content, wide && styles.wide, safeTop && { paddingTop: insets.top + 12 }]} + /> + ); +} + +/** + * One row of a list drawn as a single card (the 2pt edge and rounded corners + * `Card` gives a list), so each row can be its own FlatList cell. + */ +export function CardRowFrame({ index, count, children }: { index: number; count: number; children: ReactNode }) { + const { colors } = useTheme(); + return ( + + {children} + + ); +} + +/** + * Which rows of a virtualized list arrive with ListItemIn's animation: pass the + * list's keys on every render and ask `entersNow(key)` when a row mounts. + */ +export function useRowArrivals(keys: readonly string[]) { + const [arrivals] = useState(() => createRowArrivals()); + arrivals.update(keys); + return arrivals; +} + +const styles = StyleSheet.create({ + // The same column as Screen: phone width on iPad unless `wide`. + 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 }, + frame: { borderLeftWidth: 2, borderRightWidth: 2, overflow: 'hidden' }, + frameFirst: { borderTopWidth: 2, borderTopLeftRadius: radius.card, borderTopRightRadius: radius.card }, + frameLast: { borderBottomWidth: 2, borderBottomLeftRadius: radius.card, borderBottomRightRadius: radius.card }, +}); diff --git a/apps/mobile/src/components/source-row.tsx b/apps/mobile/src/components/source-row.tsx index 65190e01..49452eb1 100644 --- a/apps/mobile/src/components/source-row.tsx +++ b/apps/mobile/src/components/source-row.tsx @@ -168,13 +168,19 @@ export function SourceRow({ accessibilityRole="button" accessibilityLabel={t('mobile.common.openItem', { title })} accessibilityState={selected ? { selected } : undefined} + // VoiceOver offers the long-press menu's actions on the row itself (swipe up or down). accessibilityActions={[ { name: 'toggle', label: toggleLabel }, + { name: 'copy', label: t('common.copy') }, + { name: 'share', label: t('mobile.library.share') }, { name: 'delete', label: t('common.delete') }, ]} onAccessibilityAction={(event) => { - if (event.nativeEvent.actionName === 'toggle') toggle(); - if (event.nativeEvent.actionName === 'delete') void remove(); + const name = event.nativeEvent.actionName; + if (name === 'toggle') toggle(); + else if (name === 'copy') actions.onCopy(); + else if (name === 'share') actions.onShare(); + else if (name === 'delete') void remove(); }} onPressIn={press.onPressIn} onPressOut={press.onPressOut} diff --git a/apps/mobile/src/components/work-row.tsx b/apps/mobile/src/components/work-row.tsx index 4c4c7cdf..e80b187a 100644 --- a/apps/mobile/src/components/work-row.tsx +++ b/apps/mobile/src/components/work-row.tsx @@ -80,10 +80,30 @@ export function WorkRow({ const href = from ? workHref(item, from) : null; // Practice work opens the Practice tab, which has nothing to preview, so it is a plain row that navigates itself. const linked = href !== null && href !== '/practice'; + const document = hasDocumentText(item); + const remove = () => { + void actions.remove(item).then((removed) => { + if (removed) onDeleted?.(); + }); + }; + // VoiceOver offers the long-press menu's actions on the row itself (swipe up or down). + const a11yActions = linked + ? [ + ...(document ? [{ name: 'discuss', label: t('common.discuss') }, { name: 'share', label: t('mobile.library.share') }] : []), + ...(canDeleteWork(item) ? [{ name: 'delete', label: t('common.delete') }] : []), + ] + : []; const row = ( 0 ? a11yActions : undefined} + onAccessibilityAction={(event) => { + const name = event.nativeEvent.actionName; + if (name === 'discuss') void actions.discuss(item); + else if (name === 'share') void actions.share(item); + else if (name === 'delete') remove(); + }} onPress={linked ? onPress : onPress ?? (href ? () => router.navigate(href) : undefined)} onPressIn={press.onPressIn} onPressOut={press.onPressOut} @@ -107,7 +127,6 @@ export function WorkRow({ ); if (!linked) return row; - const document = hasDocumentText(item); return ( {row} @@ -124,14 +143,7 @@ export function WorkRow({ ) : null} {canDeleteWork(item) ? ( - { - void actions.remove(item).then((removed) => { - if (removed) onDeleted?.(); - }); - }}> + {t('common.delete')} ) : null} diff --git a/apps/mobile/src/lib/debate-context.tsx b/apps/mobile/src/lib/debate-context.tsx index c9870511..23f8d3e3 100644 --- a/apps/mobile/src/lib/debate-context.tsx +++ b/apps/mobile/src/lib/debate-context.tsx @@ -2,7 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use import { AppState } from 'react-native'; import { publishCurrentDebate } from './debate-widget'; -import { clearSpotlight } from './spotlight'; +import { clearSpotlight, clearSpotlightDomain } from './spotlight'; import { useAuth } from './auth'; import { continueDebate, @@ -102,6 +102,13 @@ export function DebateProvider({ children }: { children: ReactNode }) { useEffect(() => { if (!userId) void clearSpotlight(); }, [userId]); + // Spotlight holds the Current Debate's documents only; another debate, or none, empties it until the Debate tab fills it again. + const indexedDebate = useRef(currentId); + useEffect(() => { + if (indexedDebate.current === currentId) return; + indexedDebate.current = currentId; + void clearSpotlightDomain('work'); + }, [currentId]); useEffect(() => { publishCurrentDebate(userId && currentId && currentMotion ? { debateId: currentId, motion: currentMotion, counts: null } : null); }, [userId, currentId, currentMotion]); diff --git a/apps/mobile/src/lib/debates.ts b/apps/mobile/src/lib/debates.ts index 41a8ef23..29261ad5 100644 --- a/apps/mobile/src/lib/debates.ts +++ b/apps/mobile/src/lib/debates.ts @@ -355,6 +355,18 @@ async function loadRecentWork(userId: string, debateId: string, side: DebateSide return newestFirst(lists, limit); } +/** The tables of work that opens as a document, whatever its side: what Spotlight indexes. */ +const DOCUMENT_TABLES = ['cases', 'attacks', 'brainstorms', 'factoids'] as const satisfies readonly ResultTable[]; + +/** + * A debate's newest documents of every side, neutral included (attacks and factoids save as + * neutral), for Spotlight (spotlight-items.ts). Not kept offline: Spotlight is updated online only. + */ +export async function fetchDocumentsForSpotlight(userId: string, debateId: string, limit: number): Promise { + const lists = await readAll(DOCUMENT_TABLES.map((table) => fetchResultRows(userId, debateId, table, null, limit))); + return newestFirst(lists, limit); +} + /** The Prepare tools' tables. */ const PREPARE_TABLES = ['cases', 'brainstorms', 'factoids', 'flowcharts'] as const satisfies readonly ResultTable[]; const PREPARE_KINDS = new Set(PREPARE_TABLES.map((table) => KIND_BY_TABLE[table])); diff --git a/apps/mobile/src/lib/key-command-registry.test.ts b/apps/mobile/src/lib/key-command-registry.test.ts new file mode 100644 index 00000000..370ebde5 --- /dev/null +++ b/apps/mobile/src/lib/key-command-registry.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { comboForKey, createKeyCommandRegistry, isKeyCombo } from './key-command-registry'; + +const press = (key: string, modifiers: Partial<{ metaKey: boolean; ctrlKey: boolean; altKey: boolean; shiftKey: boolean }> = {}) => ({ + key, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + ...modifiers, +}); + +describe('comboForKey', () => { + it('reads ⌘N, ⌘K and ⌘↩, also with Ctrl and a capital letter', () => { + expect(comboForKey(press('n', { metaKey: true }))).toBe('cmd+n'); + expect(comboForKey(press('K', { metaKey: true }))).toBe('cmd+k'); + expect(comboForKey(press('Enter', { ctrlKey: true }))).toBe('cmd+enter'); + }); + + it('ignores plain keys, other letters and extra modifiers', () => { + expect(comboForKey(press('n'))).toBeNull(); + expect(comboForKey(press('Enter'))).toBeNull(); + expect(comboForKey(press('j', { metaKey: true }))).toBeNull(); + expect(comboForKey(press('n', { metaKey: true, shiftKey: true }))).toBeNull(); + expect(comboForKey(press('k', { metaKey: true, altKey: true }))).toBeNull(); + }); +}); + +describe('isKeyCombo', () => { + it('accepts only the three combos the native module may send', () => { + expect(isKeyCombo('cmd+enter')).toBe(true); + expect(isKeyCombo('cmd+q')).toBe(false); + expect(isKeyCombo(undefined)).toBe(false); + }); +}); + +describe('createKeyCommandRegistry', () => { + it('runs only the newest handler for a combo, and the older one again once the newer leaves', () => { + const registry = createKeyCommandRegistry(); + const below = vi.fn(); + const above = vi.fn(); + registry.register('cmd+enter', below); + const leave = registry.register('cmd+enter', above); + + expect(registry.dispatch('cmd+enter')).toBe(true); + expect(above).toHaveBeenCalledTimes(1); + expect(below).not.toHaveBeenCalled(); + + leave(); + registry.dispatch('cmd+enter'); + expect(below).toHaveBeenCalledTimes(1); + expect(above).toHaveBeenCalledTimes(1); + }); + + it('keeps combos apart and reports a press nobody listens for', () => { + const registry = createKeyCommandRegistry(); + const fresh = vi.fn(); + registry.register('cmd+n', fresh); + expect(registry.dispatch('cmd+k')).toBe(false); + expect(fresh).not.toHaveBeenCalled(); + }); + + it('tells subscribers only when the set of combos changes, in a fixed order', () => { + const registry = createKeyCommandRegistry(); + const seen: string[][] = []; + registry.subscribe((active) => seen.push(active)); + const a = registry.register('cmd+enter', () => undefined); + const b = registry.register('cmd+n', () => undefined); + const c = registry.register('cmd+n', () => undefined); + b(); + c(); + a(); + expect(seen).toEqual([['cmd+enter'], ['cmd+n', 'cmd+enter'], ['cmd+enter'], []]); + }); + + it('removes a registration once, even when its cleanup runs twice', () => { + const registry = createKeyCommandRegistry(); + const kept = vi.fn(); + registry.register('cmd+k', kept); + const leave = registry.register('cmd+k', () => undefined); + leave(); + leave(); + registry.dispatch('cmd+k'); + expect(kept).toHaveBeenCalledTimes(1); + expect(registry.active()).toEqual(['cmd+k']); + }); +}); diff --git a/apps/mobile/src/lib/key-command-registry.ts b/apps/mobile/src/lib/key-command-registry.ts new file mode 100644 index 00000000..63dc8b41 --- /dev/null +++ b/apps/mobile/src/lib/key-command-registry.ts @@ -0,0 +1,91 @@ +/** + * The iPad's ⌘ shortcuts, shared by the native module (key-commands.ts) and the + * web preview (key-commands.web.ts). A combo can be wanted by several screens at + * once (the root layout's ⌘N under a screen that also listens, Ask above + * Crossfire in the stack); only the newest enabled registration runs, so one + * press never acts twice. No React Native or Expo imports, so it is unit-tested + * in Node. + */ + +export type KeyCombo = 'cmd+n' | 'cmd+k' | 'cmd+enter'; + +/** Every combo, in the order iOS lists them in the ⌘ overlay. */ +export const KEY_COMBOS: readonly KeyCombo[] = ['cmd+n', 'cmd+k', 'cmd+enter']; + +/** What UIKeyCommand reads: the key's input and the catalog key of the title the ⌘ overlay shows. */ +export const KEY_COMMAND_SPECS: Record = { + 'cmd+n': { input: 'n', titleKey: 'mobile.debates.newDebate' }, + 'cmd+k': { input: 'k', titleKey: 'debate.switch' }, + 'cmd+enter': { input: '\r', titleKey: 'askChat.send' }, +}; + +export function isKeyCombo(value: unknown): value is KeyCombo { + return typeof value === 'string' && (KEY_COMBOS as readonly string[]).includes(value); +} + +/** A keydown as the web preview reports it. */ +export type KeyPress = { key: string; metaKey: boolean; ctrlKey: boolean; altKey: boolean; shiftKey: boolean }; + +/** The combo a key press is, or null: ⌘ (or Ctrl, for the preview on other systems) with no other modifier. */ +export function comboForKey(press: KeyPress): KeyCombo | null { + if (!(press.metaKey || press.ctrlKey) || press.altKey || press.shiftKey) return null; + if (press.key === 'Enter') return 'cmd+enter'; + const key = press.key.toLowerCase(); + if (key === 'n') return 'cmd+n'; + if (key === 'k') return 'cmd+k'; + return null; +} + +export type KeyCommandRegistry = { + /** Adds a handler; the returned function removes exactly this one. */ + register: (combo: KeyCombo, handler: () => void) => () => void; + /** Runs the newest handler for the combo. False when nothing listens. */ + dispatch: (combo: KeyCombo) => boolean; + /** The combos something listens for, in KEY_COMBOS order. */ + active: () => KeyCombo[]; + /** Called whenever `active()` changes. */ + subscribe: (listener: (active: KeyCombo[]) => void) => () => void; +}; + +export function createKeyCommandRegistry(): KeyCommandRegistry { + let next = 0; + const entries: { id: number; combo: KeyCombo; handler: () => void }[] = []; + const listeners = new Set<(active: KeyCombo[]) => void>(); + const active = () => KEY_COMBOS.filter((combo) => entries.some((entry) => entry.combo === combo)); + let shown = ''; + const changed = () => { + const now = active(); + const key = now.join(','); + if (key === shown) return; + shown = key; + listeners.forEach((listener) => listener(now)); + }; + return { + register(combo, handler) { + const id = ++next; + entries.push({ id, combo, handler }); + changed(); + return () => { + const at = entries.findIndex((entry) => entry.id === id); + if (at === -1) return; + entries.splice(at, 1); + changed(); + }; + }, + dispatch(combo) { + for (let at = entries.length - 1; at >= 0; at -= 1) { + if (entries[at].combo !== combo) continue; + entries[at].handler(); + return true; + } + return false; + }, + active, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} diff --git a/apps/mobile/src/lib/key-commands.ts b/apps/mobile/src/lib/key-commands.ts index 56768c26..961b6684 100644 --- a/apps/mobile/src/lib/key-commands.ts +++ b/apps/mobile/src/lib/key-commands.ts @@ -1,10 +1,73 @@ +import { requireOptionalNativeModule } from 'expo'; +import { useFocusEffect } from 'expo-router'; +import i18n from 'i18next'; +import { useCallback, useEffect, useRef } from 'react'; + +import { createKeyCommandRegistry, isKeyCombo, KEY_COMMAND_SPECS, type KeyCombo } from './key-command-registry'; +import { nativeCapabilities } from './native-capabilities'; + +export type { KeyCombo } from './key-command-registry'; + /** - * iPad hardware-keyboard shortcuts (⌘N, ⌘K, ⌘↩) need UIKeyCommand, which - * neither React Native nor Expo Go exposes. Until the development build - * carries a key-command module, the native app registers nothing and the - * shortcuts work in the web preview only (key-commands.web.ts). - * TODO(key-commands): wire a dev-build module here behind nativeCapabilities(). + * iPad hardware-keyboard shortcuts (⌘N, ⌘K, ⌘↩) through the app's own module, + * modules/key-commands (UIKeyCommand, with titles for the ⌘ overlay). It is in + * development builds only; Expo Go and a build without it register nothing. + * The web preview has key-commands.web.ts. */ -export type KeyCombo = 'cmd+n' | 'cmd+k' | 'cmd+enter'; -export function useKeyCommand(_combo: KeyCombo, _handler: () => void, _enabled = true): void {} +type KeyCommandsModule = { + setCommands: (commands: { id: KeyCombo; input: string; title: string }[]) => void; + addListener: (event: 'onKeyCommand', listener: (event: { id?: unknown }) => void) => { remove: () => void }; +}; + +const registry = createKeyCommandRegistry(); +let started = false; + +function start() { + if (started) return; + started = true; + if (!nativeCapabilities().keyCommands) return; + const native = requireOptionalNativeModule('DebateOwlKeyCommands'); + if (!native) return; + native.addListener('onKeyCommand', (event) => { + if (isKeyCombo(event.id)) registry.dispatch(event.id); + }); + const publish = () => { + try { + native.setCommands(registry.active().map((id) => ({ id, input: KEY_COMMAND_SPECS[id].input, title: i18n.t(KEY_COMMAND_SPECS[id].titleKey) }))); + } catch (cause) { + console.warn('Could not set the keyboard shortcuts:', cause instanceof Error ? cause.message : cause); + } + }; + registry.subscribe(publish); + // The ⌘ overlay's titles follow the app's language. + i18n.on('languageChanged', publish); + publish(); +} + +/** Runs `handler` on the combo while `enabled`. Of several screens listening, the newest wins. */ +export function useKeyCommand(combo: KeyCombo, handler: () => void, enabled = true): void { + const latest = useRef(handler); + useEffect(() => { + latest.current = handler; + }); + useEffect(() => { + if (!enabled) return; + start(); + return registry.register(combo, () => latest.current()); + }, [combo, enabled]); +} + +/** useKeyCommand for a screen: it listens only while the screen is in view, never from under the one above it. */ +export function useFocusedKeyCommand(combo: KeyCombo, handler: () => void): void { + const latest = useRef(handler); + useEffect(() => { + latest.current = handler; + }); + useFocusEffect( + useCallback(() => { + start(); + return registry.register(combo, () => latest.current()); + }, [combo]), + ); +} diff --git a/apps/mobile/src/lib/key-commands.web.ts b/apps/mobile/src/lib/key-commands.web.ts index 2413b1e7..4ee49c84 100644 --- a/apps/mobile/src/lib/key-commands.web.ts +++ b/apps/mobile/src/lib/key-commands.web.ts @@ -1,25 +1,45 @@ -import { useEffect, useRef } from 'react'; +import { useFocusEffect } from 'expo-router'; +import { useCallback, useEffect, useRef } from 'react'; -export type KeyCombo = 'cmd+n' | 'cmd+k' | 'cmd+enter'; +import { comboForKey, createKeyCommandRegistry, type KeyCombo } from './key-command-registry'; -function matches(event: KeyboardEvent, combo: KeyCombo): boolean { - if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return false; - if (combo === 'cmd+enter') return event.key === 'Enter'; - return event.key.toLowerCase() === combo.slice(-1); +export type { KeyCombo } from './key-command-registry'; + +const registry = createKeyCommandRegistry(); +let listening = false; + +function listen() { + if (listening) return; + listening = true; + window.addEventListener('keydown', (event) => { + const combo = comboForKey(event); + if (combo && registry.dispatch(combo)) event.preventDefault(); + }); } -/** The iPad's ⌘ shortcuts in the web preview, from the same hook the native app calls (key-commands.ts). */ +/** The iPad's ⌘ shortcuts in the web preview, from the same hook and registry the native app uses (key-commands.ts). */ export function useKeyCommand(combo: KeyCombo, handler: () => void, enabled = true): void { const latest = useRef(handler); - latest.current = handler; + useEffect(() => { + latest.current = handler; + }); useEffect(() => { if (!enabled) return; - const onKey = (event: KeyboardEvent) => { - if (!matches(event, combo)) return; - event.preventDefault(); - latest.current(); - }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); + listen(); + return registry.register(combo, () => latest.current()); }, [combo, enabled]); } + +/** useKeyCommand for a screen: it listens only while the screen is in view, never from under the one above it. */ +export function useFocusedKeyCommand(combo: KeyCombo, handler: () => void): void { + const latest = useRef(handler); + useEffect(() => { + latest.current = handler; + }); + useFocusEffect( + useCallback(() => { + listen(); + return registry.register(combo, () => latest.current()); + }, [combo]), + ); +} diff --git a/apps/mobile/src/lib/live-value.test.ts b/apps/mobile/src/lib/live-value.test.ts new file mode 100644 index 00000000..a83afc46 --- /dev/null +++ b/apps/mobile/src/lib/live-value.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createLiveValue } from './live-value'; + +describe('createLiveValue', () => { + it('tells subscribers about each change, with updates applied in order', () => { + const value = createLiveValue(null); + const listener = vi.fn(); + value.subscribe(listener); + value.set(''); + value.set((previous) => `${previous ?? ''}Sugar `); + value.set((previous) => `${previous ?? ''}taxes`); + expect(value.get()).toBe('Sugar taxes'); + expect(listener).toHaveBeenCalledTimes(3); + }); + + it('stays quiet when the value does not change', () => { + const value = createLiveValue(5); + const listener = vi.fn(); + value.subscribe(listener); + value.set(5); + value.set((previous) => previous); + expect(listener).not.toHaveBeenCalled(); + }); + + it('stops telling a listener once it unsubscribes', () => { + const value = createLiveValue(0); + const listener = vi.fn(); + const stop = value.subscribe(listener); + stop(); + value.set(1); + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/lib/live-value.ts b/apps/mobile/src/lib/live-value.ts new file mode 100644 index 00000000..b2c17804 --- /dev/null +++ b/apps/mobile/src/lib/live-value.ts @@ -0,0 +1,58 @@ +import { useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react'; + +/** + * A value that changes many times a second (an answer streaming in, a clock) + * kept outside React state, so only the small components that show it render + * again, never the whole screen around them. A screen subscribes to a coarse + * reading (`useLiveValue(value, select)`) when it needs one, such as whether + * any text has arrived; it then renders only when that reading changes. + */ +export type LiveValue = { + get: () => T; + set: (next: T | ((previous: T) => T)) => void; + subscribe: (listener: () => void) => () => void; +}; + +export function createLiveValue(initial: T): LiveValue { + let value = initial; + const listeners = new Set<() => void>(); + return { + get: () => value, + set(next) { + const resolved = typeof next === 'function' ? (next as (previous: T) => T)(value) : next; + if (Object.is(resolved, value)) return; + value = resolved; + listeners.forEach((listener) => listener()); + }, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} + +/** One live value for the component's lifetime. */ +export function useLiveValueStore(initial: T): LiveValue { + const [store] = useState(() => createLiveValue(initial)); + return store; +} + +/** Reads the value, or a reading of it; the component renders again only when that reading changes. */ +export function useLiveValue(store: LiveValue, select?: (value: T) => R): R { + const read = () => (select ? select(store.get()) : (store.get() as unknown as R)); + return useSyncExternalStore(store.subscribe, read, read); +} + +/** + * The latest value, for effects and callbacks that must not re-run or be + * recreated when it changes. Written after render, as React Compiler requires. + */ +export function useLatest(value: T): { readonly current: T } { + const ref = useRef(value); + useLayoutEffect(() => { + ref.current = value; + }); + return ref; +} diff --git a/apps/mobile/src/lib/native-capabilities.test.ts b/apps/mobile/src/lib/native-capabilities.test.ts index 41f42bb8..26c7e5ae 100644 --- a/apps/mobile/src/lib/native-capabilities.test.ts +++ b/apps/mobile/src/lib/native-capabilities.test.ts @@ -31,8 +31,10 @@ describe('nativeCapabilities', () => { ExpoWidgets: { widgetsDirectory: 'file:///group/ExpoWidgets/' }, ExpoQuickActions: {}, ExpoCoreSpotlight: {}, + DebateOwlKeyCommands: {}, + ExpoShareIntentModule: {}, }; - expect(nativeCapabilities()).toEqual({ speechRecognition: false, liveActivity: false, widgets: false, quickActions: false, spotlight: false }); + expect(nativeCapabilities()).toEqual({ speechRecognition: false, liveActivity: false, widgets: false, quickActions: false, spotlight: false, keyCommands: false, shareIntent: false }); expect(requireOptionalNativeModule).not.toHaveBeenCalled(); }); @@ -45,6 +47,8 @@ describe('nativeCapabilities', () => { widgets: false, quickActions: false, spotlight: false, + keyCommands: false, + shareIntent: false, }); }); @@ -57,24 +61,28 @@ describe('nativeCapabilities', () => { widgets: true, quickActions: false, spotlight: false, + keyCommands: false, + shareIntent: false, }); }); - it('reports quick actions and Spotlight once their modules are linked', () => { + it('reports quick actions, Spotlight, shortcuts and sharing once their modules are linked', () => { env.executionEnvironment = 'bare'; - env.modules = { ExpoQuickActions: {}, ExpoCoreSpotlight: {} }; + env.modules = { ExpoQuickActions: {}, ExpoCoreSpotlight: {}, DebateOwlKeyCommands: {}, ExpoShareIntentModule: {} }; expect(nativeCapabilities()).toEqual({ speechRecognition: false, liveActivity: false, widgets: false, quickActions: true, spotlight: true, + keyCommands: true, + shareIntent: true, }); }); it('reports nothing where the modules are missing, as in the web preview', () => { env.executionEnvironment = 'bare'; env.modules = {}; - expect(nativeCapabilities()).toEqual({ speechRecognition: false, liveActivity: false, widgets: false, quickActions: false, spotlight: false }); + expect(nativeCapabilities()).toEqual({ speechRecognition: false, liveActivity: false, widgets: false, quickActions: false, spotlight: false, keyCommands: false, shareIntent: false }); }); }); diff --git a/apps/mobile/src/lib/native-capabilities.ts b/apps/mobile/src/lib/native-capabilities.ts index 6519bee6..111f7984 100644 --- a/apps/mobile/src/lib/native-capabilities.ts +++ b/apps/mobile/src/lib/native-capabilities.ts @@ -4,10 +4,8 @@ import Constants, { ExecutionEnvironment } from 'expo-constants'; // What the running binary can do beyond Expo Go. Autolinking puts these // modules into every native build; DEBATELAB_BUILD=dev in app.config.ts adds // what they need to work (the speech prompts, and with an App Group the -// widget extension). Quick actions and Spotlight need packages that are not -// installed yet; until they are, their modules are simply missing. Expo Go -// never has them, so it is not even asked: no import of a module that is -// missing, no crash. +// widget extension, and the share extension). Expo Go never has them, so it +// is not even asked: no import of a module that is missing, no crash. export type NativeCapabilities = { /** expo-speech-recognition is linked. */ @@ -20,13 +18,17 @@ export type NativeCapabilities = { quickActions: boolean; /** expo-core-spotlight is linked, so debates and saved cases can be indexed (src/lib/spotlight.ts). */ spotlight: boolean; + /** The app's own modules/key-commands is linked, so iPad ⌘ shortcuts reach the app (src/lib/key-commands.ts). */ + keyCommands: boolean; + /** expo-share-intent is linked, so text and links shared from other apps reach the app (src/lib/share-intent.ts). */ + shareIntent: boolean; }; type WidgetsModule = { widgetsDirectory?: string | null }; export function nativeCapabilities(): NativeCapabilities { if (Constants.executionEnvironment === ExecutionEnvironment.StoreClient) { - return { speechRecognition: false, liveActivity: false, widgets: false, quickActions: false, spotlight: false }; + return { speechRecognition: false, liveActivity: false, widgets: false, quickActions: false, spotlight: false, keyCommands: false, shareIntent: false }; } // expo-widgets links into every development build, but without the App // Group (DEBATELAB_APP_GROUP) it has no extension and no container. @@ -38,5 +40,7 @@ export function nativeCapabilities(): NativeCapabilities { widgets: widgetContainer, quickActions: requireOptionalNativeModule('ExpoQuickActions') != null, spotlight: requireOptionalNativeModule('ExpoCoreSpotlight') != null, + keyCommands: requireOptionalNativeModule('DebateOwlKeyCommands') != null, + shareIntent: requireOptionalNativeModule('ExpoShareIntentModule') != null, }; } diff --git a/apps/mobile/src/lib/row-arrivals.test.ts b/apps/mobile/src/lib/row-arrivals.test.ts new file mode 100644 index 00000000..d64a48b2 --- /dev/null +++ b/apps/mobile/src/lib/row-arrivals.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; + +import { ARRIVAL_WINDOW_MS, createRowArrivals } from './row-arrivals'; + +function clock() { + let time = 1_000; + return { now: () => time, advance: (ms: number) => (time += ms) }; +} + +describe('createRowArrivals', () => { + it('animates the first load, but not rows scrolled to after it settled', () => { + const time = clock(); + const arrivals = createRowArrivals(time.now); + arrivals.update(['a', 'b', 'c']); + expect(arrivals.entersNow('a')).toBe(true); + time.advance(ARRIVAL_WINDOW_MS); + // Row c mounts only now, scrolled into view: it is just there. + expect(arrivals.entersNow('c')).toBe(false); + }); + + it('animates a row added later, once, while the others stay put', () => { + const time = clock(); + const arrivals = createRowArrivals(time.now); + arrivals.update(['a', 'b']); + time.advance(5_000); + arrivals.update(['new', 'a', 'b']); + expect(arrivals.entersNow('new')).toBe(true); + expect(arrivals.entersNow('a')).toBe(false); + time.advance(ARRIVAL_WINDOW_MS); + expect(arrivals.entersNow('new')).toBe(false); + }); + + it('treats a row a filter brings back as arriving again', () => { + const time = clock(); + const arrivals = createRowArrivals(time.now); + arrivals.update(['a', 'b']); + time.advance(5_000); + arrivals.update(['a']); + time.advance(100); + arrivals.update(['a', 'b']); + expect(arrivals.entersNow('b')).toBe(true); + expect(arrivals.entersNow('a')).toBe(false); + }); + + it('does not restart a row when the same keys come in again on another render', () => { + const time = clock(); + const arrivals = createRowArrivals(time.now); + arrivals.update(['a']); + time.advance(ARRIVAL_WINDOW_MS - 1); + arrivals.update(['a']); + time.advance(1); + expect(arrivals.entersNow('a')).toBe(false); + }); + + it('knows nothing of keys that are not in the list', () => { + const arrivals = createRowArrivals(clock().now); + arrivals.update(['a']); + expect(arrivals.entersNow('gone')).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/row-arrivals.ts b/apps/mobile/src/lib/row-arrivals.ts new file mode 100644 index 00000000..f720f7dc --- /dev/null +++ b/apps/mobile/src/lib/row-arrivals.ts @@ -0,0 +1,37 @@ +/** + * When a row of a virtualized list should play its arrival (ListItemIn's fade up). + * A FlatList mounts rows as they scroll into view and unmounts them again, so a + * row that animated on mount would replay every time it came back. Here a row + * arrives when its key joins the list's data (the first load, a new source, a + * filter that brings it back), and it plays the arrival only if it mounts soon + * after; a row scrolled to later is simply there. No React Native imports, so it + * is unit-tested in Node. + */ + +/** Long enough for the first screenful's stagger (eight steps of 40 ms) and its mount. */ +export const ARRIVAL_WINDOW_MS = 700; + +export type RowArrivals = { + /** The list's keys, in order, on each render. Calling it again with the same keys changes nothing. */ + update: (keys: readonly string[]) => void; + /** Whether a row mounting now arrives with its animation. */ + entersNow: (key: string) => boolean; +}; + +export function createRowArrivals(now: () => number = Date.now): RowArrivals { + let previous = new Set(); + const arrivedAt = new Map(); + return { + update(keys) { + const next = new Set(keys); + const at = now(); + for (const key of next) if (!previous.has(key)) arrivedAt.set(key, at); + for (const key of arrivedAt.keys()) if (!next.has(key)) arrivedAt.delete(key); + previous = next; + }, + entersNow(key) { + const at = arrivedAt.get(key); + return at !== undefined && now() - at < ARRIVAL_WINDOW_MS; + }, + }; +} diff --git a/apps/mobile/src/lib/share-inbox.ts b/apps/mobile/src/lib/share-inbox.ts new file mode 100644 index 00000000..6d231525 --- /dev/null +++ b/apps/mobile/src/lib/share-inbox.ts @@ -0,0 +1,28 @@ +/** + * Where the share extension's URL waits for share-intent.ts. +native-intent.tsx + * runs before the root layout mounts, so it only drops the URL here; this file + * imports nothing, as deep-links.ts does, so the intent handler stays light. + */ + +let pendingUrl: string | null = null; +const listeners = new Set<(url: string) => void>(); + +/** Called by +native-intent.tsx with the share extension's URL. */ +export function receiveShareUrl(url: string): void { + pendingUrl = url; + listeners.forEach((listener) => listener(url)); +} + +/** Hands over a URL that arrived before anyone listened, then every new one. */ +export function listenForShareUrls(listener: (url: string) => void): () => void { + listeners.add(listener); + if (pendingUrl) listener(pendingUrl); + return () => { + listeners.delete(listener); + }; +} + +/** The URL has been read; it is not handed over again. */ +export function takeShareUrl(): void { + pendingUrl = null; +} diff --git a/apps/mobile/src/lib/share-intent-parse.test.ts b/apps/mobile/src/lib/share-intent-parse.test.ts new file mode 100644 index 00000000..50dc527c --- /dev/null +++ b/apps/mobile/src/lib/share-intent-parse.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; + +import { isReadableShareUrl, isShareIntentUrl, MAX_SHARED_TEXT, noteTitleFrom, parseSharedItem, sharedItemRoute } from './share-intent-parse'; + +describe('isShareIntentUrl', () => { + it('knows the URL the share extension opens the app with, and nothing else', () => { + expect(isShareIntentUrl('debatelab://dataUrl=debatelabShareKey?nonce=1#weburl')).toBe(true); + expect(isShareIntentUrl('debatelab://case/abc')).toBe(false); + expect(isShareIntentUrl('https://debateowl.com/dataUrl=x')).toBe(false); + expect(isShareIntentUrl(null)).toBe(false); + }); +}); + +describe('isReadableShareUrl', () => { + it('passes only what the extension opens, so a crafted link never reaches the native reader', () => { + expect(isReadableShareUrl('debatelab://dataUrl=debatelabShareKey?nonce=0F8FAD5B-D9CB-469F-A165-70867728950E#weburl')).toBe(true); + expect(isReadableShareUrl('debatelab://dataUrl=debatelabShareKey#text')).toBe(true); + // No fragment: the module would force-unwrap it and crash. + expect(isReadableShareUrl('debatelab://dataUrl=debatelabShareKey')).toBe(false); + expect(isReadableShareUrl('debatelab://dataUrl=debatelabShareKey?nonce=1#')).toBe(false); + // Files are not taken, another key reads another store entry. + expect(isReadableShareUrl('debatelab://dataUrl=debatelabShareKey#media')).toBe(false); + expect(isReadableShareUrl('debatelab://dataUrl=otherKey#text')).toBe(false); + expect(isReadableShareUrl('debatelab://dataUrl=debatelabShareKey?nonce=x#text&y')).toBe(false); + }); +}); + +describe('parseSharedItem', () => { + it('reads a page shared from Safari as a link with the page title', () => { + const value = JSON.stringify({ weburls: [{ url: 'https://example.org/report', meta: JSON.stringify({ title: 'A long\nreport' }) }] }); + expect(parseSharedItem(value)).toEqual({ kind: 'link', url: 'https://example.org/report', title: 'A long report' }); + }); + + it('treats text that is only an address as a link', () => { + expect(parseSharedItem({ text: ' https://example.org/a?b=1 ' })).toEqual({ kind: 'link', url: 'https://example.org/a?b=1', title: undefined }); + }); + + it('keeps text with an address in it as a note, titled by its first line', () => { + const item = parseSharedItem(JSON.stringify({ text: '\n\nSugar taxes cut intake\nsee https://example.org' })); + expect(item).toEqual({ kind: 'text', text: 'Sugar taxes cut intake\nsee https://example.org', title: 'Sugar taxes cut intake' }); + }); + + it('refuses addresses that are not http or https, and what it cannot read', () => { + expect(parseSharedItem({ weburls: [{ url: 'javascript:alert(1)' }] })).toBeNull(); + expect(parseSharedItem({ weburls: [{ url: 'file:///etc/passwd' }] })).toBeNull(); + expect(parseSharedItem('{not json')).toBeNull(); + expect(parseSharedItem({ files: [{ path: 'file:///photo.jpg' }] })).toBeNull(); + expect(parseSharedItem({ text: ' ' })).toBeNull(); + expect(parseSharedItem(42)).toBeNull(); + }); + + it('turns an over-long address into nothing and cuts over-long text to the Library limit', () => { + expect(parseSharedItem({ weburls: [{ url: `https://example.org/${'a'.repeat(3000)}` }] })).toBeNull(); + const item = parseSharedItem({ text: `Title\n${'x'.repeat(MAX_SHARED_TEXT + 10)}` }); + expect(item?.kind === 'text' && item.text.length).toBe(MAX_SHARED_TEXT); + }); + + it('keeps a page whose metadata cannot be read', () => { + expect(parseSharedItem({ weburls: [{ url: 'https://example.org', meta: '{oops' }], meta: { title: 'Fallback' } })).toEqual({ kind: 'link', url: 'https://example.org', title: 'Fallback' }); + }); +}); + +describe('noteTitleFrom', () => { + it('shortens a long first line', () => { + const title = noteTitleFrom(`${'word '.repeat(40)}\nrest`); + expect(title.length).toBeLessThanOrEqual(80); + expect(title.endsWith('…')).toBe(true); + }); +}); + +describe('sharedItemRoute', () => { + it('opens Add link for a link and New note for text, with nothing shared in the route', () => { + expect(sharedItemRoute({ kind: 'link', url: 'https://example.org', title: 'Page' })).toEqual({ pathname: '/add-link', params: { shared: '1' } }); + expect(sharedItemRoute({ kind: 'text', text: 'Body', title: 'Body' })).toEqual({ pathname: '/new-note', params: { shared: '1' } }); + }); +}); diff --git a/apps/mobile/src/lib/share-intent-parse.ts b/apps/mobile/src/lib/share-intent-parse.ts new file mode 100644 index 00000000..474c6238 --- /dev/null +++ b/apps/mobile/src/lib/share-intent-parse.ts @@ -0,0 +1,116 @@ +/** + * What another app shared into DebateOwl (the share extension from + * expo-share-intent), and where it lands: a web page becomes Add link with the + * address filled in, any other text becomes a New note. Files are not taken. + * No React Native or Expo imports, so it is unit-tested in Node; share-intent.ts + * talks to the native module. + */ + +/** The URL the share extension opens the app with: `debatelab://dataUrl=?nonce=…#`. */ +const SHARE_URL = /^debatelab:\/\/dataUrl=/i; +/** The key the extension stores the shared item under in the App Group (`ShareKey`). */ +export const SHARE_KEY = 'debatelabShareKey'; + +const MAX_URL_LENGTH = 2048; +/** The Library's limit for a source's text (packages/core/src/source-file-import.ts). */ +export const MAX_SHARED_TEXT = 120_000; +const MAX_TITLE = 80; + +export type SharedItem = + | { kind: 'link'; url: string; title?: string } + | { kind: 'text'; text: string; title: string }; + +/** + * Exactly what our extension opens (key, a UUID nonce, a text or web page + * type), and nothing else. expo-share-intent's native reader force-unwraps the + * fragment, so `debatelab://dataUrl=x` from any web page would crash the app if + * it reached it; files are never shared in, so their types are refused too. + */ +const READABLE_SHARE_URL = new RegExp(`^debatelab://dataUrl=${SHARE_KEY}(\\?nonce=[0-9A-Fa-f-]{1,64})?#(text|weburl)$`); + +/** Any URL in the share extension's shape: never a route, whether or not it can be read. */ +export function isShareIntentUrl(url: unknown): url is string { + return typeof url === 'string' && SHARE_URL.test(url); +} + +/** A share URL the native module may be asked to read. */ +export function isReadableShareUrl(url: unknown): url is string { + return typeof url === 'string' && url.length <= 256 && READABLE_SHARE_URL.test(url); +} + +/** An http or https address on its own, without spaces or control characters. */ +function webAddress(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (trimmed.length === 0 || trimmed.length > MAX_URL_LENGTH) return null; + // eslint-disable-next-line no-control-regex + if (!/^https?:\/\/[^\s/?#]+[^\s\u0000-\u001f]*$/i.test(trimmed)) return null; + return trimmed; +} + +function cleanTitle(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const line = value.replace(/\s+/g, ' ').trim(); + if (!line) return undefined; + return line.length > MAX_TITLE ? `${line.slice(0, MAX_TITLE - 1).trimEnd()}…` : line; +} + +/** A note's title from its text: the first line that has words, shortened. */ +export function noteTitleFrom(text: string): string { + const first = text.split(/\r?\n/).find((line) => line.trim() !== '') ?? ''; + return cleanTitle(first) ?? ''; +} + +/** + * Reads the native module's `onChange` value (a JSON string on iOS, an object + * on Android) into what the app does with it, or null for nothing it can use. + */ +export function parseSharedItem(value: unknown): SharedItem | null { + let payload: unknown = value; + if (typeof value === 'string') { + try { + payload = JSON.parse(value); + } catch { + return null; + } + } + if (payload === null || typeof payload !== 'object') return null; + const shared = payload as { text?: unknown; weburls?: unknown; meta?: unknown }; + const meta = shared.meta !== null && typeof shared.meta === 'object' ? (shared.meta as { title?: unknown }) : {}; + + if (Array.isArray(shared.weburls) && shared.weburls.length > 0) { + const first = shared.weburls[0] as { url?: unknown; meta?: unknown } | null; + const url = webAddress(first?.url); + if (url) { + let pageMeta: { title?: unknown } = {}; + if (typeof first?.meta === 'string') { + try { + const parsed: unknown = JSON.parse(first.meta); + if (parsed !== null && typeof parsed === 'object') pageMeta = parsed as { title?: unknown }; + } catch { + // A page without readable metadata still has its address. + } + } + return { kind: 'link', url, title: cleanTitle(pageMeta.title) ?? cleanTitle(meta.title) }; + } + } + + if (typeof shared.text === 'string') { + const url = webAddress(shared.text); + if (url) return { kind: 'link', url, title: cleanTitle(meta.title) }; + const text = shared.text.trim().slice(0, MAX_SHARED_TEXT); + if (!text) return null; + return { kind: 'text', text, title: cleanTitle(meta.title) ?? noteTitleFrom(text) }; + } + return null; +} + +/** + * The sheet a shared item opens. What was shared never travels in the route: + * share-intent.ts holds it for the sheet, and the route only says it is there, + * so a link from anywhere else (debatelab://add-link?url=…) cannot start an + * import or fill in a note. + */ +export function sharedItemRoute(item: SharedItem): { pathname: '/add-link' | '/new-note'; params: { shared: '1' } } { + return { pathname: item.kind === 'link' ? '/add-link' : '/new-note', params: { shared: '1' } }; +} diff --git a/apps/mobile/src/lib/share-intent.ts b/apps/mobile/src/lib/share-intent.ts new file mode 100644 index 00000000..54ec0f77 --- /dev/null +++ b/apps/mobile/src/lib/share-intent.ts @@ -0,0 +1,114 @@ +import { requireOptionalNativeModule } from 'expo'; +import { useRouter } from 'expo-router'; +import { useEffect, useState } from 'react'; + +import { useAuth } from './auth'; +import { nativeCapabilities } from './native-capabilities'; +import { listenForShareUrls, takeShareUrl } from './share-inbox'; +import { useLatest } from './live-value'; +import { isReadableShareUrl, parseSharedItem, SHARE_KEY, sharedItemRoute, type SharedItem } from './share-intent-parse'; + +/** + * Text and web pages shared from Safari or any other app. The share extension + * (expo-share-intent, in a development build with the App Group) stores the + * item in the App Group and opens `debatelab://dataUrl=…`; +native-intent.tsx + * leaves that URL in share-inbox.ts instead of routing it. The module reads the item back + * and the app opens Add link or New note with it filled in + * (share-intent-parse.ts). Expo Go and builds without the package have no + * module, so nothing here runs there. + */ + +type ShareIntentModule = { + getShareIntent: (url: string) => Promise; + clearShareIntent: (key: string) => void; + addListener: (event: 'onChange' | 'onError', listener: (event: { value?: unknown }) => void) => { remove: () => void }; +}; + +// What the sheet opened by the share extension fills in, read once. Only useShareIntent sets it, +// so the sheets' own route parameters can never start an import. +let held: SharedItem | null = null; +// The account the hand-off was made for; another account never sees it. +let heldOwner: string | null = null; + +/** The shared item of this kind the sheet was opened for, if any; `forgetShared` once it is in the form. */ +export function sharedItem(kind: K): Extract | null { + return held?.kind === kind ? (held as Extract) : null; +} + +export function forgetShared(): void { + held = null; + heldOwner = null; +} + +/** + * Opens what was shared. Call it in the root layout's Root component with its + * `ready`, like useQuickActions: the item waits until the stack is mounted and + * someone is signed in, because the sheets it opens add to the Current Debate. + * An item belongs to the account signed in when it was read: if that account + * signs out first, it is dropped, never opened for the next one. An item read + * before any session exists goes to whoever signs in on this phone. + */ +export function useShareIntent(ready: boolean): void { + const router = useRouter(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const user = useLatest(userId); + const [item, setItem] = useState<{ item: SharedItem; owner: string | null } | null>(null); + + useEffect(() => { + if (!nativeCapabilities().shareIntent) return; + const native = requireOptionalNativeModule('ExpoShareIntentModule'); + if (!native) return; + // Reads run one at a time, so the item an event brings back is the last read's: it belongs to the + // account that read started under, which stays set until the next read starts. + let readOwner: string | null = null; + let reading: Promise = Promise.resolve(); + const changed = native.addListener('onChange', (event) => { + const next = parseSharedItem(event.value); + const owner = readOwner; + // Read once: the item does not open again on the next launch. + try { + native.clearShareIntent(SHARE_KEY); + } catch { + // Nothing to clear. + } + if (next) setItem({ item: next, owner }); + }); + const failed = native.addListener('onError', (event) => { + console.warn('Could not read what was shared:', typeof event.value === 'string' ? event.value : 'unknown error'); + }); + // A URL from a cold start waited in the inbox until now. + const stopListening = listenForShareUrls((url) => { + takeShareUrl(); + if (!isReadableShareUrl(url)) return; + reading = reading.then(() => { + readOwner = user.current; + return native.getShareIntent(url); + }).catch((cause: unknown) => console.warn('Could not read what was shared:', cause instanceof Error ? cause.message : cause)); + }); + return () => { + stopListening(); + changed.remove(); + failed.remove(); + }; + }, [user]); + + // Another account: what the previous one shared is dropped, waiting or handed off. + useEffect(() => { + setItem((pending) => (pending && pending.owner !== null && pending.owner !== userId ? null : pending)); + if (heldOwner !== userId) forgetShared(); + }, [userId]); + + useEffect(() => { + if (!item || !ready || !userId) return; + if (item.owner !== null && item.owner !== userId) return; + // After the stack has committed its first screens, as useQuickActions does. + const timer = setTimeout(() => { + setItem(null); + held = item.item; + heldOwner = userId; + router.push(sharedItemRoute(item.item)); + }, 0); + return () => clearTimeout(timer); + }, [item, ready, userId, router]); +} diff --git a/apps/mobile/src/lib/spotlight-items.test.ts b/apps/mobile/src/lib/spotlight-items.test.ts new file mode 100644 index 00000000..92ba4833 --- /dev/null +++ b/apps/mobile/src/lib/spotlight-items.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { SPOTLIGHT_LIMIT, spotlightWorkItems } from './spotlight-items'; + +const label = (kind: string) => `kind:${kind}`; + +describe('spotlightWorkItems', () => { + it('indexes documents with their route, title and motion, newest first', () => { + const items = spotlightWorkItems( + [ + { id: 'a1', kind: 'attack', title: 'Attack on the cost', createdAt: 1 }, + { id: 'c1', kind: 'case', title: 'Main case', createdAt: 2 }, + ], + 'This house would tax sugar', + label, + ); + expect(items).toEqual([ + { url: 'debatelab://case/c1', title: 'Main case', description: 'This house would tax sugar', keywords: ['kind:case'] }, + { url: 'debatelab://attack/a1', title: 'Attack on the cost', description: 'This house would tax sugar', keywords: ['kind:attack'] }, + ]); + }); + + it('leaves out work with no document screen and ids that do not belong in a URL', () => { + const items = spotlightWorkItems( + [ + { id: 'x1', kind: 'chat', title: 'A chat' }, + { id: 'x2', kind: 'sparring', title: 'A round' }, + { id: '../you', kind: 'case', title: 'Sneaky' }, + { id: 'b1?x=1', kind: 'brainstorm', title: 'Query' }, + { id: 'f1', kind: 'factoids', title: 'Facts' }, + ], + 'Motion', + label, + ); + expect(items.map((item) => item.url)).toEqual(['debatelab://factoids/f1']); + }); + + it('lists a document once when it shows under both sides, and names an untitled one by its kind', () => { + const items = spotlightWorkItems( + [ + { id: 'c1', kind: 'case', title: ' ' }, + { id: 'c1', kind: 'case', title: ' ' }, + ], + '', + label, + ); + expect(items).toEqual([{ url: 'debatelab://case/c1', title: 'kind:case', description: undefined, keywords: ['kind:case'] }]); + }); + + it('keeps the newest work only', () => { + const work = Array.from({ length: SPOTLIGHT_LIMIT + 5 }, (_, index) => ({ id: `c${index}`, kind: 'case', title: `Case ${index}`, createdAt: index })); + const items = spotlightWorkItems(work, 'Motion', label); + expect(items).toHaveLength(SPOTLIGHT_LIMIT); + expect(items[0].url).toBe(`debatelab://case/c${SPOTLIGHT_LIMIT + 4}`); + }); +}); diff --git a/apps/mobile/src/lib/spotlight-items.ts b/apps/mobile/src/lib/spotlight-items.ts new file mode 100644 index 00000000..6957081b --- /dev/null +++ b/apps/mobile/src/lib/spotlight-items.ts @@ -0,0 +1,48 @@ +/** + * What Spotlight indexes for a debate: its saved documents (cases, attacks, + * brainstorms and factoids), each opening its document screen through the + * app's scheme. No React Native or Expo imports, so it is unit-tested in Node; + * spotlight.ts hands the items to iOS. + */ + +/** One searchable item. Its URL is also its Spotlight identifier, which the package opens when the result is tapped. */ +export type SpotlightItem = { + /** An app link, such as `debatelab://case/`. */ + url: `debatelab://${string}`; + title: string; + description?: string; + keywords?: string[]; +}; + +/** The kinds that open as a document, with the route each opens (src/app//[id].tsx). */ +const DOCUMENT_KINDS = new Set(['case', 'attack', 'brainstorm', 'factoids']); +/** Row ids are uuids; anything else is not put into a URL. */ +const ROW_ID = /^[A-Za-z0-9-]{1,64}$/; +/** Spotlight is for finding, not an archive: the newest work of the debate is enough. */ +export const SPOTLIGHT_LIMIT = 40; + +export type IndexableWork = { id: string; kind: string; title: string; createdAt?: number }; + +/** + * The debate's documents for Spotlight, newest first and each once, titled by + * their own title (or the kind's name) and described by the debate's motion. + */ +export function spotlightWorkItems( + work: readonly IndexableWork[], + motion: string, + kindLabel: (kind: string) => string, +): SpotlightItem[] { + const seen = new Set(); + const items: SpotlightItem[] = []; + const ordered = [...work].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)); + for (const item of ordered) { + if (!DOCUMENT_KINDS.has(item.kind) || !ROW_ID.test(item.id)) continue; + const url = `debatelab://${item.kind}/${item.id}` as const; + if (seen.has(url)) continue; + seen.add(url); + const kind = kindLabel(item.kind); + items.push({ url, title: item.title.trim() || kind, description: motion.trim() || undefined, keywords: [kind] }); + if (items.length === SPOTLIGHT_LIMIT) break; + } + return items; +} diff --git a/apps/mobile/src/lib/spotlight.test.ts b/apps/mobile/src/lib/spotlight.test.ts new file mode 100644 index 00000000..fbb50702 --- /dev/null +++ b/apps/mobile/src/lib/spotlight.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const calls: string[] = []; +const removals: (() => void)[] = []; +const native = { + removeAllItemsFromDomain: vi.fn((domain: string) => { + calls.push(`remove ${domain}`); + // Each domain removal waits until the test lets it finish, like iOS answering later. + return new Promise((resolve) => removals.push(resolve)); + }), + removeAllItems: vi.fn(async () => { + calls.push('remove all'); + }), + indexItems: vi.fn(async (items: { title: string }[]) => { + calls.push(`index ${items.map((item) => item.title).join(',')}`); + }), +}; + +vi.mock('expo', () => ({ requireOptionalNativeModule: () => native })); +vi.mock('./native-capabilities', () => ({ nativeCapabilities: () => ({ spotlight: true }) })); + +import { clearSpotlight, clearSpotlightDomain, indexForSpotlight } from './spotlight'; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); +const item = (title: string) => ({ url: `debatelab://case/${title}` as const, title }); + +describe('spotlight', () => { + beforeEach(() => { + calls.length = 0; + removals.length = 0; + }); + + it('never indexes an account’s documents again after sign-out cleared them mid-index', async () => { + const indexing = indexForSpotlight('work', [item('A-case')]); + await flush(); + expect(calls).toEqual(['remove com.pistack.debateowl.work']); + const cleared = clearSpotlight(); + removals.shift()?.(); + await indexing; + await cleared; + expect(calls).toEqual(['remove com.pistack.debateowl.work', 'remove all']); + }); + + it('drops indexing still queued behind a debate switch, and keeps indexing asked for after it', async () => { + const first = indexForSpotlight('work', [item('old')]); + const switched = clearSpotlightDomain('work'); + const second = indexForSpotlight('work', [item('new')]); + await flush(); + // The first index was dropped before touching iOS; the switch's removal runs, then the new debate's. + removals.shift()?.(); + await first; + await switched; + await flush(); + removals.shift()?.(); + await second; + expect(calls).toEqual(['remove com.pistack.debateowl.work', 'remove com.pistack.debateowl.work', 'index new']); + }); +}); diff --git a/apps/mobile/src/lib/spotlight.ts b/apps/mobile/src/lib/spotlight.ts index 931da6c6..67bbee3a 100644 --- a/apps/mobile/src/lib/spotlight.ts +++ b/apps/mobile/src/lib/spotlight.ts @@ -1,29 +1,20 @@ import { requireOptionalNativeModule } from 'expo'; import { nativeCapabilities } from './native-capabilities'; +import type { SpotlightItem } from './spotlight-items'; /** - * Spotlight search for debates and saved cases, through expo-core-spotlight - * (2.x is the line for SDK 56 and 57; it needs its config plugin, which - * app.config.ts adds once the package is installed). It is reached - * by its native module name, so nothing here loads in Expo Go, on the web or - * in a build without the package: there every call is a no-op. + * Spotlight search for the Current Debate's saved documents, through + * expo-core-spotlight (2.x: 3.0 drops the config plugin app.config.ts adds). + * It is reached by its native module name, so nothing here loads in Expo Go, + * on the web or in a build without the package: there every call is a no-op. * - * The Debate tab indexes the Current Debate's cases and the debate context - * clears the index on sign-out. TODO(spotlight): install expo-core-spotlight@^2 - * for the development build, and clear the index after Delete all my data. + * The Debate tab indexes the Current Debate's documents (spotlight-items.ts); + * the debate context clears the index on sign-out and Delete all my data + * clears it too. A tapped result opens its debatelab:// URL. */ -/** One searchable item. Its URL is also its Spotlight identifier, which the package opens when the result is tapped. */ -export type SpotlightItem = { - /** An app link, such as `debatelab://case/`. */ - url: `debatelab://${string}`; - title: string; - description?: string; - keywords?: string[]; -}; - -export type SpotlightDomain = 'debates' | 'cases'; +export type SpotlightDomain = 'debates' | 'work'; type CoreSpotlightModule = { indexItems: (items: Record[]) => Promise; @@ -38,13 +29,32 @@ function spotlight(): CoreSpotlightModule | null { return requireOptionalNativeModule('ExpoCoreSpotlight'); } +/** Whether this build has Spotlight, so callers can skip reading what they would index. */ +export function spotlightAvailable(): boolean { + return spotlight() !== null; +} + +// Every call runs in one queue, in the order it was made. A clear also counts a new generation at +// once, so indexing asked for before it (still queued, or waiting on iOS) never writes afterwards: +// a signed-out account's or an earlier debate's documents cannot come back. +let queue: Promise = Promise.resolve(); +let generation = 0; + +function enqueue(run: () => Promise, failure: string): Promise { + const next = queue.then(run).catch((cause: unknown) => console.warn(failure, cause instanceof Error ? cause.message : cause)); + queue = next; + return next; +} + /** Replaces everything indexed for one domain with these items, so deleted debates and cases leave Spotlight too. */ -export async function indexForSpotlight(domain: SpotlightDomain, items: SpotlightItem[]): Promise { +export function indexForSpotlight(domain: SpotlightDomain, items: SpotlightItem[]): Promise { const native = spotlight(); - if (!native) return; - try { + if (!native) return Promise.resolve(); + const asked = generation; + return enqueue(async () => { + if (asked !== generation) return; await native.removeAllItemsFromDomain(domainId(domain)); - if (items.length === 0) return; + if (items.length === 0 || asked !== generation) return; await native.indexItems( items.map((item) => ({ uniqueIdentifier: item.url, @@ -55,18 +65,21 @@ export async function indexForSpotlight(domain: SpotlightDomain, items: Spotligh domainIdentifier: domainId(domain), })), ); - } catch (cause) { - console.warn('Could not update Spotlight:', cause instanceof Error ? cause.message : cause); - } + }, 'Could not update Spotlight:'); +} + +/** Removes one domain's items, such as the documents of a debate that is no longer the Current Debate. */ +export function clearSpotlightDomain(domain: SpotlightDomain): Promise { + const native = spotlight(); + if (!native) return Promise.resolve(); + generation += 1; + return enqueue(() => native.removeAllItemsFromDomain(domainId(domain)), 'Could not update Spotlight:'); } /** Removes every item this app indexed: one account's debates must not show in Spotlight for the next. */ -export async function clearSpotlight(): Promise { +export function clearSpotlight(): Promise { const native = spotlight(); - if (!native) return; - try { - await native.removeAllItems(); - } catch (cause) { - console.warn('Could not clear Spotlight:', cause instanceof Error ? cause.message : cause); - } + if (!native) return Promise.resolve(); + generation += 1; + return enqueue(() => native.removeAllItems(), 'Could not clear Spotlight:'); } diff --git a/docs/development.md b/docs/development.md index cd229cb7..1e5605ae 100644 --- a/docs/development.md +++ b/docs/development.md @@ -137,13 +137,13 @@ Stop it with `$PREVIEW down "debatelab mobile expo-go"` when you are done. ### Development build -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, the timer's Live Activity and lock-screen widget (with an App Group), and later Google sign-in (Expo Go cannot receive OAuth redirects). Its Info.plist also gets the 120 Hz key, which Expo Go ignores. JavaScript-only changes never need a new build. +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 Live Activity, the widgets and the share extension (with an App Group), and later Google sign-in (Expo Go cannot receive OAuth redirects). 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: - 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`) and Live Activity, `NSSupportsLiveActivities`, and the App Group `group.com.pistack.debateowl` (the only value the config accepts) on both targets. Register that group for both 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_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. `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. @@ -194,7 +194,7 @@ Go on only if all of these hold: - the profile is enterprise (`all devices` true) or ad hoc with Adam's iPhone UDID among its devices: ask Adam for the UDID. A development profile (`get-task-allow` true) is not what EAS internal distribution expects; ask Adam first; - the `.p12`'s SHA-1 equals one of the profile's certificate SHA-1s. -Set `DEBATELAB_APP_GROUP` in the profile's `env` in `eas.json` only if `app groups` lists a group, and then pass `widgets` to step 4. Don't export it in this shell: outside a build the config rejects it without `DEBATELAB_BUILD=dev`, which breaks `eas init`. Without an App Group this build has no Live Activity. +Set `DEBATELAB_APP_GROUP` in the profile's `env` in `eas.json` only if `app groups` lists a group, and then pass `widgets` to step 4. Don't export it in this shell: outside a build the config rejects it without `DEBATELAB_BUILD=dev`, which breaks `eas init`. Without an App Group this build has no Live Activity, widgets or share extension. **2. The EAS project is linked.** `app.config.ts` has `owner: 'pi-stack'` and the project ID of `@pi-stack/debateowl`, set with `eas init --account pi-stack --force --non-interactive` (which cannot edit a dynamic config, so it printed the ID). Nothing to do here unless the project changes. @@ -220,9 +220,10 @@ import json, os, sys s = sys.argv[1] cert = {'path': f'{s}/cert.p12', 'password': open(f'{s}/p12-password').read().strip()} target = {'provisioningProfilePath': f'{s}/profile.mobileprovision', 'distributionCertificate': cert} -# With DEBATELAB_APP_GROUP the widget extension is a second target and needs its own entry, -# signed by a profile valid for both bundle ids (a wildcard profile is). -ios = {'DebateLab': target, 'ExpoWidgetsTarget': target} if sys.argv[2:] == ['widgets'] else target +# With DEBATELAB_APP_GROUP the widget and share extensions are two more targets and need their +# own entries, signed by a profile valid for all three bundle ids (a wildcard profile is). The keys +# are the Xcode target names prebuild writes; the app's follows `name` in app.config.ts. +ios = {'DebateOwl': target, 'ExpoWidgetsTarget': target, 'DebateOwlShare': target} if sys.argv[2:] == ['widgets'] else target fd = os.open('credentials.json', os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) os.write(fd, json.dumps({'ios': ios}).encode()) os.close(fd) diff --git a/knip.json b/knip.json index 2651de67..166cafb0 100644 --- a/knip.json +++ b/knip.json @@ -54,6 +54,9 @@ "expo-updates", "expo-speech-recognition", "expo-widgets", + "expo-quick-actions", + "expo-core-spotlight", + "expo-share-intent", "@expo/dom-webview" ] } diff --git a/package-lock.json b/package-lock.json index d3193bd7..5e8042db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -137,6 +137,7 @@ "expo-audio": "~57.0.5", "expo-clipboard": "~57.0.2", "expo-constants": "~57.0.21", + "expo-core-spotlight": "~2.0.2", "expo-crypto": "~57.0.3", "expo-dev-client": "~57.0.20", "expo-document-picker": "~57.0.3", @@ -149,8 +150,10 @@ "expo-network": "~57.0.2", "expo-notifications": "~57.0.22", "expo-print": "~57.0.2", + "expo-quick-actions": "~6.0.2", "expo-router": "~57.0.25", "expo-secure-store": "~57.0.4", + "expo-share-intent": "~8.0.1", "expo-sharing": "~57.0.22", "expo-speech": "~57.0.3", "expo-speech-recognition": "^57.1.1", @@ -618,6 +621,17 @@ "react-native": "*" } }, + "apps/mobile/node_modules/expo-core-spotlight": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/expo-core-spotlight/-/expo-core-spotlight-2.0.2.tgz", + "integrity": "sha512-HwXBf+rTW8AwSBOkO7usR4xzucaIXZBidasH2Y+PoAr5kevki7FQd6Xx4MsasDM+92OG+sKioTRBgqp/L18jpw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "apps/mobile/node_modules/expo-crypto": { "version": "57.0.3", "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-57.0.3.tgz", @@ -850,6 +864,54 @@ "react-native": "*" } }, + "apps/mobile/node_modules/expo-quick-actions": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/expo-quick-actions/-/expo-quick-actions-6.0.2.tgz", + "integrity": "sha512-IVoTMCx55MjSnPqUEb5jhePpOAUdOTonifY3QXM/DBFxc6qMzBy5iJWEkfDZcFAzFzahK1ZW9tFaNv4pi2AAvQ==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.8.7", + "schema-utils": "^4.3.2", + "sf-symbols-typescript": "^2.1.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "apps/mobile/node_modules/expo-quick-actions/node_modules/@expo/image-utils": { + "version": "0.8.17", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.8.17.tgz", + "integrity": "sha512-1eVrX6FFkPNl9DJNiw3Gjsf6PtG7HttbapEVvcROVrA9PCMikujOJWWHDYKGptmKoVIIVajFZE8UHGHUb3mPug==", + "license": "MIT", + "dependencies": { + "@expo/require-utils": "^55.0.8", + "@expo/spawn-async": "^1.7.2", + "chalk": "^4.0.0", + "getenv": "^2.0.0", + "jimp-compact": "0.16.1", + "parse-png": "^2.1.0", + "semver": "^7.6.0" + } + }, + "apps/mobile/node_modules/expo-quick-actions/node_modules/@expo/require-utils": { + "version": "55.0.8", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.8.tgz", + "integrity": "sha512-DDS5R67iz2SJwncbpcUMHtnsq31dSfllhaEncKxGlK0/ewgc2CaXUCkDA2AumfT7ubnSXiPkU9Gn8v2xCrvj+A==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "apps/mobile/node_modules/expo-router": { "version": "57.0.25", "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.25.tgz", @@ -931,6 +993,31 @@ "expo": "*" } }, + "apps/mobile/node_modules/expo-share-intent": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/expo-share-intent/-/expo-share-intent-8.0.1.tgz", + "integrity": "sha512-VOHzutKhiEuVW9ygWCfBlseVqR5Zs8+8GAOGdg42bm7yzHALmYBclrTL+6oCIv34q+CHxf/lCt/67qeZrORfVQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/achorein" + }, + "https://www.buymeacoffee.com/achorein" + ], + "license": "MIT", + "dependencies": { + "@expo/config-plugins": "~57.0.2", + "expo-constants": "~57.0.3", + "expo-linking": "~57.0.1" + }, + "peerDependencies": { + "expo": "^57", + "expo-constants": ">=57.0.3", + "expo-linking": ">=57.0.1", + "react": "*", + "react-native": "*" + } + }, "apps/mobile/node_modules/expo-sharing": { "version": "57.0.22", "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-57.0.22.tgz", @@ -9701,7 +9788,6 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, "license": "MIT" }, "node_modules/@types/mdast": { @@ -10543,6 +10629,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/anser": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", @@ -12896,6 +13021,22 @@ "fast-string-truncated-width": "^3.0.2" } }, + "node_modules/fast-uri": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fast-wrap-ansi": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", @@ -18334,6 +18475,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -18525,6 +18675,70 @@ "loose-envify": "^1.1.0" } }, + "node_modules/schema-utils": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.8.1.tgz", + "integrity": "sha512-x39pYlJPyH7Uge5QO64B9XGP9zLDc9WNmiQZc+CE3IzZPvMu3lHOuAMGpYNoPixEL7MSWlGhYTg5UlLSL8G6iw==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "ajv-keywords": "^5.1.0" + }, + "bin": { + "schema-utils": "bin/schema-utils.js" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.112.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + } + } + }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/schema-utils/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/scroll": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/scroll/-/scroll-3.0.1.tgz",