mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 01:27:35 +00:00
* feat(mobile): light and dark themes with desktop's palettes Settings → Appearance offers System, Light and Dark. The shell passes the resolved scheme to over-the-air pages in its init frame. Fixes #22184 * fix(mobile): draw your chat messages in desktop's user bubble Desktop uses a faint surface with a hairline border in both themes; the inverted fill went black in light. * test(mobile): re-measure the page script sweep after the shared theme hooks Every route now imports the theme hooks while the entry does not, so they emit one shared script (96 for the tree). Re-swept every route prefix; the worst route step is now 15. * test(mobile): read themed stylesheets in the text-input font-size census The census only unwrapped StyleSheet.create({...}); inputs styled through themedStyleSheet(() => ({...})) came back unread.
247 lines
10 KiB
TypeScript
247 lines
10 KiB
TypeScript
import { startAndroidForegroundPushPresentation } from '../src/notifications/android-foreground-push'
|
||
import { registerPushDismissalTask } from '../src/notifications/push-background-dismissal'
|
||
import { readNativeNotificationData } from '../src/notifications/native-notification-data'
|
||
import { setNotificationViewingWorkspace } from '../src/notifications/notification-viewing-policy'
|
||
import { useCallback, useEffect, useRef } from 'react'
|
||
import { View } from 'react-native'
|
||
import { Stack, useRouter, useGlobalSearchParams, usePathname } from 'expo-router'
|
||
import { StatusBar } from 'expo-status-bar'
|
||
import * as SplashScreen from 'expo-splash-screen'
|
||
import * as Notifications from 'expo-notifications'
|
||
import * as Linking from 'expo-linking'
|
||
import { colors } from '../src/theme/mobile-theme'
|
||
import { startMobileAppearance } from '../src/theme/mobile-appearance'
|
||
import { themedStyleSheet } from '../src/theme/themed-styles'
|
||
import { useMobileColorScheme } from '../src/theme/use-mobile-color-scheme'
|
||
import { OrcaLogo } from '../src/components/OrcaLogo'
|
||
import { RpcClientProvider } from '../src/transport/client-context'
|
||
import { getNotificationNavigationTarget } from '../src/notifications/notification-routing'
|
||
import { useOpenNotificationRoute } from '../src/notifications/use-open-notification-route'
|
||
import {
|
||
isRemotePushTrigger,
|
||
pushNotificationRouteData,
|
||
foregroundNotificationBehavior
|
||
} from '../src/notifications/push-receive'
|
||
import { startPushTokenSync } from '../src/notifications/push-registration'
|
||
import { ensureDesktopNotificationChannel } from '../src/notifications/desktop-notification-channel'
|
||
import { loadHostCatalog } from '../src/transport/host-store'
|
||
import { extractPairingCodeFromUrl } from '../src/transport/pairing'
|
||
import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery'
|
||
import { appUpdateChecker } from '../src/app-update/app-update-runtime'
|
||
import { SideSafeArea } from '../src/layout/side-safe-area'
|
||
|
||
// Keep splash visible until React content is ready to avoid a blank frame.
|
||
SplashScreen.preventAutoHideAsync()
|
||
// Apply the phone's scheme before the first render; the saved choice lands under the splash.
|
||
const appearanceReady = startMobileAppearance()
|
||
|
||
// Create orca-desktop channel at boot: background FCM can arrive before socket subscription, and Android drops unknown-channel pushes.
|
||
void ensureDesktopNotificationChannel().catch(() => {})
|
||
void registerPushDismissalTask().catch(() => {})
|
||
|
||
// Register before scheduling so foreground delivery uses the same suppression policy.
|
||
Notifications.setNotificationHandler({
|
||
handleNotification: foregroundNotificationBehavior
|
||
})
|
||
|
||
export default function RootLayout() {
|
||
const colorScheme = useMobileColorScheme()
|
||
const router = useRouter()
|
||
const pathname = usePathname()
|
||
const { hostId, worktreeId, executionHost } = useGlobalSearchParams<{
|
||
hostId?: string
|
||
worktreeId?: string
|
||
executionHost?: string
|
||
}>()
|
||
useEffect(() => {
|
||
setNotificationViewingWorkspace(
|
||
pathname.includes('/session/') && typeof hostId === 'string' && typeof worktreeId === 'string'
|
||
? { hostId, worktreeId, ...(typeof executionHost === 'string' ? { executionHost } : {}) }
|
||
: null
|
||
)
|
||
return () => setNotificationViewingWorkspace(null)
|
||
}, [pathname, hostId, worktreeId, executionHost])
|
||
const openNotificationRoute = useOpenNotificationRoute()
|
||
const handledNotificationIdsRef = useRef<Set<string>>(new Set())
|
||
|
||
useEffect(() => {
|
||
// Reconcile the crash-persistent pairing journal before another scan can replace it.
|
||
void recoverMobileRelayPairing()
|
||
}, [])
|
||
|
||
// Re-register rolled APNs/FCM tokens with every paired host or delivery silently stops.
|
||
useEffect(() => startPushTokenSync(), [])
|
||
// Cold start, then foreground and timer checks on the desktop updater's cadence.
|
||
useEffect(() => appUpdateChecker.start(), [])
|
||
useEffect(() => startAndroidForegroundPushPresentation(), [])
|
||
|
||
// Route pair links through confirmation; getInitialURL covers cold starts and the listener covers warm starts.
|
||
useEffect(() => {
|
||
function handleUrl(url: string) {
|
||
const code = extractPairingCodeFromUrl(url)
|
||
if (code) {
|
||
// Replace Android camera’s unmatched orca://pair route so Cancel/Back cannot reveal the router error.
|
||
router.replace({ pathname: '/pair-confirm', params: { code } })
|
||
}
|
||
}
|
||
|
||
void Linking.getInitialURL().then((url) => {
|
||
if (url) {
|
||
handleUrl(url)
|
||
}
|
||
})
|
||
|
||
const sub = Linking.addEventListener('url', ({ url }) => handleUrl(url))
|
||
return () => sub.remove()
|
||
}, [router])
|
||
|
||
// Notification taps arrive through expo-notifications, not Linking; route both cold and warm responses to their host/worktree.
|
||
useEffect(() => {
|
||
let disposed = false
|
||
|
||
function clearLastNotificationResponse() {
|
||
try {
|
||
Notifications.clearLastNotificationResponse()
|
||
} catch {
|
||
// Older native shells may lack clear API; duplicate guards still protect this JS runtime.
|
||
}
|
||
}
|
||
|
||
function getInitialNotificationResponse(): Notifications.NotificationResponse | null {
|
||
try {
|
||
return Notifications.getLastNotificationResponse()
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
async function getNavigationTarget(notification: Notifications.Notification) {
|
||
const hosts = await loadHostCatalog().catch(() => null)
|
||
const data = readNativeNotificationData(notification.request)
|
||
// Gateway pushes identify hosts by key fingerprint; without catalog resolution, never fall back to their raw hostId.
|
||
const routeData = pushNotificationRouteData(
|
||
data,
|
||
hosts ?? [],
|
||
isRemotePushTrigger(notification.request.trigger)
|
||
)
|
||
return getNotificationNavigationTarget(routeData, {
|
||
knownHostIds: hosts ? new Set(hosts.map((host) => host.id)) : undefined,
|
||
credentialStatusByHostId: hosts
|
||
? new Map(hosts.map((host) => [host.id, host.credentialStatus]))
|
||
: undefined
|
||
})
|
||
}
|
||
|
||
async function handleNotificationResponse(response: Notifications.NotificationResponse) {
|
||
if (response.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) {
|
||
clearLastNotificationResponse()
|
||
return
|
||
}
|
||
|
||
const notificationId = response.notification.request.identifier
|
||
if (handledNotificationIdsRef.current.has(notificationId)) {
|
||
return
|
||
}
|
||
handledNotificationIdsRef.current.add(notificationId)
|
||
// RootLayout never unmounts; bound tap dedup with FIFO eviction.
|
||
if (handledNotificationIdsRef.current.size > 256) {
|
||
const oldest = handledNotificationIdsRef.current.values().next().value
|
||
if (oldest !== undefined) {
|
||
handledNotificationIdsRef.current.delete(oldest)
|
||
}
|
||
}
|
||
|
||
const target = await getNavigationTarget(response.notification)
|
||
clearLastNotificationResponse()
|
||
if (disposed) {
|
||
return
|
||
}
|
||
if (target) {
|
||
openNotificationRoute(target)
|
||
}
|
||
}
|
||
|
||
const initialResponse = getInitialNotificationResponse()
|
||
if (initialResponse) {
|
||
void handleNotificationResponse(initialResponse)
|
||
}
|
||
|
||
const sub = Notifications.addNotificationResponseReceivedListener((response) => {
|
||
void handleNotificationResponse(response)
|
||
})
|
||
return () => {
|
||
disposed = true
|
||
sub.remove()
|
||
}
|
||
}, [openNotificationRoute])
|
||
|
||
// Hide splash only after Stack layout; a placeholder render can precede real screen content and expose a grey gap.
|
||
const onNavigatorLayout = useCallback(async () => {
|
||
await appearanceReady
|
||
await SplashScreen.hideAsync()
|
||
}, [])
|
||
|
||
return (
|
||
<RpcClientProvider>
|
||
<View style={styles.root} onLayout={onNavigatorLayout}>
|
||
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
|
||
<SideSafeArea>
|
||
<Stack
|
||
screenOptions={{
|
||
headerStyle: { backgroundColor: colors.bgPanel },
|
||
headerTintColor: colors.textPrimary,
|
||
headerTitleStyle: { fontSize: 16, fontWeight: '600' },
|
||
contentStyle: { backgroundColor: colors.bgBase },
|
||
headerShadowVisible: false
|
||
// Leave orientation unset: react-native-screens overrides rotation lock even for default; Android manifest fullUser honors the user’s setting.
|
||
}}
|
||
>
|
||
<Stack.Screen
|
||
name="index"
|
||
options={{
|
||
headerShown: false,
|
||
headerTitle: () => <OrcaLogo size={22} />
|
||
}}
|
||
/>
|
||
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
|
||
<Stack.Screen name="pair" options={{ headerShown: false }} />
|
||
<Stack.Screen name="pair-confirm" options={{ headerShown: false }} />
|
||
<Stack.Screen
|
||
name="mobile-onboarding"
|
||
options={{ headerShown: false, presentation: 'modal', gestureEnabled: false }}
|
||
/>
|
||
<Stack.Screen name="settings" options={{ headerShown: false }} />
|
||
<Stack.Screen name="appearance-settings" options={{ headerShown: false }} />
|
||
<Stack.Screen name="terminal-settings" options={{ headerShown: false }} />
|
||
<Stack.Screen name="native-chat-settings" options={{ headerShown: false }} />
|
||
<Stack.Screen name="browser-settings" options={{ headerShown: false }} />
|
||
<Stack.Screen name="voice-settings" options={{ headerShown: false }} />
|
||
<Stack.Screen name="notifications" options={{ headerShown: false }} />
|
||
<Stack.Screen name="troubleshoot" options={{ headerShown: false }} />
|
||
<Stack.Screen name="connection-log" options={{ headerShown: false }} />
|
||
<Stack.Screen name="about" options={{ headerShown: false }} />
|
||
<Stack.Screen name="h" options={{ headerShown: false }} />
|
||
<Stack.Screen
|
||
name="new-workspace"
|
||
options={{
|
||
headerShown: false,
|
||
// Draws nothing of its own: the drawer is the page's, over Home.
|
||
presentation: 'transparentModal',
|
||
animation: 'none',
|
||
contentStyle: { backgroundColor: 'transparent' }
|
||
}}
|
||
/>
|
||
</Stack>
|
||
</SideSafeArea>
|
||
</View>
|
||
</RpcClientProvider>
|
||
)
|
||
}
|
||
|
||
const styles = themedStyleSheet(() => ({
|
||
root: {
|
||
flex: 1,
|
||
backgroundColor: colors.bgBase
|
||
}
|
||
}))
|