Files
orca/mobile/src/components/ActionSheetModal.tsx
T
Jinwoo HongandOrca 77b154d5dd Add Orca Relay desktop and mobile transport (#8536)
* feat(mobile): define relay protocol groundwork

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): implement replay-safe E2EE v2 sessions

Co-authored-by: Orca <help@stably.ai>

* test(auth): lock cloud refresh single-flight

Co-authored-by: Orca <help@stably.ai>

* test(mobile): complete E2EE v2 adversarial coverage

Co-authored-by: Orca <help@stably.ai>

* refactor(runtime): unify mobile socket wiring

Co-authored-by: Orca <help@stably.ai>

* feat(runtime): add relay control and data clients

Co-authored-by: Orca <help@stably.ai>

* feat(runtime): coordinate desktop relay sessions

Co-authored-by: Orca <help@stably.ai>

* fix(auth): fence stale cloud session mutations

Co-authored-by: Orca <help@stably.ai>

* feat(runtime): add relay pairing and durable revoke

Co-authored-by: Orca <help@stably.ai>

* feat(runtime): add relay credential pairing RPCs

Co-authored-by: Orca <help@stably.ai>

* feat(settings): show Orca Relay sign-in status

Co-authored-by: Orca <help@stably.ai>

* test(relay): prove desktop lifecycle and E2EE splice

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): persist relay pairing state

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): race direct and relay pairing

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): recover pairing through relay director

Co-authored-by: Orca <help@stably.ai>

* fix(relay): preserve origin controls during drain

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): recover interrupted relay pairing

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): add stable relay RPC sessions

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): supervise direct and relay endpoints

Co-authored-by: Orca <help@stably.ai>

* Cover mobile relay director fallback matrix

Co-authored-by: Orca <help@stably.ai>

* Fix relay settings component test isolation

Co-authored-by: Orca <help@stably.ai>

* Remove unrelated merge formatting drift

Co-authored-by: Orca <help@stably.ai>

* Update runtime connection count integration assertion

Co-authored-by: Orca <help@stably.ai>

* Run mobile typecheck through pnpm

Co-authored-by: Orca <help@stably.ai>

* feat(relay): gate desktop controls on mobile demand

Co-authored-by: Orca <help@stably.ai>

* test(mobile): cover served relay recovery

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): upgrade direct pairings to relay

Co-authored-by: Orca <help@stably.ai>

* fix(relay): harden mobile reconnect and teardown

Co-authored-by: Orca <help@stably.ai>

* fix(auth): clarify account sign-in state

Co-authored-by: Orca <help@stably.ai>

* fix(auth): polish sign-in completion flow

Co-authored-by: Orca <help@stably.ai>

* fix(auth): clarify sign-out confirmation

Co-authored-by: Orca <help@stably.ai>

* fix(auth): simplify sign-in completion page

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): add per-device pairing connection mode

Co-authored-by: Orca <help@stably.ai>

* fix(mobile): stabilize pairing option layout

Co-authored-by: Orca <help@stably.ai>

* fix(mobile): give pairing choices stable space

Co-authored-by: Orca <help@stably.ai>

* fix(mobile): stabilize pairing QR regeneration

Co-authored-by: Orca <help@stably.ai>

* Animate mobile pairing flow height

Co-authored-by: Orca <help@stably.ai>

* Configure auth in packaged builds

Co-authored-by: Orca <help@stably.ai>

* Make Orca Relay pairing an opt-in beta

Co-authored-by: Orca <help@stably.ai>

* Show Relay beta details on hover

Co-authored-by: Orca <help@stably.ai>

* Refine mobile relay pairing choice

Co-authored-by: Orca <help@stably.ai>

* Polish Orca Relay pairing controls

Co-authored-by: Orca <help@stably.ai>

* Keep mobile contract fallback test additive

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-14 11:47:05 -07:00

205 lines
5.3 KiB
TypeScript

