import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "expo-router"; import * as SecureStore from "expo-secure-store"; import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; import { AppState } from "react-native"; import { beginAuthorization, clearTokens, completeAuthorization, configureAuthFailure, getAccessToken, logout, refreshTokens } from "./auth"; import { sessionMemory } from "./api"; import { authApi, publicApi } from "./services"; import { notificationApi, notificationKeys } from "./notification-api"; import { NotificationRealtimeClient } from "./realtime"; import { restorePendingIntents } from "./pending-intent"; import type { Consents, NotificationItem, NotificationRealtimeEvent } from "./types"; const PENDING_CONSENTS_KEY = "han.pending-consents"; type AuthStatus = "guest" | "authorizing" | "bootstrapping" | "authenticated"; type AppContextValue = { authStatus: AuthStatus; realtimeState: string; setRealtimeState: (value: string) => void; authorize: (consents: Consents) => Promise; finishCallback: (code: string, state: string, consents: Consents) => Promise; signOut: () => Promise; ensureSession: () => Promise; }; const Context = createContext(null); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 15_000 } }, }); export function AppProvider({ children }: { children: React.ReactNode }) { const [authStatus, setAuthStatus] = useState("guest"); const [realtimeState, setRealtimeState] = useState("idle"); const [idleTimeoutMs, setIdleTimeoutMs] = useState(null); const router = useRouter(); const toGuest = useCallback(() => { sessionMemory.clear(); setRealtimeState("idle"); setAuthStatus("guest"); queryClient.clear(); }, []); useEffect(() => { configureAuthFailure(toGuest); void publicApi.config().then((config) => { const minutes = config.ux.idle_timeout_minutes; if (minutes > 0) setIdleTimeoutMs(minutes * 60_000); }).catch(() => undefined); void restorePendingIntents().then(refreshTokens) .then(async () => { await authApi.startSession("cold_start"); setAuthStatus("authenticated"); }) .catch(() => setAuthStatus("guest")); }, [toGuest]); const finishCallback = useCallback(async (code: string, state: string, consents: Consents) => { setAuthStatus("bootstrapping"); try { if (!getAccessToken()) await completeAuthorization(code, state); await authApi.bootstrap(consents); await authApi.startSession("first_launch"); setAuthStatus("authenticated"); } catch (error) { setAuthStatus("guest"); throw error; } }, []); const authorize = useCallback(async (consents: Consents) => { setAuthStatus("authorizing"); try { await SecureStore.setItemAsync(PENDING_CONSENTS_KEY, JSON.stringify(consents)); const result = await beginAuthorization(); if (getAccessToken()) { setAuthStatus("authenticated"); await SecureStore.deleteItemAsync(PENDING_CONSENTS_KEY); return true; } if (result.type === "dismiss" || result.type === "cancel") { await new Promise((resolve) => setTimeout(resolve, 800)); if (getAccessToken()) { setAuthStatus("authenticated"); await SecureStore.deleteItemAsync(PENDING_CONSENTS_KEY); return true; } } if (result.type !== "success" || typeof result.params.code !== "string" || typeof result.params.state !== "string") { setAuthStatus("guest"); if (result.type !== "dismiss" && result.type !== "cancel") throw new Error("authorization_failed"); return false; } await finishCallback(result.params.code, result.params.state, consents); await SecureStore.deleteItemAsync(PENDING_CONSENTS_KEY); return true; } catch (error) { setAuthStatus("guest"); throw error; } }, [finishCallback]); const ensureSession = useCallback(async () => { if (authStatus !== "authenticated") return; if (!sessionMemory.id) await authApi.startSession("cold_start"); else if (idleTimeoutMs !== null && Date.now() - sessionMemory.lastActivityAt > idleTimeoutMs) { await authApi.startSession("idle_timeout"); } sessionMemory.touch(); }, [authStatus, idleTimeoutMs]); useEffect(() => { const subscription = AppState.addEventListener("change", (state) => { if (state === "active") void ensureSession(); }); return () => subscription.remove(); }, [ensureSession]); const value = useMemo(() => ({ authStatus, realtimeState, setRealtimeState, authorize, finishCallback, ensureSession, signOut: async () => { await logout(); toGuest(); router.replace("/"); }, }), [authStatus, realtimeState, authorize, finishCallback, ensureSession, toGuest, router]); return ( {children} ); } export function useApp() { const value = useContext(Context); if (!value) throw new Error("AppProvider is missing"); return value; } export async function resetAuthForTests() { await clearTokens(); queryClient.clear(); } export async function loadPendingConsents() { const raw = await SecureStore.getItemAsync(PENDING_CONSENTS_KEY); return raw ? JSON.parse(raw) as Consents : null; } export const clearPendingConsents = () => SecureStore.deleteItemAsync(PENDING_CONSENTS_KEY); function NotificationRealtimeBridge({ authenticated, onState, }: { authenticated: boolean; onState: (state: string) => void; }) { const client = useQueryClient(); useEffect(() => { if (!authenticated) return; const updateFromEvent = (event: NotificationRealtimeEvent) => { client.setQueryData(notificationKeys.counter, { unread_count: event.unread_count }); if (event.type === "notification.updated") { client.setQueryData(notificationKeys.detail(event.notification_id), (old: NotificationItem | undefined) => old ? { ...old, ...event } : old); } if (event.type === "notification.closed") { client.setQueryData(notificationKeys.detail(event.notification_id), (old: NotificationItem | undefined) => old ? { ...old, lifecycle_status: "closed" as const } : old); } void client.invalidateQueries({ queryKey: ["notifications"] }); }; const reconcile = async () => { const [home, center, counter] = await Promise.all([ notificationApi.list("home"), notificationApi.list("center"), notificationApi.counter(), ]); client.setQueryData(notificationKeys.home(true), home); client.setQueryData(notificationKeys.center, center); client.setQueryData(notificationKeys.counter, counter); }; const realtime = new NotificationRealtimeClient(updateFromEvent, reconcile, onState); const syncRealtime = (state: string) => { if (state === "active") realtime.start(); else realtime.stop(); }; syncRealtime(AppState.currentState); const subscription = AppState.addEventListener("change", syncRealtime); return () => { subscription.remove(); realtime.stop(); }; }, [authenticated, client, onState]); return null; }