204 lines
7.5 KiB
TypeScript
204 lines
7.5 KiB
TypeScript
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<boolean>;
|
|
finishCallback: (code: string, state: string, consents: Consents) => Promise<void>;
|
|
signOut: () => Promise<void>;
|
|
ensureSession: () => Promise<void>;
|
|
};
|
|
|
|
const Context = createContext<AppContextValue | null>(null);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: 1, staleTime: 15_000 } },
|
|
});
|
|
|
|
export function AppProvider({ children }: { children: React.ReactNode }) {
|
|
const [authStatus, setAuthStatus] = useState<AuthStatus>("guest");
|
|
const [realtimeState, setRealtimeState] = useState("idle");
|
|
const [idleTimeoutMs, setIdleTimeoutMs] = useState<number | null>(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<AppContextValue>(() => ({
|
|
authStatus, realtimeState, setRealtimeState, authorize, finishCallback, ensureSession,
|
|
signOut: async () => { await logout(); toGuest(); router.replace("/"); },
|
|
}), [authStatus, realtimeState, authorize, finishCallback, ensureSession, toGuest, router]);
|
|
|
|
return (
|
|
<QueryClientProvider client={queryClient}>
|
|
<Context.Provider value={value}>
|
|
<NotificationRealtimeBridge authenticated={authStatus === "authenticated"} onState={setRealtimeState} />
|
|
{children}
|
|
</Context.Provider>
|
|
</QueryClientProvider>
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|