Files
opencode-mobile/app/_layout.tsx
engineer ace8c19816 feat(analytics): add consent-gated activation-funnel analytics via PostHog
Installs are up 615% but 7-day retention is ~0% and we had no analytics SDK
to see where users drop off. Adds a thin PostHog wrapper (src/lib/analytics.ts)
that tracks app_opened, connection_form_submitted, connection_attempted,
connection_succeeded/failed (with a coarse error_class, e.g. the known 401
auth bug), message_sent, and response_received.

PostHog was chosen over Aptabase for its GMS-free JS-only RN SDK (fine for
the F-Droid/no-Firebase build), EU-hosted/self-host option, and generous
free tier. Analytics shares the exact same consent flag as Sentry
(telemetry.ts now gates both) so zero network calls happen without explicit
opt-in.

Requires a new EXPO_PUBLIC_POSTHOG_KEY CI secret (wired into build.yml,
publish-fdroid.yml, publish-play-store.yml, and documented in
publish-app-store.yml alongside the existing Sentry secrets).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJKAQ6HAikWGQK7PGZ5Y4E
2026-07-16 15:48:16 -07:00

170 lines
5.7 KiB
TypeScript

import { useEffect, useRef, useState } 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 { TelemetryConsentModal } from "../src/components/TelemetryConsentModal"
import * as notifications from "../src/lib/notifications"
import { addBreadcrumb, wrap } from "../src/lib/sentry"
import { loadTelemetryConsent, setTelemetryConsent } from "../src/lib/telemetry"
import { initAnalytics, trackAppOpened } from "../src/lib/analytics"
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)
// Telemetry consent state: null = loading, 'unknown' = show modal, else decided
const [consentState, setConsentState] = useState<"loading" | "unknown" | "decided">("loading")
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
const unsubNotifications = notifications.onTap((data) => {
router.push(`/session/${data.sessionId}`)
})
// Load telemetry consent — initialise Sentry only if previously granted
loadTelemetryConsent()
.then((state) => {
if (state === "granted") {
import("../src/lib/sentry").then(({ initSentry }) => {
initSentry()
addBreadcrumb({ category: "app.lifecycle", message: "app started" })
})
initAnalytics()
trackAppOpened()
setConsentState("decided")
} else if (state === "denied") {
addBreadcrumb({ category: "app.lifecycle", message: "app started (telemetry off)" })
setConsentState("decided")
} else {
setConsentState("unknown")
}
})
.catch(() => {
// SecureStore unavailable — show modal so user can decide
setConsentState("unknown")
})
return unsubNotifications
}, [])
// 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 || consentState === "loading"
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>
{/* Telemetry consent modal — shown once on first launch */}
<TelemetryConsentModal
visible={consentState === "unknown"}
onAllow={async () => {
await setTelemetryConsent(true)
setConsentState("decided")
}}
onDecline={async () => {
await setTelemetryConsent(false)
setConsentState("decided")
}}
/>
</ErrorBoundary>
)
}
export default wrap(RootLayout)