Adds full-stack crash capture so any unexpected failure — React render,
uncaught JS exception, unhandled promise rejection, or native — is
reported to Sentry with rich, scrubbed context. Expected operational
errors (timeouts, biometric cancel, etc.) stay local to preserve signal.
Changes:
- src/lib/sentry.ts: explicit native crash handlers, release/dist tags
from app.json, beforeSend/beforeBreadcrumb URL+secret scrubbing,
addBreadcrumb/captureException helpers, ErrorUtils + onunhandledrejection
wrappers that always feed the in-memory log buffer (so offline Share
Report includes the crash too).
- src/components/ErrorBoundary.tsx: new app-wide React boundary with a
dark recovery screen — error message, top stack/component frames,
Share Report (clipboard + native share sheet) and Try Again.
- src/lib/diagnostics.ts: buildCrashReport() reuses the existing
DiagnosticReport pipeline so crashes and connect failures share one
UI and one transport.
- _layout.tsx: wraps app in ErrorBoundary; emits app.lifecycle
breadcrumb at startup.
- stores/{connections,events,sessions}.ts: high-signal breadcrumbs at
connect, SSE connect/disconnect/reconnect, and session select.
- (tabs)/settings.tsx: fix unhandled promise on notificationsGranted().
- app.json: bump expo.version to 0.2.3.
- docs/prd.md, docs/tdd.md: new product + technical design docs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
130 lines
4.1 KiB
TypeScript
130 lines
4.1 KiB
TypeScript
import { useEffect, useRef } from "react"
|
|
import { Stack, router } from "expo-router"
|
|
import { StatusBar } from "expo-status-bar"
|
|
import { useColorScheme, View, ActivityIndicator } from "react-native"
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
|
import { GestureHandlerRootView } from "react-native-gesture-handler"
|
|
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"
|
|
import { useAuth } from "../src/stores/auth"
|
|
import { useConnections } from "../src/stores/connections"
|
|
import { useEvents } from "../src/stores/events"
|
|
import { useCatalog } from "../src/stores/catalog"
|
|
import { useSettings } from "../src/stores/settings"
|
|
import { AuthGate } from "../src/components/AuthGate"
|
|
import { ErrorBoundary } from "../src/components/ErrorBoundary"
|
|
import * as notifications from "../src/lib/notifications"
|
|
import { addBreadcrumb, initSentry, wrap } from "../src/lib/sentry"
|
|
|
|
initSentry()
|
|
addBreadcrumb({ category: "app.lifecycle", message: "app started" })
|
|
|
|
const queryClient = new QueryClient()
|
|
|
|
function RootLayout() {
|
|
const colorScheme = useColorScheme()
|
|
const isDark = colorScheme === "dark"
|
|
|
|
const { initialize: initAuth, isLoading: authLoading } = useAuth()
|
|
const { loadConnections, isLoading: connectionsLoading, client } = useConnections()
|
|
const sseStarted = useRef(false)
|
|
|
|
useEffect(() => {
|
|
initAuth()
|
|
loadConnections()
|
|
useSettings.getState().load()
|
|
|
|
// Connect notification preferences to the notification module
|
|
notifications.configure(() => useSettings.getState().notifications)
|
|
|
|
// Navigate to session when user taps a notification
|
|
return notifications.onTap((data) => {
|
|
router.push(`/session/${data.sessionId}`)
|
|
})
|
|
}, [])
|
|
|
|
// Connect/disconnect SSE and load catalog when client changes
|
|
useEffect(() => {
|
|
if (client && !sseStarted.current) {
|
|
sseStarted.current = true
|
|
useEvents.getState().connect()
|
|
useCatalog.getState().load()
|
|
} else if (!client && sseStarted.current) {
|
|
sseStarted.current = false
|
|
useEvents.getState().disconnect()
|
|
}
|
|
return () => {
|
|
if (sseStarted.current) {
|
|
sseStarted.current = false
|
|
useEvents.getState().disconnect()
|
|
}
|
|
}
|
|
}, [client])
|
|
|
|
const isLoading = authLoading || connectionsLoading
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
backgroundColor: isDark ? "#0a0a0a" : "#ffffff",
|
|
}}
|
|
>
|
|
<ActivityIndicator size="large" color={isDark ? "#ffffff" : "#0a0a0a"} />
|
|
</View>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<ErrorBoundary>
|
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
|
<BottomSheetModalProvider>
|
|
<QueryClientProvider client={queryClient}>
|
|
<AuthGate>
|
|
<Stack
|
|
screenOptions={{
|
|
headerStyle: {
|
|
backgroundColor: isDark ? "#0a0a0a" : "#ffffff",
|
|
},
|
|
headerTintColor: isDark ? "#ffffff" : "#0a0a0a",
|
|
contentStyle: {
|
|
backgroundColor: isDark ? "#0a0a0a" : "#ffffff",
|
|
},
|
|
}}
|
|
>
|
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
|
<Stack.Screen
|
|
name="session/[id]"
|
|
options={{
|
|
title: "Session",
|
|
presentation: "card",
|
|
}}
|
|
/>
|
|
<Stack.Screen
|
|
name="connection/add"
|
|
options={{
|
|
title: "Add Connection",
|
|
presentation: "modal",
|
|
}}
|
|
/>
|
|
<Stack.Screen
|
|
name="connection/[id]"
|
|
options={{
|
|
title: "Edit Connection",
|
|
presentation: "modal",
|
|
}}
|
|
/>
|
|
</Stack>
|
|
<StatusBar style={isDark ? "light" : "dark"} />
|
|
</AuthGate>
|
|
</QueryClientProvider>
|
|
</BottomSheetModalProvider>
|
|
</GestureHandlerRootView>
|
|
</ErrorBoundary>
|
|
)
|
|
}
|
|
|
|
export default wrap(RootLayout)
|