Первая версия мобильного приложения
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
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 (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;
|
||||
}
|
||||
Reference in New Issue
Block a user