Первая версия мобильного приложения

This commit is contained in:
mi
2026-08-21 17:37:12 +03:00
parent d2415fcfeb
commit c1e49fb15d
60 changed files with 14106 additions and 6 deletions
+190
View File
@@ -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;
}