mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 01:27:35 +00:00
* feat(mobile): add UI localization infrastructure Mobile follows the phone's language (or an in-app choice under Settings > Appearance) across the same 11 languages as desktop, switching live like the theme does. - i18next with desktop's translate(key, fallback, options) call shape; catalogs in mobile/src/i18n/locales load lazily, English fallbacks stay inline - Hermes lacks Intl.PluralRules: load the FormatJS polyfill with the 11 languages' rules - OTA pages get the shell's language through an optional bridge init field - iOS permission prompts localized; iOS and Android 13+ per-app language pickers - verify:mobile-localization and a coverage ratchet run in lint, check:changed and CI * fix(mobile): mount pages after the newest language request; tighten localization gates - Wait for the latest page language activation, not a superseded one - Keep literal fallbacks when a key also has a dynamic one - Count headerTitle as visible copy; select the mobile check when the runtime catalog generator changes * feat(mobile): repaint themed components on language changes Components that read the theme already subscribe for repaint; a language switch repaints them too, so labels built by helpers during render follow it. RPC recordings load the real i18next and JSON catalogs. * fix(mobile): scope the translate() gate to render bodies and memos; prune stale keys on sync Handlers and effects may call translate() when they run. sync:mobile-localization now removes keys no code references from every catalog. * fix(mobile): use node: imports in the locale config plugin; pass uiLocale in bridge hook test * chore(mobile): refresh the localization coverage baseline * fix(mobile): follow render callbacks in the translate() gate; keep used plural siblings on prune Also let anonymous default-exported screens count as components, flag any plural family a locale started, and mark JSON modules as ES modules in the recording loader. * fix(mobile): treat async and named local callbacks as handlers in the translate() gate * chore(mobile): refresh the localization coverage baseline * chore(mobile): refresh the localization coverage baseline --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
254 lines
11 KiB
TypeScript
254 lines
11 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'
|
||
import { useFollowDeviceUiLocale } from '../src/i18n/use-follow-device-ui-locale'
|
||
import { startMobileUiLanguage } from '../src/i18n/ui-language-preference'
|
||
|
||
// 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()
|
||
// Likewise the UI language: the saved choice and its catalog load under the splash.
|
||
const languageReady = startMobileUiLanguage()
|
||
|
||
// 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()
|
||
useFollowDeviceUiLocale()
|
||
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 Promise.all([appearanceReady, languageReady])
|
||
// Let the language switch commit before revealing the first frame.
|
||
await new Promise((resolve) => requestAnimationFrame(resolve))
|
||
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
|
||
}
|
||
}))
|