diff --git a/apps/native/src/app/_layout.tsx b/apps/native/src/app/_layout.tsx index df62596..4360a03 100644 --- a/apps/native/src/app/_layout.tsx +++ b/apps/native/src/app/_layout.tsx @@ -10,13 +10,15 @@ import { requestTrackingPermissionsAsync, } from "expo-tracking-transparency"; import { PostHogErrorBoundary, PostHogProvider } from "posthog-react-native"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { Uniwind, useResolveClassNames } from "uniwind"; import { OfflineBanner } from "@/components/ui/offline-banner"; +import { ServerUnreachableBanner } from "@/components/ui/server-unreachable-banner"; import { authClient, rebuildAuthClient } from "@/lib/auth-client"; +import { getCachedSession } from "@/lib/cached-session"; import { clearStorageScope, hasScopedStorage, @@ -26,15 +28,37 @@ import { } from "@/lib/mmkv"; import { applyTrackingTransparency, posthog } from "@/lib/posthog"; import { queryClient } from "@/lib/query-client"; +import { + onServerReachabilityChange, + startReachabilityMonitor, +} from "@/lib/server-reachability"; import { ensureInstanceId, getCurrentInstanceId, hasStoredServerUrl, onServerUrlChange, } from "@/lib/server-url"; +import { toast } from "@/lib/toast"; SplashScreen.preventAutoHideAsync(); +// Seed the session atom with cached data from SecureStore before React renders. +// This allows the app to show cached data immediately when the server is +// unreachable, instead of hanging on the splash screen or dumping the user +// on the login screen. Better Auth's background fetch will still fire and +// update the atom when the server responds. +const cachedSession = getCachedSession(); +if (cachedSession) { + const sessionAtom = authClient.$store.atoms.session; + sessionAtom.set({ + data: cachedSession, + error: null, + isPending: false, + isRefetching: true, + refetch: sessionAtom.get().refetch, + }); +} + export const unstable_settings = { initialRouteName: "(tabs)", }; @@ -122,16 +146,55 @@ function AppContent() { Uniwind.setTheme("dark"); }, []); + // --- Safety splash timeout (belt-and-suspenders if seeding fails) --- + useEffect(() => { + const timer = setTimeout(() => SplashScreen.hideAsync(), 3000); + return () => clearTimeout(timer); + }, []); + useEffect(() => { if (!isPending || !hasServerUrl) { SplashScreen.hideAsync(); } }, [isPending, hasServerUrl]); + // --- Server reachability monitor --- + useEffect(() => { + if (!hasServerUrl) return; + return startReachabilityMonitor(); + }, [hasServerUrl]); + + // --- Session reconciliation: re-validate when server comes back --- + const hadOptimisticSession = useRef(!!cachedSession); + const prevSession = useRef(session); + + useEffect(() => { + return onServerReachabilityChange((reachable) => { + if (reachable) { + // Server came back — trigger session re-validation + const atom = authClient.$store.atoms.session; + atom.get().refetch?.(); + } + }); + }, []); + + // If session was seeded from cache and then invalidated by the server, + // show a toast so the user knows why they were signed out. + useEffect(() => { + if (prevSession.current && !session && hadOptimisticSession.current) { + toast.info("Session expired", { + description: "Please sign in again.", + }); + hadOptimisticSession.current = false; + } + prevSession.current = session; + }, [session]); + return ( <> + { + let mounted = true; + + const handleState = (state: Network.NetworkState) => { + if (!mounted) return; + setIsDeviceOnline(!!state.isConnected && !!state.isInternetReachable); + }; + + Network.getNetworkStateAsync().then(handleState); + const subscription = Network.addNetworkStateListener(handleState); + + return () => { + mounted = false; + subscription.remove(); + }; + }, []); + + useEffect(() => { + if (!isReachable && wasReachable.current) { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); + } + wasReachable.current = isReachable; + }, [isReachable]); + + // Only show when device has internet but server is unreachable + if (isReachable || !isDeviceOnline) return null; + + const useGlass = isLiquidGlassAvailable(); + + const content = ( + <> + + + Can't reach server + + + Retry + + + ); + + return ( + + {useGlass ? ( + + {content} + + ) : ( + + {content} + + )} + + ); +} diff --git a/apps/native/src/components/ui/server-unreachable-banner.tsx b/apps/native/src/components/ui/server-unreachable-banner.tsx new file mode 100644 index 0000000..3d5c85a --- /dev/null +++ b/apps/native/src/components/ui/server-unreachable-banner.tsx @@ -0,0 +1,72 @@ +import { IconCloudOff } from "@tabler/icons-react-native"; +import * as Network from "expo-network"; +import { useEffect, useRef, useState } from "react"; +import { Pressable, View } from "react-native"; +import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/ui/text"; +import { useServerReachability } from "@/lib/server-reachability"; +import * as Haptics from "@/utils/haptics"; + +export function ServerUnreachableBanner() { + const { isReachable, retry } = useServerReachability(); + const insets = useSafeAreaInsets(); + const wasReachable = useRef(true); + + // Track device connectivity so we don't double up with OfflineBanner + const [isDeviceOnline, setIsDeviceOnline] = useState(true); + + useEffect(() => { + let mounted = true; + + const handleState = (state: Network.NetworkState) => { + if (!mounted) return; + setIsDeviceOnline(!!state.isConnected && !!state.isInternetReachable); + }; + + Network.getNetworkStateAsync().then(handleState); + const subscription = Network.addNetworkStateListener(handleState); + + return () => { + mounted = false; + subscription.remove(); + }; + }, []); + + useEffect(() => { + if (!isReachable && wasReachable.current) { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); + } + wasReachable.current = isReachable; + }, [isReachable]); + + // Only show when device has internet but server is unreachable + if (isReachable || !isDeviceOnline) return null; + + return ( + + + + + Can't reach server + + + Retry + + + + ); +} diff --git a/apps/native/src/lib/cached-session.ts b/apps/native/src/lib/cached-session.ts new file mode 100644 index 0000000..4817b7a --- /dev/null +++ b/apps/native/src/lib/cached-session.ts @@ -0,0 +1,40 @@ +import * as SecureStore from "expo-secure-store"; + +import { getCurrentInstanceId } from "@/lib/server-url"; + +interface CachedSessionData { + session: { id: string; expiresAt?: string; [key: string]: unknown }; + user: { id: string; [key: string]: unknown }; +} + +/** + * Synchronously reads the cached session from SecureStore. + * Better Auth's Expo plugin writes session data here on every successful + * /get-session response, but never reads it back as a fallback. + * We use it to seed the session atom before React renders so the app + * can show cached data immediately when the server is unreachable. + */ +export function getCachedSession(): CachedSessionData | null { + const instanceId = getCurrentInstanceId(); + if (!instanceId) return null; + + const key = `sofa_${instanceId}_session_data`; + + try { + const raw = SecureStore.getItem(key); + if (!raw) return null; + + const data = JSON.parse(raw); + if (!data?.session?.id || !data?.user?.id) return null; + + // Reject obviously expired sessions + if (data.session.expiresAt) { + const expiry = new Date(data.session.expiresAt).getTime(); + if (expiry < Date.now()) return null; + } + + return data as CachedSessionData; + } catch { + return null; + } +} diff --git a/apps/native/src/lib/server-reachability.ts b/apps/native/src/lib/server-reachability.ts new file mode 100644 index 0000000..b04dc62 --- /dev/null +++ b/apps/native/src/lib/server-reachability.ts @@ -0,0 +1,99 @@ +import { useEffect, useState } from "react"; +import { AppState } from "react-native"; + +import { getServerUrl, hasStoredServerUrl } from "@/lib/server-url"; + +const REACHABLE_POLL_MS = 60_000; +const UNREACHABLE_POLL_MS = 15_000; +const PROBE_TIMEOUT_MS = 5_000; + +// --- Singleton state --- + +let isReachable = true; // Optimistic default +const listeners: Array<(reachable: boolean) => void> = []; + +function notify() { + for (const l of listeners) l(isReachable); +} + +async function probe(): Promise { + if (!hasStoredServerUrl() && !process.env.EXPO_PUBLIC_SERVER_URL) return true; + + try { + const res = await fetch(`${getServerUrl()}/api/health`, { + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + }); + return res.ok; + } catch { + return false; + } +} + +export async function checkReachability(): Promise { + const wasReachable = isReachable; + isReachable = await probe(); + + if (wasReachable !== isReachable) { + notify(); + } + + return isReachable; +} + +export function getIsReachable(): boolean { + return isReachable; +} + +export function onServerReachabilityChange( + callback: (reachable: boolean) => void, +): () => void { + listeners.push(callback); + return () => { + const idx = listeners.indexOf(callback); + if (idx !== -1) listeners.splice(idx, 1); + }; +} + +// --- Polling + AppState lifecycle --- + +let pollTimer: ReturnType | null = null; + +function schedulePoll() { + if (pollTimer) clearTimeout(pollTimer); + const interval = isReachable ? REACHABLE_POLL_MS : UNREACHABLE_POLL_MS; + pollTimer = setTimeout(async () => { + await checkReachability(); + schedulePoll(); + }, interval); +} + +export function startReachabilityMonitor(): () => void { + checkReachability().then(() => schedulePoll()); + + const sub = AppState.addEventListener("change", (nextState) => { + if (nextState === "active") { + checkReachability(); + } + }); + + return () => { + if (pollTimer) { + clearTimeout(pollTimer); + pollTimer = null; + } + sub.remove(); + }; +} + +// --- React hook --- + +export function useServerReachability() { + const [reachable, setReachable] = useState(isReachable); + + useEffect(() => { + setReachable(isReachable); + return onServerReachabilityChange(setReachable); + }, []); + + return { isReachable: reachable, retry: checkReachability }; +}