import { useRef, type ReactNode } from 'react'
import { ActivityIndicator, View, Text, Pressable, StyleSheet } from 'react-native'
import { Edit3, Trash2, type LucideIcon } from 'lucide-react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
import { BottomDrawer } from './BottomDrawer'
export type ActionSheetAction = {
label: string
icon?: LucideIcon
renderIcon?: () => ReactNode
destructive?: boolean
disabled?: boolean
hint?: string
loading?: boolean
skipAutoClose?: boolean
closeBeforePress?: boolean
onPress: () => void
}
type Props = {
visible: boolean
title?: string
message?: string
actions: ActionSheetAction[]
onClose: () => void
}
function iconForAction(label: string, destructive?: boolean, icon?: LucideIcon): LucideIcon {
if (icon) {
return icon
}
if (destructive || /delete|remove/i.test(label)) {
return Trash2
}
return Edit3
}
type ContentProps = {
title?: string
message?: string
actions: ActionSheetAction[]
onClose?: () => void
}
export function ActionSheetContent({ title, message, actions, onClose }: ContentProps) {
return (
<>
{(title || message) && (
<View style={styles.header}>
{title ? (
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
) : null}
{message ? <Text style={styles.message}>{message}</Text> : null}
</View>
)}
<View style={styles.actionGroup}>
{actions.map((action, i) => {
const Icon = iconForAction(action.label, action.destructive, action.icon)
const customIcon = action.renderIcon?.()
return (
<View key={action.label}>
{i > 0 && <View style={styles.separator} />}
<Pressable
style={({ pressed }) => [
styles.action,
action.disabled && styles.actionDisabled,
pressed && !action.disabled && !action.loading && styles.actionPressed
]}
disabled={action.disabled || action.loading}
onPress={() => {
action.onPress()
if (!action.skipAutoClose && onClose) {
onClose()
}
}}
>
{customIcon ?? (
<Icon
size={16}
color={action.destructive ? colors.statusRed : colors.textSecondary}
/>
)}
<View style={styles.actionTextBlock}>
<Text
style={[
styles.actionText,
action.destructive && styles.actionTextDestructive,
action.disabled && styles.actionTextDisabled
]}
>
{action.label}
</Text>
{action.hint ? <Text style={styles.actionHint}>{action.hint}</Text> : null}
</View>
{action.loading ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : null}
</Pressable>
</View>
)
})}
</View>
</>
)
}
export function ActionSheetModal({ visible, title, message, actions, onClose }: Props) {
const pendingActionRef = useRef<(() => void) | null>(null)
const sequencedActions = actions.map((action) =>
action.closeBeforePress
? {
...action,
onPress: () => {
pendingActionRef.current = action.onPress
}
}
: action
)
return (
<BottomDrawer
visible={visible}
onClose={onClose}
onAfterClose={() => {
// Why: iOS cannot present a second native modal until the action
// sheet's native window has fully unmounted.
const pendingAction = pendingActionRef.current
pendingActionRef.current = null
pendingAction?.()
}}
dragContentToDismiss
>
<ActionSheetContent
title={title}
message={message}
actions={sequencedActions}
onClose={onClose}
/>
</BottomDrawer>
)
}
const styles = StyleSheet.create({
header: {
paddingHorizontal: spacing.xs,
paddingBottom: spacing.sm
},
title: {
fontSize: 13,
fontWeight: '500',
color: colors.textMuted
},
message: {
fontSize: 12,
color: colors.textMuted,
marginTop: 2
},
actionGroup: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
overflow: 'hidden'
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
action: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm + 2,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
actionDisabled: {
opacity: 0.58
},
actionPressed: {
backgroundColor: colors.bgRaised
},
actionTextBlock: {
flex: 1,
minWidth: 0
},
actionText: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary
},
actionTextDisabled: {
color: colors.textSecondary
},
actionTextDestructive: {
color: colors.statusRed
},
actionHint: {
marginTop: 2,
fontSize: typography.metaSize,
color: colors.textMuted
}
})