From 4ab72c39c5274419f0dbefbfdf2125d1f203afe7 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 29 Oct 2026 14:13:45 -0700 Subject: [PATCH 001/120] refactor(mobile): put iOS-only APIs behind platform seams for Android Each local Expo module and SwiftUI call site now has a typed contract (X.types.ts), an iOS binding (X.ios.tsx) and a fallback (X.tsx) picked by Metro platform extensions. Android bundles resolve to the fallbacks and contain no @expo/ui/swift-ui or expo-apple-authentication code. Native fingerprint is unchanged (only modules/*/ios is hashed). Co-Authored-By: Claude --- apps/mobile/AGENTS.md | 16 +- .../alert-prompt/src/AlertPrompt.ios.ts | 6 + .../modules/alert-prompt/src/AlertPrompt.ts | 3 + .../alert-prompt/src/AlertPrompt.types.ts | 12 + apps/mobile/modules/alert-prompt/src/index.ts | 19 +- .../src/AttachmentsSheet.ios.ts | 68 ++ .../attachments-sheet/src/AttachmentsSheet.ts | 4 + .../src/AttachmentsSheet.types.ts | 37 ++ .../modules/attachments-sheet/src/index.ts | 104 +-- .../modules/composer/src/Composer.ios.tsx | 202 ++++++ apps/mobile/modules/composer/src/Composer.tsx | 25 + .../modules/composer/src/Composer.types.ts | 403 ++++++++++++ apps/mobile/modules/composer/src/index.tsx | 604 +----------------- .../forward-swipe/src/ForwardSwipe.ios.tsx | 22 + .../forward-swipe/src/ForwardSwipe.tsx | 3 + .../forward-swipe/src/ForwardSwipe.types.ts | 12 + .../modules/forward-swipe/src/index.tsx | 32 +- .../live-activity/src/LiveActivity.ios.ts | 4 + .../modules/live-activity/src/LiveActivity.ts | 12 + .../live-activity/src/LiveActivity.types.ts | 42 ++ .../mobile/modules/live-activity/src/index.ts | 73 +-- .../modules/live-activity/src/orderRows.ts | 22 + .../symbol-button/src/SymbolButton.ios.tsx | 28 + .../symbol-button/src/SymbolButton.tsx | 3 + .../symbol-button/src/SymbolButton.types.ts | 25 + .../modules/symbol-button/src/index.tsx | 41 +- .../title-press/src/TitlePress.ios.tsx | 13 + .../modules/title-press/src/TitlePress.tsx | 3 + .../title-press/src/TitlePress.types.ts | 8 + apps/mobile/modules/title-press/src/index.tsx | 17 +- .../modules/voice/src/VoiceAudio.ios.ts | 39 ++ apps/mobile/modules/voice/src/VoiceAudio.ts | 11 + .../modules/voice/src/VoiceAudio.types.ts | 29 + apps/mobile/modules/voice/src/index.ts | 52 +- .../screens/(auth)/sign-in/SignInScreen.tsx | 44 +- .../sign-in/utils/signInWithApple/index.ts | 1 + .../signInWithApple/signInWithApple.ios.ts | 52 ++ .../utils/signInWithApple/signInWithApple.ts | 5 + .../ShareInviteScreen/ShareInviteScreen.tsx | 22 +- .../components/ValueMenu/ValueMenu.ios.tsx | 74 +++ .../components/ValueMenu/ValueMenu.tsx | 146 +++-- .../components/ValueMenu/ValueMenu.types.ts | 23 + .../ShareAccess/components/ValueMenu/index.ts | 1 + .../useGeneralAccess/useGeneralAccess.ts | 3 - .../components/ShareAccess/types.ts | 4 - .../SharedVersionRow/SharedVersionRow.tsx | 40 +- .../[id]/WorkspaceScreen/WorkspaceScreen.tsx | 2 +- .../ScrollToBottom/ScrollToBottom.tsx | 2 +- .../ScrollToBottomButton.tsx | 59 +- .../ScrollButton/ScrollButton.ios.tsx | 68 ++ .../components/ScrollButton/ScrollButton.tsx | 34 + .../ScrollButton/ScrollButton.types.ts | 10 + .../components/ScrollButton/index.ts | 2 + .../FileHeaderRow/FileHeaderRow.tsx | 57 +- .../FileActionsMenu/FileActionsMenu.ios.tsx | 63 ++ .../FileActionsMenu/FileActionsMenu.tsx | 84 +++ .../FileActionsMenu/FileActionsMenu.types.ts | 10 + .../components/FileActionsMenu/index.ts | 1 + 58 files changed, 1652 insertions(+), 1149 deletions(-) create mode 100644 apps/mobile/modules/alert-prompt/src/AlertPrompt.ios.ts create mode 100644 apps/mobile/modules/alert-prompt/src/AlertPrompt.ts create mode 100644 apps/mobile/modules/alert-prompt/src/AlertPrompt.types.ts create mode 100644 apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.ios.ts create mode 100644 apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.ts create mode 100644 apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.types.ts create mode 100644 apps/mobile/modules/composer/src/Composer.ios.tsx create mode 100644 apps/mobile/modules/composer/src/Composer.tsx create mode 100644 apps/mobile/modules/composer/src/Composer.types.ts create mode 100644 apps/mobile/modules/forward-swipe/src/ForwardSwipe.ios.tsx create mode 100644 apps/mobile/modules/forward-swipe/src/ForwardSwipe.tsx create mode 100644 apps/mobile/modules/forward-swipe/src/ForwardSwipe.types.ts create mode 100644 apps/mobile/modules/live-activity/src/LiveActivity.ios.ts create mode 100644 apps/mobile/modules/live-activity/src/LiveActivity.ts create mode 100644 apps/mobile/modules/live-activity/src/LiveActivity.types.ts create mode 100644 apps/mobile/modules/live-activity/src/orderRows.ts create mode 100644 apps/mobile/modules/symbol-button/src/SymbolButton.ios.tsx create mode 100644 apps/mobile/modules/symbol-button/src/SymbolButton.tsx create mode 100644 apps/mobile/modules/symbol-button/src/SymbolButton.types.ts create mode 100644 apps/mobile/modules/title-press/src/TitlePress.ios.tsx create mode 100644 apps/mobile/modules/title-press/src/TitlePress.tsx create mode 100644 apps/mobile/modules/title-press/src/TitlePress.types.ts create mode 100644 apps/mobile/modules/voice/src/VoiceAudio.ios.ts create mode 100644 apps/mobile/modules/voice/src/VoiceAudio.ts create mode 100644 apps/mobile/modules/voice/src/VoiceAudio.types.ts create mode 100644 apps/mobile/screens/(auth)/sign-in/utils/signInWithApple/index.ts create mode 100644 apps/mobile/screens/(auth)/sign-in/utils/signInWithApple/signInWithApple.ios.ts create mode 100644 apps/mobile/screens/(auth)/sign-in/utils/signInWithApple/signInWithApple.ts create mode 100644 apps/mobile/screens/(authenticated)/components/ShareAccess/components/ValueMenu/ValueMenu.ios.tsx create mode 100644 apps/mobile/screens/(authenticated)/components/ShareAccess/components/ValueMenu/ValueMenu.types.ts create mode 100644 apps/mobile/screens/(authenticated)/workspace/[id]/components/ScrollToBottomButton/components/ScrollButton/ScrollButton.ios.tsx create mode 100644 apps/mobile/screens/(authenticated)/workspace/[id]/components/ScrollToBottomButton/components/ScrollButton/ScrollButton.tsx create mode 100644 apps/mobile/screens/(authenticated)/workspace/[id]/components/ScrollToBottomButton/components/ScrollButton/ScrollButton.types.ts create mode 100644 apps/mobile/screens/(authenticated)/workspace/[id]/components/ScrollToBottomButton/components/ScrollButton/index.ts create mode 100644 apps/mobile/screens/(authenticated)/workspace/[id]/files-changed/components/FileHeaderRow/components/FileActionsMenu/FileActionsMenu.ios.tsx create mode 100644 apps/mobile/screens/(authenticated)/workspace/[id]/files-changed/components/FileHeaderRow/components/FileActionsMenu/FileActionsMenu.tsx create mode 100644 apps/mobile/screens/(authenticated)/workspace/[id]/files-changed/components/FileHeaderRow/components/FileActionsMenu/FileActionsMenu.types.ts create mode 100644 apps/mobile/screens/(authenticated)/workspace/[id]/files-changed/components/FileHeaderRow/components/FileActionsMenu/index.ts diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 63ed571ced4..4da5565c6eb 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -63,8 +63,20 @@ export default function AuthenticatedLayout() { ## Conventions -- **`apps/mobile` is iOS-only.** No Android fallbacks or platform guards for iOS-only APIs, and - Android incompatibility isn't a blocker until Android is explicitly in scope. +- **iOS ships; Android is a seam, not a target yet.** Every iOS-only API sits behind a typed + contract, picked by Metro's platform extensions, never by `Platform.OS` checks in screens: + - `X.types.ts` is the contract: props, handle and function types, with no SF Symbol names or + other iOS words in what callers pass. Screens import only the folder's `index.ts`. + - `X.ios.tsx` is the native binding: an Expo module in `modules//ios`, `@expo/ui/swift-ui`, + or an iOS-only Expo package. It is the only file that may import those. + - `X.tsx` is the fallback for every other platform and for `bun test`. It does nothing or + degrades to plain RN, and it must not crash. + - Both implementations annotate their exports with the contract type, so `tsc` checks the + `.ios` file against the same shape as the fallback that callers resolve to. + - To port to Android: add `X.android.tsx`, or for a module, add `android/` and `"android"` to + `expo-module.config.json` and rename the binding `X.native.tsx` so both platforms use it. + Examples: `modules/composer/src`, `ShareAccess/components/ValueMenu`, + `sign-in/utils/signInWithApple`. - **Bottom sheets are expo-router `formSheet` routes** — `...glassHeaderOptions` gives the native title and ✕, the body is RN + uniwind (never `@expo/ui` SwiftUI content, which can't be themed to match ours), and the list stays the screen's only layout child or it cold-mounts at zero height. diff --git a/apps/mobile/modules/alert-prompt/src/AlertPrompt.ios.ts b/apps/mobile/modules/alert-prompt/src/AlertPrompt.ios.ts new file mode 100644 index 00000000000..eb0002aaf47 --- /dev/null +++ b/apps/mobile/modules/alert-prompt/src/AlertPrompt.ios.ts @@ -0,0 +1,6 @@ +import { requireNativeModule } from "expo"; +import type { Prompt } from "./AlertPrompt.types"; + +const AlertPromptModule = requireNativeModule("AlertPrompt"); + +export const prompt: Prompt = (options) => AlertPromptModule.prompt(options); diff --git a/apps/mobile/modules/alert-prompt/src/AlertPrompt.ts b/apps/mobile/modules/alert-prompt/src/AlertPrompt.ts new file mode 100644 index 00000000000..47f6bde937a --- /dev/null +++ b/apps/mobile/modules/alert-prompt/src/AlertPrompt.ts @@ -0,0 +1,3 @@ +import type { Prompt } from "./AlertPrompt.types"; + +export const prompt: Prompt = async () => null; diff --git a/apps/mobile/modules/alert-prompt/src/AlertPrompt.types.ts b/apps/mobile/modules/alert-prompt/src/AlertPrompt.types.ts new file mode 100644 index 00000000000..1b4bb187c4e --- /dev/null +++ b/apps/mobile/modules/alert-prompt/src/AlertPrompt.types.ts @@ -0,0 +1,12 @@ +export type PromptOptions = { + title: string; + message?: string; + defaultValue?: string; + placeholder?: string; + confirmText?: string; + cancelText?: string; + selectText?: boolean; +}; + +/** Resolves the entered text, or null when cancelled. */ +export type Prompt = (options: PromptOptions) => Promise; diff --git a/apps/mobile/modules/alert-prompt/src/index.ts b/apps/mobile/modules/alert-prompt/src/index.ts index b651fd1509a..c83e2b4eee0 100644 --- a/apps/mobile/modules/alert-prompt/src/index.ts +++ b/apps/mobile/modules/alert-prompt/src/index.ts @@ -1,17 +1,2 @@ -import { requireNativeModule } from "expo"; - -export type PromptOptions = { - title: string; - message?: string; - defaultValue?: string; - placeholder?: string; - confirmText?: string; - cancelText?: string; - selectText?: boolean; -}; - -const AlertPromptModule = requireNativeModule("AlertPrompt"); - -export function prompt(options: PromptOptions): Promise { - return AlertPromptModule.prompt(options); -} +export { prompt } from "./AlertPrompt"; +export type { Prompt, PromptOptions } from "./AlertPrompt.types"; diff --git a/apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.ios.ts b/apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.ios.ts new file mode 100644 index 00000000000..2b09692b535 --- /dev/null +++ b/apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.ios.ts @@ -0,0 +1,68 @@ +import { requireOptionalNativeModule } from "expo"; +import { processColor } from "react-native"; +import type { + AttachmentsSheetAction, + AttachmentsSheetAsset, + PresentAttachmentsSheet, +} from "./AttachmentsSheet.types"; + +type AttachmentsSheetEvents = { + onAddAssets: (event: { assets: AttachmentsSheetAsset[] }) => void; + onAction: (event: { action: AttachmentsSheetAction }) => void; + onDismiss: () => void; +}; + +// expo-modules-core 56.0.23 exports a broken `NativeModule` type alias (the +// constructor type, generic dropped), so the emitter surface is typed by hand. +type AttachmentsSheetNativeModule = { + present(options: Record): Promise; + addListener( + eventName: Name, + listener: AttachmentsSheetEvents[Name], + ): { remove(): void }; +}; + +const AttachmentsSheet = + requireOptionalNativeModule("AttachmentsSheet"); + +export const presentAttachmentsSheet: PresentAttachmentsSheet = async ( + theme, + handlers, +) => { + if (!AttachmentsSheet) { + console.warn( + "AttachmentsSheet native module unavailable — rebuild the dev client", + ); + return false; + } + const subscriptions = [ + AttachmentsSheet.addListener("onAddAssets", ({ assets }) => { + settle(); + handlers.onAddAssets(assets); + }), + AttachmentsSheet.addListener("onAction", ({ action }) => { + settle(); + handlers.onAction(action); + }), + AttachmentsSheet.addListener("onDismiss", () => { + settle(); + handlers.onDismiss?.(); + }), + ]; + const settle = () => { + for (const subscription of subscriptions) subscription.remove(); + }; + + const { colorScheme, ...colors } = theme; + const presented = await AttachmentsSheet.present({ + colorScheme, + ...Object.fromEntries( + Object.entries(colors).map(([token, value]) => [ + token, + (processColor(value) as number | null) ?? 0, + ]), + ), + }); + if (!presented) settle(); + return presented; +}; diff --git a/apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.ts b/apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.ts new file mode 100644 index 00000000000..8df38475101 --- /dev/null +++ b/apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.ts @@ -0,0 +1,4 @@ +import type { PresentAttachmentsSheet } from "./AttachmentsSheet.types"; + +export const presentAttachmentsSheet: PresentAttachmentsSheet = async () => + false; diff --git a/apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.types.ts b/apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.types.ts new file mode 100644 index 00000000000..31b9b122085 --- /dev/null +++ b/apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.types.ts @@ -0,0 +1,37 @@ +export type AttachmentsSheetAction = "photos" | "camera" | "files"; + +export interface AttachmentsSheetAsset { + uri: string; + name: string; + mediaType: string; + size: number; +} + +export interface AttachmentsSheetTheme { + colorScheme: "light" | "dark"; + background: string; + foreground: string; + mutedForeground: string; + border: string; + secondary: string; + secondaryForeground: string; + primary: string; + primaryForeground: string; +} + +export interface AttachmentsSheetHandlers { + onAddAssets: (assets: AttachmentsSheetAsset[]) => void; + onAction: (action: AttachmentsSheetAction) => void; + /** Dismissed with neither assets nor a row action. */ + onDismiss?: () => void; +} + +/** + * Presents the attachments sheet. Every presentation ends in exactly one + * handler call — assets added, a row action, or a plain dismissal. Resolves + * false when nothing was presented. + */ +export type PresentAttachmentsSheet = ( + theme: AttachmentsSheetTheme, + handlers: AttachmentsSheetHandlers, +) => Promise; diff --git a/apps/mobile/modules/attachments-sheet/src/index.ts b/apps/mobile/modules/attachments-sheet/src/index.ts index 4c0e1031860..b87fef28b79 100644 --- a/apps/mobile/modules/attachments-sheet/src/index.ts +++ b/apps/mobile/modules/attachments-sheet/src/index.ts @@ -1,96 +1,8 @@ -import { requireOptionalNativeModule } from "expo"; -import { processColor } from "react-native"; - -export type AttachmentsSheetAction = "photos" | "camera" | "files"; - -export interface AttachmentsSheetAsset { - uri: string; - name: string; - mediaType: string; - size: number; -} - -export interface AttachmentsSheetTheme { - colorScheme: "light" | "dark"; - background: string; - foreground: string; - mutedForeground: string; - border: string; - secondary: string; - secondaryForeground: string; - primary: string; - primaryForeground: string; -} - -export interface AttachmentsSheetHandlers { - onAddAssets: (assets: AttachmentsSheetAsset[]) => void; - onAction: (action: AttachmentsSheetAction) => void; - /** Dismissed with neither assets nor a row action. */ - onDismiss?: () => void; -} - -type AttachmentsSheetEvents = { - onAddAssets: (event: { assets: AttachmentsSheetAsset[] }) => void; - onAction: (event: { action: AttachmentsSheetAction }) => void; - onDismiss: () => void; -}; - -// expo-modules-core 56.0.23 exports a broken `NativeModule` type alias (the -// constructor type, generic dropped), so the emitter surface is typed by hand. -type AttachmentsSheetNativeModule = { - present(options: Record): Promise; - addListener( - eventName: Name, - listener: AttachmentsSheetEvents[Name], - ): { remove(): void }; -}; - -const AttachmentsSheet = - requireOptionalNativeModule("AttachmentsSheet"); - -/** - * Presents the native attachments sheet. Every presentation ends in exactly - * one native event — assets added, a row action, or a plain dismissal — after - * which the listeners tear themselves down. - */ -export async function presentAttachmentsSheet( - theme: AttachmentsSheetTheme, - handlers: AttachmentsSheetHandlers, -): Promise { - if (!AttachmentsSheet) { - console.warn( - "AttachmentsSheet native module unavailable — rebuild the dev client", - ); - return false; - } - const subscriptions = [ - AttachmentsSheet.addListener("onAddAssets", ({ assets }) => { - settle(); - handlers.onAddAssets(assets); - }), - AttachmentsSheet.addListener("onAction", ({ action }) => { - settle(); - handlers.onAction(action); - }), - AttachmentsSheet.addListener("onDismiss", () => { - settle(); - handlers.onDismiss?.(); - }), - ]; - const settle = () => { - for (const subscription of subscriptions) subscription.remove(); - }; - - const { colorScheme, ...colors } = theme; - const presented = await AttachmentsSheet.present({ - colorScheme, - ...Object.fromEntries( - Object.entries(colors).map(([token, value]) => [ - token, - (processColor(value) as number | null) ?? 0, - ]), - ), - }); - if (!presented) settle(); - return presented; -} +export { presentAttachmentsSheet } from "./AttachmentsSheet"; +export type { + AttachmentsSheetAction, + AttachmentsSheetAsset, + AttachmentsSheetHandlers, + AttachmentsSheetTheme, + PresentAttachmentsSheet, +} from "./AttachmentsSheet.types"; diff --git a/apps/mobile/modules/composer/src/Composer.ios.tsx b/apps/mobile/modules/composer/src/Composer.ios.tsx new file mode 100644 index 00000000000..945bba021a7 --- /dev/null +++ b/apps/mobile/modules/composer/src/Composer.ios.tsx @@ -0,0 +1,202 @@ +import { requireNativeModule, requireNativeView } from "expo"; +import { forwardRef, type Ref, useImperativeHandle, useRef } from "react"; +import type { + ComposerAttachment, + ComposerBackdrop, + ComposerComponent, + ComposerHandle, + ComposerMenuOption, + ComposerModeOption, + ComposerPastedItem, + ComposerProps, + ComposerQuickKey, + ComposerQuickKeysAction, + ComposerSessionTab, + ComposerSessionTabLabels, + ComposerSlashCommand, + FileThumbnail, + PresentImage, +} from "./Composer.types"; + +/** The imperative surface the native view exposes through its ref. */ +interface NativeComposerRef { + clear: () => void; + appendDraft: (text: string) => void; + focus: () => void; + blur: () => void; +} + +interface NativeComposerViewProps { + ref?: Ref; + placeholder?: string; + initialDraft?: string; + backdrop?: ComposerBackdrop; + attachments?: ComposerAttachment[]; + selectedModel?: ComposerMenuOption; + launchOptions?: ComposerMenuOption[]; + modeOptions?: ComposerModeOption[]; + selectedModeId?: string | null; + canStop?: boolean; + headerChips?: ComposerMenuOption[]; + quickKeys?: ComposerQuickKey[]; + sessionTabs?: ComposerSessionTab[]; + sessionTabLabels?: ComposerSessionTabLabels; + /** Null, never undefined — see the pass-through below. */ + quickKeysAction?: ComposerQuickKeysAction | null; + slashCommands?: ComposerSlashCommand[]; + showAttachments?: boolean; + autocapitalization?: "sentences" | "never"; + compactEditor?: boolean; + isSending?: boolean; + onSubmit?: (event: { nativeEvent: { text: string } }) => void; + onAttachmentsPress?: () => void; + onDictationError?: (event: { nativeEvent: { message: string } }) => void; + onModelPress?: () => void; + onLaunchOptionPress?: (event: { nativeEvent: { id: string } }) => void; + onModeSelect?: (event: { nativeEvent: { id: string } }) => void; + onStop?: () => void; + onChipPress?: (event: { nativeEvent: { id: string } }) => void; + onQuickKeyPress?: (event: { nativeEvent: { id: string } }) => void; + onSessionTabPress?: (event: { nativeEvent: { id: string } }) => void; + onSessionTabClose?: (event: { nativeEvent: { id: string } }) => void; + onSessionTabRename?: (event: { nativeEvent: { id: string } }) => void; + onSessionTabCopyId?: (event: { nativeEvent: { id: string } }) => void; + onNewSessionPress?: () => void; + onAllSessionsPress?: () => void; + onQuickKeysActionPress?: () => void; + onHeightChange?: (event: { nativeEvent: { height: number } }) => void; + onPaste?: (event: { nativeEvent: { items: ComposerPastedItem[] } }) => void; + onDraftChange?: (event: { nativeEvent: { text: string } }) => void; + onRemoveAttachment?: (event: { nativeEvent: { id: string } }) => void; + onAttachmentPress?: (event: { nativeEvent: { id: string } }) => void; + onExpandedChange?: (event: { nativeEvent: { expanded: boolean } }) => void; +} + +const NativeComposerView = + requireNativeView("Composer"); + +const NativeComposerModule = requireNativeModule<{ + presentImage: (uri: string) => Promise; + fileThumbnail: (uri: string, side: number) => Promise; +}>("Composer"); + +export const presentImage: PresentImage = (uri) => + NativeComposerModule.presentImage(uri); + +export const fileThumbnail: FileThumbnail = (uri, side) => + NativeComposerModule.fileThumbnail(uri, side); + +export const Composer: ComposerComponent = forwardRef< + ComposerHandle, + ComposerProps +>(function Composer( + { + placeholder = "", + initialDraft = "", + backdrop = "dim", + attachments, + selectedModel, + launchOptions, + modeOptions, + selectedModeId, + canStop = false, + headerChips, + quickKeys, + sessionTabs, + sessionTabLabels, + quickKeysAction, + slashCommands, + showAttachments = true, + autocapitalization = "sentences", + compactEditor = false, + isSending = false, + onSubmit, + onAttachmentsPress, + onDictationError, + onModelPress, + onLaunchOptionPress, + onModeSelect, + onStop, + onChipPress, + onQuickKeyPress, + onSessionTabPress, + onSessionTabClose, + onSessionTabRename, + onSessionTabCopyId, + onNewSessionPress, + onAllSessionsPress, + onQuickKeysActionPress, + onHeightChange, + onPaste, + onDraftChange, + onRemoveAttachment, + onAttachmentPress, + onExpandedChange, + }, + ref, +) { + const nativeRef = useRef(null); + + useImperativeHandle(ref, () => ({ + clear: () => nativeRef.current?.clear(), + appendDraft: (text: string) => nativeRef.current?.appendDraft(text), + focus: () => nativeRef.current?.focus(), + blur: () => nativeRef.current?.blur(), + })); + + return ( + onSubmit?.(event.nativeEvent.text)} + onAttachmentsPress={onAttachmentsPress} + onDictationError={(event) => + onDictationError?.(event.nativeEvent.message) + } + onModelPress={onModelPress} + onLaunchOptionPress={(event) => + onLaunchOptionPress?.(event.nativeEvent.id) + } + onModeSelect={(event) => onModeSelect?.(event.nativeEvent.id)} + onStop={() => onStop?.()} + onChipPress={(event) => onChipPress?.(event.nativeEvent.id)} + onQuickKeyPress={(event) => onQuickKeyPress?.(event.nativeEvent.id)} + onSessionTabPress={(event) => onSessionTabPress?.(event.nativeEvent.id)} + onSessionTabClose={(event) => onSessionTabClose?.(event.nativeEvent.id)} + onSessionTabRename={(event) => onSessionTabRename?.(event.nativeEvent.id)} + onSessionTabCopyId={(event) => onSessionTabCopyId?.(event.nativeEvent.id)} + onNewSessionPress={onNewSessionPress} + onAllSessionsPress={onAllSessionsPress} + onQuickKeysActionPress={onQuickKeysActionPress} + onHeightChange={(event) => onHeightChange?.(event.nativeEvent.height)} + onPaste={(event) => onPaste?.(event.nativeEvent.items)} + onDraftChange={(event) => onDraftChange?.(event.nativeEvent.text)} + onRemoveAttachment={(event) => onRemoveAttachment?.(event.nativeEvent.id)} + onAttachmentPress={(event) => onAttachmentPress?.(event.nativeEvent.id)} + onExpandedChange={(event) => + onExpandedChange?.(event.nativeEvent.expanded) + } + /> + ); +}); diff --git a/apps/mobile/modules/composer/src/Composer.tsx b/apps/mobile/modules/composer/src/Composer.tsx new file mode 100644 index 00000000000..fbc1fa934f6 --- /dev/null +++ b/apps/mobile/modules/composer/src/Composer.tsx @@ -0,0 +1,25 @@ +import { forwardRef, useImperativeHandle } from "react"; +import type { + ComposerComponent, + ComposerHandle, + ComposerProps, + FileThumbnail, + PresentImage, +} from "./Composer.types"; + +export const presentImage: PresentImage = async () => {}; + +export const fileThumbnail: FileThumbnail = async () => null; + +export const Composer: ComposerComponent = forwardRef< + ComposerHandle, + ComposerProps +>(function Composer(_props, ref) { + useImperativeHandle(ref, () => ({ + clear: () => {}, + appendDraft: () => {}, + focus: () => {}, + blur: () => {}, + })); + return null; +}); diff --git a/apps/mobile/modules/composer/src/Composer.types.ts b/apps/mobile/modules/composer/src/Composer.types.ts new file mode 100644 index 00000000000..d8b290e9991 --- /dev/null +++ b/apps/mobile/modules/composer/src/Composer.types.ts @@ -0,0 +1,403 @@ +import type { ForwardRefExoticComponent, RefAttributes } from "react"; + +/** + * How the composer treats the screen behind it while expanded. + * + * `dim` matches the mocks: the composer owns the screen and an outside tap + * dismisses it. `passthrough` leaves the content behind live so it can be + * scrolled while the keyboard is up — what a chat transcript wants. In that + * mode the caller owns dismissal, since nothing intercepts the outside tap. + */ +export type ComposerBackdrop = "dim" | "passthrough"; + +/** + * One entry in a composer picker. + * + * `iconUri` may be a remote URL or a local file URI. What it must not be is a + * Metro asset reference — SwiftUI cannot read those. Resolve bundled art with + * `expo-asset` first; see `useAgentIconUri`. + */ +export interface ComposerMenuOption { + id: string; + label: string; + iconUri?: string; + /** + * Lead with a project avatar. Separate from `iconUri` because most projects + * have no logo and the app draws their initial instead of leaving a gap — + * the same thing `ProjectAvatar` does everywhere else. + */ + avatar?: boolean; + /** + * Render a step back, as a qualifier rather than as the subject. The branch + * belongs to the project name beside it and should not compete with it. + */ + muted?: boolean; +} + +/** + * One permission mode in the menu beside `+`. `symbol` is an SF Symbol name. + */ +export interface ComposerModeOption { + id: string; + label: string; + symbol: string; +} + +/** + * One item in the composer's tray. The tray stays in React Native — it is + * shared with the attachments sheet — so the composer renders a description of + * it and reports removals and taps back out. + */ +export interface ComposerAttachment { + id: string; + uri: string; + kind: "image" | "file"; + /** + * Shown on the file card. Documents are unidentifiable without it — they all + * draw the same glyph. Ignored for images, which show themselves. + */ + name?: string; + /** + * 0–1 while the file is still on its way to cloud storage, drawn as a ring + * over the thumbnail. Omitted once it has landed, or when nothing is + * uploading — a tray of settled attachments draws no rings. + */ + progress?: number; + /** + * The upload failed. Marks the thumbnail instead of the ring; the + * attachment stays in the tray because sending the message retries it. + */ + failed?: boolean; +} + +/** + * One key in the strip above the composer — the terminal's esc/tab/arrows. + * + * Deliberately carries no behaviour: what a key writes into the PTY stays with + * the terminal that owns it. The composer draws the mark and reports the id. + */ +export interface ComposerQuickKey { + id: string; + /** Monospaced label. Ignored when `symbol` is set. */ + label?: string; + /** SF Symbol name, e.g. `arrow.up`. */ + symbol?: string; + /** + * A hairline between groups rather than a key. Which keys belong together + * is the terminal's knowledge, so it arrives as data — but a divider still + * needs its own unique `id`, because the strip identifies entries by it. + */ + divider?: boolean; +} + +/** + * One session in the strip above the quick keys — the workspace's tab bar. + * + * Data only, like the quick keys: the composer draws a pill and reports which + * one was touched. Everything about what a session *is* stays with the caller. + */ +export interface ComposerSessionTab { + id: string; + label: string; + /** + * The agent's brand mark. Same rule as `ComposerMenuOption.iconUri`: a + * remote URL or a local file URI, never a Metro asset reference. Resolve + * bundled art with `expo-asset` first — see `useAgentIconUris`. Omit for a + * plain shell, which draws the session's initial instead. + */ + iconUri?: string; + selected?: boolean; + /** Desktop's StatusIndicator states; omit for a session with nothing to say. */ + attention?: "permission" | "working" | "failed" | "review"; +} + +/** + * The one static control beside the quick keys. + * + * Data only, like the keys: the composer draws a chip and says it was pressed. + * What it opens is the caller's to know — the workspace terminal points it at + * that workspace's pull requests, and nothing here names one. + * + * It sits ahead of the bar rather than inside it, so it holds still while the + * keys scroll. It takes its room only once it exists: the keys slide over the + * moment it arrives, animated by the composer. + */ +export interface ComposerQuickKeysAction { + /** SF Symbol name, e.g. `arrow.triangle.pull`. */ + symbol: string; + /** + * A mark to draw instead of `symbol`. Same rule as + * `ComposerSessionTab.iconUri`: a local file URI, never a Metro asset + * reference. Drawn as a template, so it takes `tint` like the symbol does, + * and `symbol` shows until it resolves. + */ + iconUri?: string; + /** + * Which accent the glyph takes. Omit for the same foreground the keys use. + * The name crosses the bridge and the composer owns the colour, the way + * `ComposerSessionTab.attention` does. + */ + tint?: "open" | "draft" | "queued" | "merged" | "closed"; + /** Accessibility label. Translated here; the composer has no catalog. */ + label: string; +} + +/** + * Every user-facing string the tab strip draws. + * + * They cross the bridge because the composer cannot translate — Lingui's macros + * and catalogs live here — and a hardcoded English menu item would be the one + * untranslated string on a translated screen. + */ +export interface ComposerSessionTabLabels { + /** Context menu: opens the prompt for the session's name. */ + rename: string; + /** Context menu: copies the session's id to the pasteboard. */ + copyId: string; + /** Context menu, destructive, and the close disc's accessibility label. */ + close: string; + newSession: string; + allSessions: string; + scrollToStart: string; +} + +/** + * One slash command or skill the active agent can run — the suggestion panel + * above the composer. Data only, like the quick keys: selection replaces the + * draft natively and reports back through `onDraftChange`. + */ +export interface ComposerSlashCommand { + id: string; + /** Full display name, namespace included (`agent-sdk-dev:new-sdk-app`). */ + name: string; + descriptionText?: string; + /** The sigil that opens and commits this entry: `/`, or `$` for Codex skills. */ + trigger: "/" | "$"; + /** Non-empty when the command takes arguments; a fully typed command with + * arguments keeps the panel closed so they can be typed in peace. */ + argumentHint?: string; + /** Harness-shipped commands sort after user-defined ones, like desktop. */ + isBuiltin?: boolean; + /** Alternate names; matched after the canonical name, like desktop. */ + aliases?: string[]; +} + +/** + * A file or image pasted into the field, already written to disk by the native + * side — the tray takes URIs, the same shape the pickers produce. + */ +export interface ComposerPastedItem { + uri: string; + name: string; + kind: "image" | "file"; +} + +export interface ComposerHandle { + /** Empties the draft. */ + clear: () => void; + /** + * Appends to the draft, for dictation. The composer owns the base text and + * does the join, so callers never have to read it back. + */ + appendDraft: (text: string) => void; + /** + * Re-opens the composer after something else took first responder — an + * attachments sheet, a picker — bringing the keyboard and draft back. + */ + focus: () => void; + blur: () => void; +} + +/** + * The strip and its strings arrive together or not at all. + * + * Split out of the props rather than left as two optional fields: the composer + * cannot translate, so tabs without labels render a context menu and + * accessibility labels that are empty strings — a caller can reach that state + * without any type error, and nothing about it looks wrong until VoiceOver + * reaches it. + */ +type ComposerSessionTabsProps = + | { + sessionTabs: ComposerSessionTab[]; + sessionTabLabels: ComposerSessionTabLabels; + } + | { sessionTabs?: undefined; sessionTabLabels?: undefined }; + +export type ComposerProps = ComposerBaseProps & ComposerSessionTabsProps; + +interface ComposerBaseProps { + placeholder?: string; + /** + * Whatever this surface had typed when it was last open, put back as the + * composer is set up. Read once by the caller and never changed after: this + * is a starting value, not a binding, and the composer owns its text from + * here on. There is deliberately no `value` prop — see `onDraftChange`. + */ + initialDraft?: string; + backdrop?: ComposerBackdrop; + attachments?: ComposerAttachment[]; + /** + * The selected agent, shown as brand mark + name. Omit to hide the picker — + * what the terminal surface wants. The list itself stays in React Native: + * the real pickers are `formSheet` routes with searchable lists. + */ + selectedModel?: ComposerMenuOption; + /** + * The selected agent's launch settings, drawn after it as their own + * chevron buttons — model, effort. Each reports its id on press; the + * lists stay in React Native like the agent's. Omit for agents with none. + */ + launchOptions?: ComposerMenuOption[]; + /** The agent's permission modes, as a native menu beside `+`. */ + modeOptions?: ComposerModeOption[]; + selectedModeId?: string; + /** The agent is working: with an empty draft, send becomes stop. */ + canStop?: boolean; + /** Frame 4's header row. Empty on the session surface (frame 13). */ + headerChips?: ComposerMenuOption[]; + /** + * Keys above the card — the terminal's esc/tab/arrows. Rendered natively + * rather than by the caller: as a React Native sibling the gap to the card + * had to guess a height it could not measure, and drifted every time the + * card grew. + */ + quickKeys?: ComposerQuickKey[]; + /** + * The one static control beside the quick keys. Omitted on every surface + * with nothing to link to — which is all of them but the workspace + * terminal. Independent of `quickKeys`: an action with the keys away still + * draws the row it belongs to. + */ + quickKeysAction?: ComposerQuickKeysAction; + /** + * What the active agent can run behind `/` (or `$`). Empty or omitted + * hides the suggestion panel — a plain shell, an agent without command + * discovery, or a host too old to answer all look the same here. + */ + slashCommands?: ComposerSlashCommand[]; + /** + * Offer the `+` button. A plain shell would try to *execute* an attachment + * path, so only agent sessions get it. + */ + showAttachments?: boolean; + /** `never` for the terminal — a shell command is not a sentence. */ + autocapitalization?: "sentences" | "never"; + /** + * Shrinks the expanded editor's floor by two lines, so the terminal screen + * can leave more of the transcript on screen when the composer is at its + * smallest. Omit for the generous floor the reference frames show. + */ + compactEditor?: boolean; + /** + * A submit is in flight. Send becomes a grey spinner and the mic steps + * aside. The caller owns this because only it knows when delivery finished. + */ + isSending?: boolean; + /** + * Never clears the composer — the caller clears through the ref once its own + * delivery succeeded, so a failed send keeps the draft. + */ + onSubmit?: (text: string) => void; + onAttachmentsPress?: () => void; + /** + * Dictation runs natively — the composer owns the recogniser, the permission + * prompt and the append — so there is no press to handle here. This only + * surfaces a failure so the caller can show its own alert. + */ + onDictationError?: (message: string) => void; + onModelPress?: () => void; + onLaunchOptionPress?: (id: string) => void; + onModeSelect?: (id: string) => void; + onStop?: () => void; + onChipPress?: (id: string) => void; + onQuickKeyPress?: (id: string) => void; + /** A tab was tapped — attach that session. */ + onSessionTabPress?: (id: string) => void; + /** + * Close was chosen, from the selected tab's disc or the press-and-hold + * menu. Nothing has been killed yet: the composer has no idea what closing + * costs, so the caller confirms. + */ + onSessionTabClose?: (id: string) => void; + /** + * Rename was chosen from the press-and-hold menu. The composer neither + * asks for the new name nor knows what a session's name is — a menu cannot + * take text, and the name is the host's — so the caller prompts and saves. + */ + onSessionTabRename?: (id: string) => void; + /** + * Copy id was chosen from the press-and-hold menu. The caller owns the + * pasteboard write and whatever it shows afterwards, so the confirmation + * matches every other copy on the screen. + */ + onSessionTabCopyId?: (id: string) => void; + onNewSessionPress?: () => void; + onAllSessionsPress?: () => void; + /** + * The control beside the quick keys was pressed. Only ever fires when + * `quickKeysAction` is set, so the caller that provided the chip is the one + * that hears about it. + */ + onQuickKeysActionPress?: () => void; + /** + * How much room the composer occupies above the bottom safe area — the + * session tabs, the card, the quick keys and the gaps between them. + * + * The composer draws in an overlay and takes no layout space, so a caller + * with content underneath cannot measure it. Excludes the keyboard, which + * the caller already tracks and gets a duration and curve for. + */ + onHeightChange?: (height: number) => void; + /** + * Files and images pasted into the field. A plain text field only ever takes + * strings, so the composer owns its text view to offer Paste for these and + * writes them out; adding them to the tray is the caller's job, because the + * tray is the caller's. + */ + onPaste?: (items: ComposerPastedItem[]) => void; + /** + * Every keystroke, so a caller can keep a shadow copy of the draft and put + * it back later. Outward only, like `onHeightChange`: the composer owns its + * text while it is live and takes nothing back mid-edit, which is why there + * is no `value` prop. Restore through the ref at mount instead. + */ + onDraftChange?: (text: string) => void; + onRemoveAttachment?: (id: string) => void; + /** + * Fires only for non-image attachments. Images open in the composer's own + * full-screen viewer — it already holds the URI, so routing the tap out and + * back would buy nothing — but only the app knows what to do with a document. + */ + onAttachmentPress?: (id: string) => void; + /** + * Fires whenever the composer opens or closes. Callers need it to restore + * the composer only when it was actually open — re-focusing unconditionally + * after a sheet pops the keyboard back up over a collapsed composer. + */ + onExpandedChange?: (expanded: boolean) => void; +} + +/** + * The composer. Renders nothing in the React Native layout — it mounts a + * full-screen overlay over the screen's own view controller and draws there, so + * callers reserve room for the collapsed pill with a content inset rather than + * with layout. + * + * Every surface-specific difference arrives as data, never as children: the + * moment a `ReactNode` crosses this boundary the seam artifacts that made + * `GlassComposer` unmaintainable come back with it. See + * `plans/20260821-native-composer.md`. + */ +export type ComposerComponent = ForwardRefExoticComponent< + ComposerProps & RefAttributes +>; + +/** Opens an image full screen in the composer's own viewer. */ +export type PresentImage = (uri: string) => Promise; + +/** A PNG preview of a local file, as the composer's file card draws it. */ +export type FileThumbnail = ( + uri: string, + side: number, +) => Promise; diff --git a/apps/mobile/modules/composer/src/index.tsx b/apps/mobile/modules/composer/src/index.tsx index 29c513e0b62..6fc8babcb90 100644 --- a/apps/mobile/modules/composer/src/index.tsx +++ b/apps/mobile/modules/composer/src/index.tsx @@ -1,586 +1,18 @@ -import { requireNativeModule, requireNativeView } from "expo"; -import { forwardRef, type Ref, useImperativeHandle, useRef } from "react"; - -/** The imperative surface the native view exposes through its ref. */ -interface NativeComposerRef { - clear: () => void; - appendDraft: (text: string) => void; - focus: () => void; - blur: () => void; -} - -interface NativeComposerViewProps { - ref?: Ref; - placeholder?: string; - initialDraft?: string; - backdrop?: ComposerBackdrop; - attachments?: ComposerAttachment[]; - selectedModel?: ComposerMenuOption; - launchOptions?: ComposerMenuOption[]; - modeOptions?: ComposerModeOption[]; - selectedModeId?: string | null; - canStop?: boolean; - headerChips?: ComposerMenuOption[]; - quickKeys?: ComposerQuickKey[]; - sessionTabs?: ComposerSessionTab[]; - sessionTabLabels?: ComposerSessionTabLabels; - /** Null, never undefined — see the pass-through below. */ - quickKeysAction?: ComposerQuickKeysAction | null; - slashCommands?: ComposerSlashCommand[]; - showAttachments?: boolean; - autocapitalization?: "sentences" | "never"; - compactEditor?: boolean; - isSending?: boolean; - onSubmit?: (event: { nativeEvent: { text: string } }) => void; - onAttachmentsPress?: () => void; - onDictationError?: (event: { nativeEvent: { message: string } }) => void; - onModelPress?: () => void; - onLaunchOptionPress?: (event: { nativeEvent: { id: string } }) => void; - onModeSelect?: (event: { nativeEvent: { id: string } }) => void; - onStop?: () => void; - onChipPress?: (event: { nativeEvent: { id: string } }) => void; - onQuickKeyPress?: (event: { nativeEvent: { id: string } }) => void; - onSessionTabPress?: (event: { nativeEvent: { id: string } }) => void; - onSessionTabClose?: (event: { nativeEvent: { id: string } }) => void; - onSessionTabRename?: (event: { nativeEvent: { id: string } }) => void; - onSessionTabCopyId?: (event: { nativeEvent: { id: string } }) => void; - onNewSessionPress?: () => void; - onAllSessionsPress?: () => void; - onQuickKeysActionPress?: () => void; - onHeightChange?: (event: { nativeEvent: { height: number } }) => void; - onPaste?: (event: { nativeEvent: { items: ComposerPastedItem[] } }) => void; - onDraftChange?: (event: { nativeEvent: { text: string } }) => void; - onRemoveAttachment?: (event: { nativeEvent: { id: string } }) => void; - onAttachmentPress?: (event: { nativeEvent: { id: string } }) => void; - onExpandedChange?: (event: { nativeEvent: { expanded: boolean } }) => void; -} - -const NativeComposerView = - requireNativeView("Composer"); - -const NativeComposerModule = requireNativeModule<{ - presentImage: (uri: string) => Promise; - fileThumbnail: (uri: string, side: number) => Promise; -}>("Composer"); - -/** Opens an image full screen in the composer's own viewer. */ -export function presentImage(uri: string): Promise { - return NativeComposerModule.presentImage(uri); -} - -/** A PNG preview of a local file, as the composer's file card draws it. */ -export function fileThumbnail( - uri: string, - side: number, -): Promise { - return NativeComposerModule.fileThumbnail(uri, side); -} - -/** - * How the composer treats the screen behind it while expanded. - * - * `dim` matches the mocks: the composer owns the screen and an outside tap - * dismisses it. `passthrough` leaves the content behind live so it can be - * scrolled while the keyboard is up — what a chat transcript wants. In that - * mode the caller owns dismissal, since nothing intercepts the outside tap. - */ -export type ComposerBackdrop = "dim" | "passthrough"; - -/** - * One entry in a composer picker. - * - * `iconUri` may be a remote URL or a local file URI. What it must not be is a - * Metro asset reference — SwiftUI cannot read those. Resolve bundled art with - * `expo-asset` first; see `useAgentIconUri`. - */ -export interface ComposerMenuOption { - id: string; - label: string; - iconUri?: string; - /** - * Lead with a project avatar. Separate from `iconUri` because most projects - * have no logo and the app draws their initial instead of leaving a gap — - * the same thing `ProjectAvatar` does everywhere else. - */ - avatar?: boolean; - /** - * Render a step back, as a qualifier rather than as the subject. The branch - * belongs to the project name beside it and should not compete with it. - */ - muted?: boolean; -} - -/** - * One permission mode in the menu beside `+`. `symbol` is an SF Symbol name. - */ -export interface ComposerModeOption { - id: string; - label: string; - symbol: string; -} - -/** - * One item in the composer's tray. The tray stays in React Native — it is - * shared with the attachments sheet — so the composer renders a description of - * it and reports removals and taps back out. - */ -export interface ComposerAttachment { - id: string; - uri: string; - kind: "image" | "file"; - /** - * Shown on the file card. Documents are unidentifiable without it — they all - * draw the same glyph. Ignored for images, which show themselves. - */ - name?: string; - /** - * 0–1 while the file is still on its way to cloud storage, drawn as a ring - * over the thumbnail. Omitted once it has landed, or when nothing is - * uploading — a tray of settled attachments draws no rings. - */ - progress?: number; - /** - * The upload failed. Marks the thumbnail instead of the ring; the - * attachment stays in the tray because sending the message retries it. - */ - failed?: boolean; -} - -/** - * One key in the strip above the composer — the terminal's esc/tab/arrows. - * - * Deliberately carries no behaviour: what a key writes into the PTY stays with - * the terminal that owns it. The composer draws the mark and reports the id. - */ -export interface ComposerQuickKey { - id: string; - /** Monospaced label. Ignored when `symbol` is set. */ - label?: string; - /** SF Symbol name, e.g. `arrow.up`. */ - symbol?: string; - /** - * A hairline between groups rather than a key. Which keys belong together - * is the terminal's knowledge, so it arrives as data — but a divider still - * needs its own unique `id`, because the strip identifies entries by it. - */ - divider?: boolean; -} - -/** - * One session in the strip above the quick keys — the workspace's tab bar. - * - * Data only, like the quick keys: the composer draws a pill and reports which - * one was touched. Everything about what a session *is* stays with the caller. - */ -export interface ComposerSessionTab { - id: string; - label: string; - /** - * The agent's brand mark. Same rule as `ComposerMenuOption.iconUri`: a - * remote URL or a local file URI, never a Metro asset reference. Resolve - * bundled art with `expo-asset` first — see `useAgentIconUris`. Omit for a - * plain shell, which draws the session's initial instead. - */ - iconUri?: string; - selected?: boolean; - /** Desktop's StatusIndicator states; omit for a session with nothing to say. */ - attention?: "permission" | "working" | "failed" | "review"; -} - -/** - * The one static control beside the quick keys. - * - * Data only, like the keys: the composer draws a chip and says it was pressed. - * What it opens is the caller's to know — the workspace terminal points it at - * that workspace's pull requests, and nothing here names one. - * - * It sits ahead of the bar rather than inside it, so it holds still while the - * keys scroll. It takes its room only once it exists: the keys slide over the - * moment it arrives, animated by the composer. - */ -export interface ComposerQuickKeysAction { - /** SF Symbol name, e.g. `arrow.triangle.pull`. */ - symbol: string; - /** - * A mark to draw instead of `symbol`. Same rule as - * `ComposerSessionTab.iconUri`: a local file URI, never a Metro asset - * reference. Drawn as a template, so it takes `tint` like the symbol does, - * and `symbol` shows until it resolves. - */ - iconUri?: string; - /** - * Which accent the glyph takes. Omit for the same foreground the keys use. - * The name crosses the bridge and the composer owns the colour, the way - * `ComposerSessionTab.attention` does. - */ - tint?: "open" | "draft" | "queued" | "merged" | "closed"; - /** Accessibility label. Translated here; the composer has no catalog. */ - label: string; -} - -/** - * Every user-facing string the tab strip draws. - * - * They cross the bridge because the composer cannot translate — Lingui's macros - * and catalogs live here — and a hardcoded English menu item would be the one - * untranslated string on a translated screen. - */ -export interface ComposerSessionTabLabels { - /** Context menu: opens the prompt for the session's name. */ - rename: string; - /** Context menu: copies the session's id to the pasteboard. */ - copyId: string; - /** Context menu, destructive, and the close disc's accessibility label. */ - close: string; - newSession: string; - allSessions: string; - scrollToStart: string; -} - -/** - * One slash command or skill the active agent can run — the suggestion panel - * above the composer. Data only, like the quick keys: selection replaces the - * draft natively and reports back through `onDraftChange`. - */ -export interface ComposerSlashCommand { - id: string; - /** Full display name, namespace included (`agent-sdk-dev:new-sdk-app`). */ - name: string; - descriptionText?: string; - /** The sigil that opens and commits this entry: `/`, or `$` for Codex skills. */ - trigger: "/" | "$"; - /** Non-empty when the command takes arguments; a fully typed command with - * arguments keeps the panel closed so they can be typed in peace. */ - argumentHint?: string; - /** Harness-shipped commands sort after user-defined ones, like desktop. */ - isBuiltin?: boolean; - /** Alternate names; matched after the canonical name, like desktop. */ - aliases?: string[]; -} - -/** - * A file or image pasted into the field, already written to disk by the native - * side — the tray takes URIs, the same shape the pickers produce. - */ -export interface ComposerPastedItem { - uri: string; - name: string; - kind: "image" | "file"; -} - -export interface ComposerHandle { - /** Empties the draft. */ - clear: () => void; - /** - * Appends to the draft, for dictation. The composer owns the base text and - * does the join, so callers never have to read it back. - */ - appendDraft: (text: string) => void; - /** - * Re-opens the composer after something else took first responder — an - * attachments sheet, a picker — bringing the keyboard and draft back. - */ - focus: () => void; - blur: () => void; -} - -/** - * The strip and its strings arrive together or not at all. - * - * Split out of the props rather than left as two optional fields: the composer - * cannot translate, so tabs without labels render a context menu and - * accessibility labels that are empty strings — a caller can reach that state - * without any type error, and nothing about it looks wrong until VoiceOver - * reaches it. - */ -type ComposerSessionTabsProps = - | { - sessionTabs: ComposerSessionTab[]; - sessionTabLabels: ComposerSessionTabLabels; - } - | { sessionTabs?: undefined; sessionTabLabels?: undefined }; - -export type ComposerProps = ComposerBaseProps & ComposerSessionTabsProps; - -interface ComposerBaseProps { - placeholder?: string; - /** - * Whatever this surface had typed when it was last open, put back as the - * composer is set up. Read once by the caller and never changed after: this - * is a starting value, not a binding, and the composer owns its text from - * here on. There is deliberately no `value` prop — see `onDraftChange`. - */ - initialDraft?: string; - backdrop?: ComposerBackdrop; - attachments?: ComposerAttachment[]; - /** - * The selected agent, shown as brand mark + name. Omit to hide the picker — - * what the terminal surface wants. The list itself stays in React Native: - * the real pickers are `formSheet` routes with searchable lists. - */ - selectedModel?: ComposerMenuOption; - /** - * The selected agent's launch settings, drawn after it as their own - * chevron buttons — model, effort. Each reports its id on press; the - * lists stay in React Native like the agent's. Omit for agents with none. - */ - launchOptions?: ComposerMenuOption[]; - /** The agent's permission modes, as a native menu beside `+`. */ - modeOptions?: ComposerModeOption[]; - selectedModeId?: string; - /** The agent is working: with an empty draft, send becomes stop. */ - canStop?: boolean; - /** Frame 4's header row. Empty on the session surface (frame 13). */ - headerChips?: ComposerMenuOption[]; - /** - * Keys above the card — the terminal's esc/tab/arrows. Rendered natively - * rather than by the caller: as a React Native sibling the gap to the card - * had to guess a height it could not measure, and drifted every time the - * card grew. - */ - quickKeys?: ComposerQuickKey[]; - /** - * The one static control beside the quick keys. Omitted on every surface - * with nothing to link to — which is all of them but the workspace - * terminal. Independent of `quickKeys`: an action with the keys away still - * draws the row it belongs to. - */ - quickKeysAction?: ComposerQuickKeysAction; - /** - * What the active agent can run behind `/` (or `$`). Empty or omitted - * hides the suggestion panel — a plain shell, an agent without command - * discovery, or a host too old to answer all look the same here. - */ - slashCommands?: ComposerSlashCommand[]; - /** - * Offer the `+` button. A plain shell would try to *execute* an attachment - * path, so only agent sessions get it. - */ - showAttachments?: boolean; - /** `never` for the terminal — a shell command is not a sentence. */ - autocapitalization?: "sentences" | "never"; - /** - * Shrinks the expanded editor's floor by two lines, so the terminal screen - * can leave more of the transcript on screen when the composer is at its - * smallest. Omit for the generous floor the reference frames show. - */ - compactEditor?: boolean; - /** - * A submit is in flight. Send becomes a grey spinner and the mic steps - * aside. The caller owns this because only it knows when delivery finished. - */ - isSending?: boolean; - /** - * Never clears the composer — the caller clears through the ref once its own - * delivery succeeded, so a failed send keeps the draft. - */ - onSubmit?: (text: string) => void; - onAttachmentsPress?: () => void; - /** - * Dictation runs natively — the composer owns the recogniser, the permission - * prompt and the append — so there is no press to handle here. This only - * surfaces a failure so the caller can show its own alert. - */ - onDictationError?: (message: string) => void; - onModelPress?: () => void; - onLaunchOptionPress?: (id: string) => void; - onModeSelect?: (id: string) => void; - onStop?: () => void; - onChipPress?: (id: string) => void; - onQuickKeyPress?: (id: string) => void; - /** A tab was tapped — attach that session. */ - onSessionTabPress?: (id: string) => void; - /** - * Close was chosen, from the selected tab's disc or the press-and-hold - * menu. Nothing has been killed yet: the composer has no idea what closing - * costs, so the caller confirms. - */ - onSessionTabClose?: (id: string) => void; - /** - * Rename was chosen from the press-and-hold menu. The composer neither - * asks for the new name nor knows what a session's name is — a menu cannot - * take text, and the name is the host's — so the caller prompts and saves. - */ - onSessionTabRename?: (id: string) => void; - /** - * Copy id was chosen from the press-and-hold menu. The caller owns the - * pasteboard write and whatever it shows afterwards, so the confirmation - * matches every other copy on the screen. - */ - onSessionTabCopyId?: (id: string) => void; - onNewSessionPress?: () => void; - onAllSessionsPress?: () => void; - /** - * The control beside the quick keys was pressed. Only ever fires when - * `quickKeysAction` is set, so the caller that provided the chip is the one - * that hears about it. - */ - onQuickKeysActionPress?: () => void; - /** - * How much room the composer occupies above the bottom safe area — the - * session tabs, the card, the quick keys and the gaps between them. - * - * The composer draws in an overlay and takes no layout space, so a caller - * with content underneath cannot measure it. Excludes the keyboard, which - * the caller already tracks and gets a duration and curve for. - */ - onHeightChange?: (height: number) => void; - /** - * Files and images pasted into the field. A plain text field only ever takes - * strings, so the composer owns its text view to offer Paste for these and - * writes them out; adding them to the tray is the caller's job, because the - * tray is the caller's. - */ - onPaste?: (items: ComposerPastedItem[]) => void; - /** - * Every keystroke, so a caller can keep a shadow copy of the draft and put - * it back later. Outward only, like `onHeightChange`: the composer owns its - * text while it is live and takes nothing back mid-edit, which is why there - * is no `value` prop. Restore through the ref at mount instead. - */ - onDraftChange?: (text: string) => void; - onRemoveAttachment?: (id: string) => void; - /** - * Fires only for non-image attachments. Images open in the composer's own - * full-screen viewer — it already holds the URI, so routing the tap out and - * back would buy nothing — but only the app knows what to do with a document. - */ - onAttachmentPress?: (id: string) => void; - /** - * Fires whenever the composer opens or closes. Callers need it to restore - * the composer only when it was actually open — re-focusing unconditionally - * after a sheet pops the keyboard back up over a collapsed composer. - */ - onExpandedChange?: (expanded: boolean) => void; -} - -/** - * The native composer. Renders nothing in the React Native layout — it mounts a - * full-screen overlay over the screen's own view controller and draws there, so - * callers reserve room for the collapsed pill with a content inset rather than - * with layout. - * - * Every surface-specific difference arrives as data, never as children: the - * moment a `ReactNode` crosses this boundary the seam artifacts that made - * `GlassComposer` unmaintainable come back with it. See - * `plans/20260821-native-composer.md`. - */ -export const Composer = forwardRef( - function Composer( - { - placeholder = "", - initialDraft = "", - backdrop = "dim", - attachments, - selectedModel, - launchOptions, - modeOptions, - selectedModeId, - canStop = false, - headerChips, - quickKeys, - sessionTabs, - sessionTabLabels, - quickKeysAction, - slashCommands, - showAttachments = true, - autocapitalization = "sentences", - compactEditor = false, - isSending = false, - onSubmit, - onAttachmentsPress, - onDictationError, - onModelPress, - onLaunchOptionPress, - onModeSelect, - onStop, - onChipPress, - onQuickKeyPress, - onSessionTabPress, - onSessionTabClose, - onSessionTabRename, - onSessionTabCopyId, - onNewSessionPress, - onAllSessionsPress, - onQuickKeysActionPress, - onHeightChange, - onPaste, - onDraftChange, - onRemoveAttachment, - onAttachmentPress, - onExpandedChange, - }, - ref, - ) { - const nativeRef = useRef(null); - - useImperativeHandle(ref, () => ({ - clear: () => nativeRef.current?.clear(), - appendDraft: (text: string) => nativeRef.current?.appendDraft(text), - focus: () => nativeRef.current?.focus(), - blur: () => nativeRef.current?.blur(), - })); - - return ( - onSubmit?.(event.nativeEvent.text)} - onAttachmentsPress={onAttachmentsPress} - onDictationError={(event) => - onDictationError?.(event.nativeEvent.message) - } - onModelPress={onModelPress} - onLaunchOptionPress={(event) => - onLaunchOptionPress?.(event.nativeEvent.id) - } - onModeSelect={(event) => onModeSelect?.(event.nativeEvent.id)} - onStop={() => onStop?.()} - onChipPress={(event) => onChipPress?.(event.nativeEvent.id)} - onQuickKeyPress={(event) => onQuickKeyPress?.(event.nativeEvent.id)} - onSessionTabPress={(event) => onSessionTabPress?.(event.nativeEvent.id)} - onSessionTabClose={(event) => onSessionTabClose?.(event.nativeEvent.id)} - onSessionTabRename={(event) => - onSessionTabRename?.(event.nativeEvent.id) - } - onSessionTabCopyId={(event) => - onSessionTabCopyId?.(event.nativeEvent.id) - } - onNewSessionPress={onNewSessionPress} - onAllSessionsPress={onAllSessionsPress} - onQuickKeysActionPress={onQuickKeysActionPress} - onHeightChange={(event) => onHeightChange?.(event.nativeEvent.height)} - onPaste={(event) => onPaste?.(event.nativeEvent.items)} - onDraftChange={(event) => onDraftChange?.(event.nativeEvent.text)} - onRemoveAttachment={(event) => - onRemoveAttachment?.(event.nativeEvent.id) - } - onAttachmentPress={(event) => onAttachmentPress?.(event.nativeEvent.id)} - onExpandedChange={(event) => - onExpandedChange?.(event.nativeEvent.expanded) - } - /> - ); - }, -); +export { Composer, fileThumbnail, presentImage } from "./Composer"; +export type { + ComposerAttachment, + ComposerBackdrop, + ComposerComponent, + ComposerHandle, + ComposerMenuOption, + ComposerModeOption, + ComposerPastedItem, + ComposerProps, + ComposerQuickKey, + ComposerQuickKeysAction, + ComposerSessionTab, + ComposerSessionTabLabels, + ComposerSlashCommand, + FileThumbnail, + PresentImage, +} from "./Composer.types"; diff --git a/apps/mobile/modules/forward-swipe/src/ForwardSwipe.ios.tsx b/apps/mobile/modules/forward-swipe/src/ForwardSwipe.ios.tsx new file mode 100644 index 00000000000..f445cae4867 --- /dev/null +++ b/apps/mobile/modules/forward-swipe/src/ForwardSwipe.ios.tsx @@ -0,0 +1,22 @@ +import { requireNativeView } from "expo"; +import type { ForwardSwipeComponent } from "./ForwardSwipe.types"; + +interface NativeForwardSwipeProps { + enabled: boolean; + onSwipeBegin: () => void; + style: { width: number; height: number }; +} + +const NativeForwardSwipe = + requireNativeView("ForwardSwipe"); + +export const ForwardSwipe: ForwardSwipeComponent = ({ + enabled, + onSwipeBegin, +}) => ( + +); diff --git a/apps/mobile/modules/forward-swipe/src/ForwardSwipe.tsx b/apps/mobile/modules/forward-swipe/src/ForwardSwipe.tsx new file mode 100644 index 00000000000..6a5a96bc381 --- /dev/null +++ b/apps/mobile/modules/forward-swipe/src/ForwardSwipe.tsx @@ -0,0 +1,3 @@ +import type { ForwardSwipeComponent } from "./ForwardSwipe.types"; + +export const ForwardSwipe: ForwardSwipeComponent = () => null; diff --git a/apps/mobile/modules/forward-swipe/src/ForwardSwipe.types.ts b/apps/mobile/modules/forward-swipe/src/ForwardSwipe.types.ts new file mode 100644 index 00000000000..d6493d65723 --- /dev/null +++ b/apps/mobile/modules/forward-swipe/src/ForwardSwipe.types.ts @@ -0,0 +1,12 @@ +import type { ComponentType } from "react"; + +export interface ForwardSwipeProps { + enabled: boolean; + onSwipeBegin: () => void; +} + +/** + * A pan from the enclosing screen's right edge calls `onSwipeBegin`. The + * screen that handler pushes then follows the finger. + */ +export type ForwardSwipeComponent = ComponentType; diff --git a/apps/mobile/modules/forward-swipe/src/index.tsx b/apps/mobile/modules/forward-swipe/src/index.tsx index 18ff380a74a..ee58e6a2a52 100644 --- a/apps/mobile/modules/forward-swipe/src/index.tsx +++ b/apps/mobile/modules/forward-swipe/src/index.tsx @@ -1,30 +1,2 @@ -import { requireNativeView } from "expo"; - -interface NativeForwardSwipeProps { - enabled: boolean; - onSwipeBegin: () => void; - style: { width: number; height: number }; -} - -const NativeForwardSwipe = - requireNativeView("ForwardSwipe"); - -/** - * A pan from the enclosing screen's right edge calls `onSwipeBegin`. The - * screen that handler pushes then follows the finger. - */ -export function ForwardSwipe({ - enabled, - onSwipeBegin, -}: { - enabled: boolean; - onSwipeBegin: () => void; -}) { - return ( - - ); -} +export { ForwardSwipe } from "./ForwardSwipe"; +export type { ForwardSwipeProps } from "./ForwardSwipe.types"; diff --git a/apps/mobile/modules/live-activity/src/LiveActivity.ios.ts b/apps/mobile/modules/live-activity/src/LiveActivity.ios.ts new file mode 100644 index 00000000000..9076bca2205 --- /dev/null +++ b/apps/mobile/modules/live-activity/src/LiveActivity.ios.ts @@ -0,0 +1,4 @@ +import { requireNativeModule } from "expo"; +import type { LiveActivityModule } from "./LiveActivity.types"; + +export default requireNativeModule("LiveActivity"); diff --git a/apps/mobile/modules/live-activity/src/LiveActivity.ts b/apps/mobile/modules/live-activity/src/LiveActivity.ts new file mode 100644 index 00000000000..69fc1ee52d4 --- /dev/null +++ b/apps/mobile/modules/live-activity/src/LiveActivity.ts @@ -0,0 +1,12 @@ +import type { LiveActivityModule } from "./LiveActivity.types"; + +const LiveActivity: LiveActivityModule = { + areActivitiesEnabled: () => false, + activeIds: () => [], + cacheIcon: async () => "", + start: async () => "", + update: async () => {}, + endAll: async () => {}, +}; + +export default LiveActivity; diff --git a/apps/mobile/modules/live-activity/src/LiveActivity.types.ts b/apps/mobile/modules/live-activity/src/LiveActivity.types.ts new file mode 100644 index 00000000000..0948c063355 --- /dev/null +++ b/apps/mobile/modules/live-activity/src/LiveActivity.types.ts @@ -0,0 +1,42 @@ +export interface AgentRow { + /** Terminal id — selected as `?tab=` on the workspace route. */ + id: string; + /** Workspace the terminal belongs to; the deep link's real target. */ + workspaceId: string; + name: string; + project: string; + /** Filename returned by `cacheIcon`, or omitted to draw the initial. */ + iconFile?: string; + /** Pre-translated status word, e.g. "Needs you". */ + status: string; + state: "permission" | "working" | "failed" | "review"; + /** + * Time in the current state, already formatted — "12m", "1h", "3d". + * Formatted here rather than natively: SwiftUI's free-ticking timer can + * only render mm:ss, and units belong in Lingui's catalogs. + */ + elapsed: string; + isQuiet?: boolean; +} + +export interface AgentSnapshot { + rows: AgentRow[]; + more?: string; + /** Every agent, not just the rows that fit on the card. */ + totalCount: number; + topState: AgentRow["state"]; + staleDetail?: string; + machineName: string; + staleAfterSeconds?: number; +} + +/** A glanceable, system-drawn card of agent status outside the app. */ +export interface LiveActivityModule { + areActivitiesEnabled: () => boolean; + activeIds: () => string[]; + /** Downloads, downscales and caches a project icon where the card can read it. */ + cacheIcon: (key: string, url: string) => Promise; + start: (snapshot: AgentSnapshot) => Promise; + update: (id: string, snapshot: AgentSnapshot) => Promise; + endAll: () => Promise; +} diff --git a/apps/mobile/modules/live-activity/src/index.ts b/apps/mobile/modules/live-activity/src/index.ts index ba2ff5e3f21..fc017bf462e 100644 --- a/apps/mobile/modules/live-activity/src/index.ts +++ b/apps/mobile/modules/live-activity/src/index.ts @@ -1,66 +1,7 @@ -import { requireNativeModule } from "expo"; - -export interface AgentRow { - /** Terminal id — selected as `?tab=` on the workspace route. */ - id: string; - /** Workspace the terminal belongs to; the deep link's real target. */ - workspaceId: string; - name: string; - project: string; - /** Filename returned by `cacheIcon`, or omitted to draw the initial. */ - iconFile?: string; - /** Pre-translated status word, e.g. "Needs you". */ - status: string; - state: "permission" | "working" | "failed" | "review"; - /** - * Time in the current state, already formatted — "12m", "1h", "3d". - * Formatted here rather than in Swift: SwiftUI's free-ticking timer can - * only render mm:ss, and units belong in Lingui's catalogs. - */ - elapsed: string; - isQuiet?: boolean; -} - -export interface AgentSnapshot { - rows: AgentRow[]; - more?: string; - /** Every agent, not just the rows that fit on the card. */ - totalCount: number; - topState: AgentRow["state"]; - staleDetail?: string; - machineName: string; - staleAfterSeconds?: number; -} - -/** - * Row order for the card, most urgent first. - * - * Deliberately NOT desktop's `STATUS_PRIORITY`, which ranks `working` above - * `review`. On a glanceable card the question is "what wants me?", and a - * finished session waiting to be read wants you more than a busy one that - * does not. Rows are sorted by this, then by recency. - */ -export const CARD_PRIORITY: Record = { - permission: 4, - failed: 3, - review: 2, - working: 1, -}; - -export function orderRows(rows: AgentRow[]): AgentRow[] { - return [...rows].sort( - (a, b) => CARD_PRIORITY[b.state] - CARD_PRIORITY[a.state], - ); -} - -interface LiveActivityModule { - areActivitiesEnabled: () => boolean; - activeIds: () => string[]; - /** Downloads, downscales and caches a project icon into the App Group. */ - cacheIcon: (key: string, url: string) => Promise; - start: (snapshot: AgentSnapshot) => Promise; - update: (id: string, snapshot: AgentSnapshot) => Promise; - endAll: () => Promise; -} - -export default requireNativeModule("LiveActivity"); +export { default } from "./LiveActivity"; +export type { + AgentRow, + AgentSnapshot, + LiveActivityModule, +} from "./LiveActivity.types"; +export { CARD_PRIORITY, orderRows } from "./orderRows"; diff --git a/apps/mobile/modules/live-activity/src/orderRows.ts b/apps/mobile/modules/live-activity/src/orderRows.ts new file mode 100644 index 00000000000..2cbe3fc3d25 --- /dev/null +++ b/apps/mobile/modules/live-activity/src/orderRows.ts @@ -0,0 +1,22 @@ +import type { AgentRow } from "./LiveActivity.types"; + +/** + * Row order for the card, most urgent first. + * + * Deliberately NOT desktop's `STATUS_PRIORITY`, which ranks `working` above + * `review`. On a glanceable card the question is "what wants me?", and a + * finished session waiting to be read wants you more than a busy one that + * does not. Rows are sorted by this, then by recency. + */ +export const CARD_PRIORITY: Record = { + permission: 4, + failed: 3, + review: 2, + working: 1, +}; + +export function orderRows(rows: AgentRow[]): AgentRow[] { + return [...rows].sort( + (a, b) => CARD_PRIORITY[b.state] - CARD_PRIORITY[a.state], + ); +} diff --git a/apps/mobile/modules/symbol-button/src/SymbolButton.ios.tsx b/apps/mobile/modules/symbol-button/src/SymbolButton.ios.tsx new file mode 100644 index 00000000000..a7ebd57f2ec --- /dev/null +++ b/apps/mobile/modules/symbol-button/src/SymbolButton.ios.tsx @@ -0,0 +1,28 @@ +import { requireNativeView } from "expo"; +import type { + SymbolButtonComponent, + SymbolButtonProps, +} from "./SymbolButton.types"; + +type NativeSymbolButtonProps = Omit< + SymbolButtonProps, + "onPress" | "onSelect" +> & { + onTap?: () => void; + onSelect?: (event: { nativeEvent: { id: string } }) => void; +}; + +const NativeSymbolButton = + requireNativeView("SymbolButton"); + +export const SymbolButton: SymbolButtonComponent = ({ + onSelect, + onPress, + ...props +}) => ( + onSelect?.(event.nativeEvent.id)} + /> +); diff --git a/apps/mobile/modules/symbol-button/src/SymbolButton.tsx b/apps/mobile/modules/symbol-button/src/SymbolButton.tsx new file mode 100644 index 00000000000..6447217757e --- /dev/null +++ b/apps/mobile/modules/symbol-button/src/SymbolButton.tsx @@ -0,0 +1,3 @@ +import type { SymbolButtonComponent } from "./SymbolButton.types"; + +export const SymbolButton: SymbolButtonComponent = () => null; diff --git a/apps/mobile/modules/symbol-button/src/SymbolButton.types.ts b/apps/mobile/modules/symbol-button/src/SymbolButton.types.ts new file mode 100644 index 00000000000..e96c6a91f61 --- /dev/null +++ b/apps/mobile/modules/symbol-button/src/SymbolButton.types.ts @@ -0,0 +1,25 @@ +import type { ComponentType } from "react"; +import type { StyleProp, ViewStyle } from "react-native"; + +export interface SymbolMenuItem { + id: string; + title: string; + /** SF Symbol name. */ + systemImage?: string; +} + +export interface SymbolButtonProps { + /** SF Symbol name. */ + systemImage: string; + size?: number; + tint?: string; + accessibilityLabel?: string; + enabled?: boolean; + /** Turns the tap into a pull-down menu of these items. */ + items?: SymbolMenuItem[]; + onPress?: () => void; + onSelect?: (id: string) => void; + style?: StyleProp; +} + +export type SymbolButtonComponent = ComponentType; diff --git a/apps/mobile/modules/symbol-button/src/index.tsx b/apps/mobile/modules/symbol-button/src/index.tsx index 762eace7447..5b2cc46cebf 100644 --- a/apps/mobile/modules/symbol-button/src/index.tsx +++ b/apps/mobile/modules/symbol-button/src/index.tsx @@ -1,39 +1,2 @@ -import { requireNativeView } from "expo"; -import type { StyleProp, ViewStyle } from "react-native"; - -export interface SymbolMenuItem { - id: string; - title: string; - systemImage?: string; -} - -interface SymbolButtonProps { - systemImage: string; - size?: number; - tint?: string; - accessibilityLabel?: string; - enabled?: boolean; - items?: SymbolMenuItem[]; - onTap?: () => void; - onSelect?: (event: { nativeEvent: { id: string } }) => void; - style?: StyleProp; -} - -const NativeSymbolButton = requireNativeView("SymbolButton"); - -export function SymbolButton({ - onSelect, - onPress, - ...props -}: Omit & { - onSelect?: (id: string) => void; - onPress?: () => void; -}) { - return ( - onSelect?.(event.nativeEvent.id)} - /> - ); -} +export { SymbolButton } from "./SymbolButton"; +export type { SymbolButtonProps, SymbolMenuItem } from "./SymbolButton.types"; diff --git a/apps/mobile/modules/title-press/src/TitlePress.ios.tsx b/apps/mobile/modules/title-press/src/TitlePress.ios.tsx new file mode 100644 index 00000000000..ad84d5bce55 --- /dev/null +++ b/apps/mobile/modules/title-press/src/TitlePress.ios.tsx @@ -0,0 +1,13 @@ +import { requireNativeView } from "expo"; +import type { TitlePressComponent } from "./TitlePress.types"; + +interface NativeTitlePressProps { + onTitlePress: () => void; + style: { width: number; height: number }; +} + +const NativeTitlePress = requireNativeView("TitlePress"); + +export const TitlePress: TitlePressComponent = ({ onPress }) => ( + +); diff --git a/apps/mobile/modules/title-press/src/TitlePress.tsx b/apps/mobile/modules/title-press/src/TitlePress.tsx new file mode 100644 index 00000000000..7eb6c8233f7 --- /dev/null +++ b/apps/mobile/modules/title-press/src/TitlePress.tsx @@ -0,0 +1,3 @@ +import type { TitlePressComponent } from "./TitlePress.types"; + +export const TitlePress: TitlePressComponent = () => null; diff --git a/apps/mobile/modules/title-press/src/TitlePress.types.ts b/apps/mobile/modules/title-press/src/TitlePress.types.ts new file mode 100644 index 00000000000..ee7febe9048 --- /dev/null +++ b/apps/mobile/modules/title-press/src/TitlePress.types.ts @@ -0,0 +1,8 @@ +import type { ComponentType } from "react"; + +export interface TitlePressProps { + onPress: () => void; +} + +/** Reports taps on the enclosing native stack screen's title. */ +export type TitlePressComponent = ComponentType; diff --git a/apps/mobile/modules/title-press/src/index.tsx b/apps/mobile/modules/title-press/src/index.tsx index dcd584d9b1b..2e1e0db2dc4 100644 --- a/apps/mobile/modules/title-press/src/index.tsx +++ b/apps/mobile/modules/title-press/src/index.tsx @@ -1,15 +1,2 @@ -import { requireNativeView } from "expo"; - -interface NativeTitlePressProps { - onTitlePress: () => void; - style: { width: number; height: number }; -} - -const NativeTitlePress = requireNativeView("TitlePress"); - -/** Reports taps on the enclosing native stack screen's title. */ -export function TitlePress({ onPress }: { onPress: () => void }) { - return ( - - ); -} +export { TitlePress } from "./TitlePress"; +export type { TitlePressProps } from "./TitlePress.types"; diff --git a/apps/mobile/modules/voice/src/VoiceAudio.ios.ts b/apps/mobile/modules/voice/src/VoiceAudio.ios.ts new file mode 100644 index 00000000000..89c0f99647b --- /dev/null +++ b/apps/mobile/modules/voice/src/VoiceAudio.ios.ts @@ -0,0 +1,39 @@ +import { requireOptionalNativeModule } from "expo"; +import type { EventSubscription } from "expo-modules-core"; +import type { + VoiceAudioApi, + VoiceInterruptionEvent, + VoiceRouteChangeEvent, +} from "./VoiceAudio.types"; + +interface VoiceAudioNativeModule { + configure(): Promise; + preferSpeaker(on: boolean): Promise; + release(): Promise; + currentRoute(): string[]; + addListener( + event: "onInterruption", + listener: (event: VoiceInterruptionEvent) => void, + ): EventSubscription; + addListener( + event: "onRouteChange", + listener: (event: VoiceRouteChangeEvent) => void, + ): EventSubscription; +} + +// Optional: a shell built before this module runs voice over WebRTC's own +// session, on the receiver, rather than not at all. +const native = + requireOptionalNativeModule("VoiceAudio"); + +export const VoiceAudio: VoiceAudioApi = { + available: native !== null, + configure: () => native?.configure() ?? Promise.resolve(), + preferSpeaker: (on) => native?.preferSpeaker(on) ?? Promise.resolve(), + release: () => native?.release() ?? Promise.resolve(), + currentRoute: () => native?.currentRoute() ?? [], + onInterruption: (listener) => + native?.addListener("onInterruption", listener) ?? null, + onRouteChange: (listener) => + native?.addListener("onRouteChange", listener) ?? null, +}; diff --git a/apps/mobile/modules/voice/src/VoiceAudio.ts b/apps/mobile/modules/voice/src/VoiceAudio.ts new file mode 100644 index 00000000000..56f648f3c5b --- /dev/null +++ b/apps/mobile/modules/voice/src/VoiceAudio.ts @@ -0,0 +1,11 @@ +import type { VoiceAudioApi } from "./VoiceAudio.types"; + +export const VoiceAudio: VoiceAudioApi = { + available: false, + configure: () => Promise.resolve(), + preferSpeaker: () => Promise.resolve(), + release: () => Promise.resolve(), + currentRoute: () => [], + onInterruption: () => null, + onRouteChange: () => null, +}; diff --git a/apps/mobile/modules/voice/src/VoiceAudio.types.ts b/apps/mobile/modules/voice/src/VoiceAudio.types.ts new file mode 100644 index 00000000000..376a5e7887b --- /dev/null +++ b/apps/mobile/modules/voice/src/VoiceAudio.types.ts @@ -0,0 +1,29 @@ +export interface VoiceInterruptionEvent { + phase: "began" | "ended"; + shouldResume: boolean; +} + +export interface VoiceRouteChangeEvent { + reason: string; + outputs: string[]; +} + +export interface VoiceSubscription { + remove(): void; +} + +/** The app's audio session for a voice call. */ +export interface VoiceAudioApi { + /** False when voice runs on WebRTC's own session instead. */ + available: boolean; + configure(): Promise; + preferSpeaker(on: boolean): Promise; + release(): Promise; + currentRoute(): string[]; + onInterruption( + listener: (event: VoiceInterruptionEvent) => void, + ): VoiceSubscription | null; + onRouteChange( + listener: (event: VoiceRouteChangeEvent) => void, + ): VoiceSubscription | null; +} diff --git a/apps/mobile/modules/voice/src/index.ts b/apps/mobile/modules/voice/src/index.ts index 893b7219043..c28cfbb7bc0 100644 --- a/apps/mobile/modules/voice/src/index.ts +++ b/apps/mobile/modules/voice/src/index.ts @@ -1,45 +1,7 @@ -import { requireOptionalNativeModule } from "expo"; -import type { EventSubscription } from "expo-modules-core"; - -export interface VoiceInterruptionEvent { - phase: "began" | "ended"; - shouldResume: boolean; -} - -export interface VoiceRouteChangeEvent { - reason: string; - outputs: string[]; -} - -interface VoiceAudioNativeModule { - configure(): Promise; - preferSpeaker(on: boolean): Promise; - release(): Promise; - currentRoute(): string[]; - addListener( - event: "onInterruption", - listener: (event: VoiceInterruptionEvent) => void, - ): EventSubscription; - addListener( - event: "onRouteChange", - listener: (event: VoiceRouteChangeEvent) => void, - ): EventSubscription; -} - -// Optional: a JS-only build (tests, a shell built before this module) runs -// voice over WebRTC's own session, on the receiver, rather than not at all. -const native = - requireOptionalNativeModule("VoiceAudio"); - -export const VoiceAudio = { - available: native !== null, - configure: () => native?.configure() ?? Promise.resolve(), - preferSpeaker: (on: boolean) => - native?.preferSpeaker(on) ?? Promise.resolve(), - release: () => native?.release() ?? Promise.resolve(), - currentRoute: () => native?.currentRoute() ?? [], - onInterruption: (listener: (event: VoiceInterruptionEvent) => void) => - native?.addListener("onInterruption", listener) ?? null, - onRouteChange: (listener: (event: VoiceRouteChangeEvent) => void) => - native?.addListener("onRouteChange", listener) ?? null, -}; +export { VoiceAudio } from "./VoiceAudio"; +export type { + VoiceAudioApi, + VoiceInterruptionEvent, + VoiceRouteChangeEvent, + VoiceSubscription, +} from "./VoiceAudio.types"; diff --git a/apps/mobile/screens/(auth)/sign-in/SignInScreen.tsx b/apps/mobile/screens/(auth)/sign-in/SignInScreen.tsx index 852d226a766..3e28bce86b8 100644 --- a/apps/mobile/screens/(auth)/sign-in/SignInScreen.tsx +++ b/apps/mobile/screens/(auth)/sign-in/SignInScreen.tsx @@ -1,8 +1,6 @@ import { Trans } from "@lingui/react/macro"; import { BRAND } from "@superset/shared/brand"; import { COMPANY } from "@superset/shared/constants"; -import * as AppleAuthentication from "expo-apple-authentication"; -import * as Crypto from "expo-crypto"; import { useState } from "react"; import { Image, View } from "react-native"; @@ -16,6 +14,7 @@ import { DevSignInOptions } from "./components/DevSignInOptions"; import { EmailSignInLink } from "./components/EmailSignInLink"; import type { SocialProvider } from "./components/SocialButton"; import { SocialButton } from "./components/SocialButton"; +import { signInWithApple } from "./utils/signInWithApple"; export function SignInScreen() { const appName = BRAND.NAME; @@ -37,47 +36,8 @@ export function SignInScreen() { const handleAppleSignIn = async () => { setError(null); try { - // Apple puts SHA256(nonce) in the identity token; the server gets the - // raw nonce and better-auth compares against the hashed claim. - const rawNonce = Crypto.randomUUID(); - const hashedNonce = await Crypto.digestStringAsync( - Crypto.CryptoDigestAlgorithm.SHA256, - rawNonce, - ); - const credential = await AppleAuthentication.signInAsync({ - requestedScopes: [ - AppleAuthentication.AppleAuthenticationScope.FULL_NAME, - AppleAuthentication.AppleAuthenticationScope.EMAIL, - ], - nonce: hashedNonce, - }); - if (!credential.identityToken) { - throw new Error("Apple did not return an identity token"); - } - await signIn.social({ - provider: "apple", - idToken: { - token: credential.identityToken, - nonce: rawNonce, - // Apple only sends name/email on the very first authorization - user: { - name: { - firstName: credential.fullName?.givenName ?? undefined, - lastName: credential.fullName?.familyName ?? undefined, - }, - email: credential.email ?? undefined, - }, - }, - callbackURL: "/", - }); + await signInWithApple(); } catch (err) { - if ( - err instanceof Error && - "code" in err && - err.code === "ERR_REQUEST_CANCELED" - ) { - return; - } console.error("[sign-in] Apple error:", err); setError(errorCopy(err)); } diff --git a/apps/mobile/screens/(auth)/sign-in/utils/signInWithApple/index.ts b/apps/mobile/screens/(auth)/sign-in/utils/signInWithApple/index.ts new file mode 100644 index 00000000000..cc78aeb0bc8 --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/utils/signInWithApple/index.ts @@ -0,0 +1 @@ +export { signInWithApple } from "./signInWithApple"; diff --git a/apps/mobile/screens/(auth)/sign-in/utils/signInWithApple/signInWithApple.ios.ts b/apps/mobile/screens/(auth)/sign-in/utils/signInWithApple/signInWithApple.ios.ts new file mode 100644 index 00000000000..f86bfa0dea0 --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/utils/signInWithApple/signInWithApple.ios.ts @@ -0,0 +1,52 @@ +import * as AppleAuthentication from "expo-apple-authentication"; +import * as Crypto from "expo-crypto"; +import { signIn } from "@/lib/auth/client"; + +/** Resolves without signing in when the user cancels the system sheet. */ +export async function signInWithApple(): Promise { + // Apple puts SHA256(nonce) in the identity token; the server gets the + // raw nonce and better-auth compares against the hashed claim. + const rawNonce = Crypto.randomUUID(); + const hashedNonce = await Crypto.digestStringAsync( + Crypto.CryptoDigestAlgorithm.SHA256, + rawNonce, + ); + let credential: AppleAuthentication.AppleAuthenticationCredential; + try { + credential = await AppleAuthentication.signInAsync({ + requestedScopes: [ + AppleAuthentication.AppleAuthenticationScope.FULL_NAME, + AppleAuthentication.AppleAuthenticationScope.EMAIL, + ], + nonce: hashedNonce, + }); + } catch (err) { + if ( + err instanceof Error && + "code" in err && + err.code === "ERR_REQUEST_CANCELED" + ) { + return; + } + throw err; + } + if (!credential.identityToken) { + throw new Error("Apple did not return an identity token"); + } + await signIn.social({ + provider: "apple", + idToken: { + token: credential.identityToken, + nonce: rawNonce, + // Apple only sends name/email on the very first authorization + user: { + name: { + firstName: credential.fullName?.givenName ?? undefined, + lastName: credential.fullName?.familyName ?? undefined, + }, + email: credential.email ?? undefined, + }, + }, + callbackURL: "/", + }); +} diff --git a/apps/mobile/screens/(auth)/sign-in/utils/signInWithApple/signInWithApple.ts b/apps/mobile/screens/(auth)/sign-in/utils/signInWithApple/signInWithApple.ts new file mode 100644 index 00000000000..7cfe1809973 --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/utils/signInWithApple/signInWithApple.ts @@ -0,0 +1,5 @@ +import { signIn } from "@/lib/auth/client"; + +export async function signInWithApple(): Promise { + await signIn.social({ provider: "apple", callbackURL: "/" }); +} diff --git a/apps/mobile/screens/(authenticated)/components/ShareAccess/ShareInviteScreen/ShareInviteScreen.tsx b/apps/mobile/screens/(authenticated)/components/ShareAccess/ShareInviteScreen/ShareInviteScreen.tsx index 000813b47d7..353d9e3f083 100644 --- a/apps/mobile/screens/(authenticated)/components/ShareAccess/ShareInviteScreen/ShareInviteScreen.tsx +++ b/apps/mobile/screens/(authenticated)/components/ShareAccess/ShareInviteScreen/ShareInviteScreen.tsx @@ -1,4 +1,3 @@ -import { Button } from "@expo/ui/swift-ui"; import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import { @@ -147,19 +146,14 @@ export function ShareInviteScreen({ label={current?.label} a11yLabel={t({ message: "Access for this person" })} size={14} - chevron="chevron.up.chevron.down" - > - {roles.map((option) => ( -