Разработана первая версия приложений
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { env } from "./config";
|
||||
import { getAccessToken, refreshTokens } from "./auth";
|
||||
export { sessionMemory } from "./session";
|
||||
import { sessionMemory } from "./session";
|
||||
|
||||
export type ApiErrorEnvelope = {
|
||||
error: { code: string; message: string; request_id?: string; details?: Record<string, unknown> };
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly requestId?: string,
|
||||
readonly retryAfter?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export type Diagnostic = {
|
||||
at: number;
|
||||
method: string;
|
||||
path: string;
|
||||
status: number;
|
||||
requestId: string;
|
||||
};
|
||||
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
|
||||
export const getDiagnostics = () => [...diagnostics];
|
||||
|
||||
function traceparent() {
|
||||
const traceId = crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", "").slice(0, 16);
|
||||
const spanId = crypto.randomUUID().replaceAll("-", "").slice(0, 16);
|
||||
return `00-${traceId.slice(0, 32)}-${spanId}-01`;
|
||||
}
|
||||
|
||||
function safePath(path: string) {
|
||||
return path.split("?")[0] ?? path;
|
||||
}
|
||||
|
||||
async function parseError(response: Response, requestId: string) {
|
||||
let envelope: ApiErrorEnvelope | undefined;
|
||||
try { envelope = (await response.json()) as ApiErrorEnvelope; } catch { /* intentionally empty */ }
|
||||
const code = envelope?.error?.code ?? `http_${response.status}`;
|
||||
const retry = Number(response.headers.get("Retry-After"));
|
||||
return new ApiError(
|
||||
response.status,
|
||||
code,
|
||||
envelope?.error?.message ?? "Запрос не выполнен",
|
||||
envelope?.error?.request_id ?? requestId,
|
||||
Number.isFinite(retry) ? retry : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(
|
||||
path: string,
|
||||
init: RequestInit & { protected?: boolean } = {},
|
||||
replayed = false,
|
||||
): Promise<T> {
|
||||
const requestId = crypto.randomUUID();
|
||||
const isProtected = init.protected ?? false;
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set("Accept", "application/json");
|
||||
headers.set("X-Request-ID", requestId);
|
||||
headers.set("traceparent", traceparent());
|
||||
if (init.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
|
||||
if (isProtected) {
|
||||
const token = getAccessToken();
|
||||
if (!token) throw new ApiError(401, "unauthorized", "Требуется авторизация", requestId);
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
if (sessionMemory.id) headers.set("X-Ux-Session-Id", sessionMemory.id);
|
||||
}
|
||||
const response = await fetch(`${env.apiBaseUrl}${path}`, { ...init, headers });
|
||||
diagnostics.unshift({
|
||||
at: Date.now(), method: init.method ?? "GET", path: safePath(path),
|
||||
status: response.status, requestId: response.headers.get("X-Request-ID") ?? requestId,
|
||||
});
|
||||
diagnostics.splice(20);
|
||||
if (response.status === 401 && isProtected && !replayed) {
|
||||
await refreshTokens();
|
||||
return apiRequest<T>(path, init, true);
|
||||
}
|
||||
if (!response.ok) throw await parseError(response, requestId);
|
||||
if (response.status === 204) return undefined as T;
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const json = (value: unknown) => JSON.stringify(value);
|
||||
export const idempotencyHeaders = (key: string) => ({ "Idempotency-Key": key });
|
||||
@@ -0,0 +1,121 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { AppState, Platform } from "react-native";
|
||||
import { beginAuthorization, clearTokens, completeAuthorization, configureAuthFailure, getAccessToken, logout, refreshTokens } from "./auth";
|
||||
import { sessionMemory } from "./api";
|
||||
import { authApi, publicApi } from "./services";
|
||||
import type { Consents } from "./types";
|
||||
|
||||
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 refreshTokens()
|
||||
.then(() => {
|
||||
setAuthStatus("authenticated");
|
||||
void authApi.startSession("cold_start").catch(() => undefined);
|
||||
})
|
||||
.catch(() => setAuthStatus("guest"));
|
||||
}, [toGuest]);
|
||||
|
||||
const finishCallback = useCallback(async (code: string, state: string, consents: Consents) => {
|
||||
setAuthStatus("bootstrapping");
|
||||
if (!getAccessToken()) await completeAuthorization(code, state);
|
||||
await authApi.bootstrap(consents);
|
||||
await authApi.startSession("first_launch");
|
||||
if (typeof window !== "undefined") window.sessionStorage.removeItem("han.pending-consents");
|
||||
setAuthStatus("authenticated");
|
||||
router.replace("/");
|
||||
}, [router]);
|
||||
|
||||
const authorize = useCallback(async (consents: Consents) => {
|
||||
setAuthStatus("authorizing");
|
||||
if (typeof window !== "undefined") window.sessionStorage.setItem("han.pending-consents", 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);
|
||||
return true;
|
||||
}, [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(() => {
|
||||
if (Platform.OS === "web") {
|
||||
const activity = () => sessionMemory.touch();
|
||||
const resume = () => { if (!document.hidden) void ensureSession(); };
|
||||
window.addEventListener("pointerdown", activity);
|
||||
window.addEventListener("keydown", activity);
|
||||
document.addEventListener("visibilitychange", resume);
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", activity);
|
||||
window.removeEventListener("keydown", activity);
|
||||
document.removeEventListener("visibilitychange", resume);
|
||||
};
|
||||
}
|
||||
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}>{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();
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import * as AuthSession from "expo-auth-session";
|
||||
import * as Crypto from "expo-crypto";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import * as WebBrowser from "expo-web-browser";
|
||||
import { Platform } from "react-native";
|
||||
import { env, oidcIssuer } from "./config";
|
||||
import { SingleFlight } from "./single-flight";
|
||||
import type { TokenSet } from "./types";
|
||||
|
||||
WebBrowser.maybeCompleteAuthSession();
|
||||
|
||||
const REFRESH_KEY = "han.refresh-token";
|
||||
const PKCE_KEY = "han.pkce";
|
||||
const PKCE_TTL_MS = 10 * 60_000;
|
||||
let tokens: TokenSet | null = null;
|
||||
const refreshFlight = new SingleFlight<TokenSet>();
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let authFailure: (() => void) | undefined;
|
||||
|
||||
const browserStore = {
|
||||
async get(key: string) {
|
||||
if (typeof window === "undefined") return null;
|
||||
return window.localStorage.getItem(key);
|
||||
},
|
||||
async set(key: string, value: string) {
|
||||
if (typeof window !== "undefined") window.localStorage.setItem(key, value);
|
||||
},
|
||||
async del(key: string) {
|
||||
if (typeof window !== "undefined") window.localStorage.removeItem(key);
|
||||
},
|
||||
};
|
||||
|
||||
const secureStore = {
|
||||
get: (key: string) =>
|
||||
Platform.OS === "web" ? browserStore.get(key) : SecureStore.getItemAsync(key),
|
||||
set: (key: string, value: string) =>
|
||||
Platform.OS === "web" ? browserStore.set(key, value) : SecureStore.setItemAsync(key, value),
|
||||
del: (key: string) =>
|
||||
Platform.OS === "web" ? browserStore.del(key) : SecureStore.deleteItemAsync(key),
|
||||
};
|
||||
|
||||
const random = () => Crypto.randomUUID().replaceAll("-", "") + Crypto.randomUUID().replaceAll("-", "");
|
||||
const redirectUri = AuthSession.makeRedirectUri({ scheme: "han-chat", path: "auth/callback" });
|
||||
const tokenEndpoint = `${oidcIssuer}/protocol/openid-connect/token`;
|
||||
|
||||
export function configureAuthFailure(callback: () => void) {
|
||||
authFailure = callback;
|
||||
}
|
||||
|
||||
export function getAccessToken() {
|
||||
return tokens?.accessToken ?? null;
|
||||
}
|
||||
|
||||
export function getTokenInfo() {
|
||||
return tokens ? { expiresAt: tokens.expiresAt } : null;
|
||||
}
|
||||
|
||||
async function persist(next: TokenSet) {
|
||||
tokens = next;
|
||||
await secureStore.set(REFRESH_KEY, next.refreshToken);
|
||||
if (refreshTimer) clearTimeout(refreshTimer);
|
||||
const delay = Math.max(1_000, next.expiresAt - Date.now() - 60_000);
|
||||
refreshTimer = setTimeout(() => void refreshTokens().catch(() => undefined), delay);
|
||||
}
|
||||
|
||||
async function parseTokenResponse(response: Response): Promise<TokenSet> {
|
||||
const body = (await response.json()) as Record<string, unknown>;
|
||||
if (!response.ok || typeof body.access_token !== "string" || typeof body.refresh_token !== "string") {
|
||||
throw new Error(typeof body.error === "string" ? body.error : "token_exchange_failed");
|
||||
}
|
||||
return {
|
||||
accessToken: body.access_token,
|
||||
refreshToken: body.refresh_token,
|
||||
expiresAt: Date.now() + Number(body.expires_in ?? 300) * 1000,
|
||||
...(typeof body.id_token === "string" ? { idToken: body.id_token } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function beginAuthorization() {
|
||||
const verifier = random();
|
||||
const state = random();
|
||||
const nonce = random();
|
||||
const digest = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, verifier, {
|
||||
encoding: Crypto.CryptoEncoding.BASE64,
|
||||
});
|
||||
const challenge = digest.replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
||||
await secureStore.set(PKCE_KEY, JSON.stringify({ verifier, state, nonce, createdAt: Date.now() }));
|
||||
const url = `${oidcIssuer}/protocol/openid-connect/auth?${new URLSearchParams({
|
||||
client_id: env.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: "code",
|
||||
scope: "openid profile offline_access",
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
state,
|
||||
nonce,
|
||||
})}`;
|
||||
const result = await WebBrowser.openAuthSessionAsync(url, redirectUri);
|
||||
if (result.type !== "success") return { type: result.type as "cancel" | "dismiss" };
|
||||
const callback = new URL(result.url);
|
||||
return {
|
||||
type: "success" as const,
|
||||
params: {
|
||||
code: callback.searchParams.get("code"),
|
||||
state: callback.searchParams.get("state"),
|
||||
error: callback.searchParams.get("error"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function completeAuthorization(code: string, state: string) {
|
||||
const raw = await secureStore.get(PKCE_KEY);
|
||||
await secureStore.del(PKCE_KEY);
|
||||
if (!raw) throw new Error("pkce_state_missing");
|
||||
const saved = JSON.parse(raw) as { verifier: string; state: string; nonce: string; createdAt: number };
|
||||
if (saved.state !== state || Date.now() - saved.createdAt > PKCE_TTL_MS) {
|
||||
throw new Error("pkce_state_invalid");
|
||||
}
|
||||
const response = await fetch(tokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: env.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
code,
|
||||
code_verifier: saved.verifier,
|
||||
}).toString(),
|
||||
});
|
||||
const next = await parseTokenResponse(response);
|
||||
if (!next.idToken || readJwtClaim(next.idToken, "nonce") !== saved.nonce) {
|
||||
await clearTokens();
|
||||
throw new Error("oidc_nonce_invalid");
|
||||
}
|
||||
await persist(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function readJwtClaim(token: string, claim: string) {
|
||||
const payload = token.split(".")[1];
|
||||
if (!payload) return undefined;
|
||||
try {
|
||||
const normalized = payload.replaceAll("-", "+").replaceAll("_", "/");
|
||||
const decoded = decodeURIComponent(
|
||||
Array.from(atob(normalized), (character) => `%${character.charCodeAt(0).toString(16).padStart(2, "0")}`).join(""),
|
||||
);
|
||||
return (JSON.parse(decoded) as Record<string, unknown>)[claim];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshTokens(): Promise<TokenSet> {
|
||||
return refreshFlight.run(async () => {
|
||||
const refreshToken = tokens?.refreshToken ?? (await secureStore.get(REFRESH_KEY));
|
||||
if (!refreshToken) throw new Error("refresh_token_missing");
|
||||
try {
|
||||
const response = await fetch(tokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
client_id: env.clientId,
|
||||
refresh_token: refreshToken,
|
||||
}).toString(),
|
||||
});
|
||||
const next = await parseTokenResponse(response);
|
||||
await persist(next);
|
||||
return next;
|
||||
} catch (error) {
|
||||
await clearTokens();
|
||||
authFailure?.();
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearTokens() {
|
||||
tokens = null;
|
||||
if (refreshTimer) clearTimeout(refreshTimer);
|
||||
await secureStore.del(REFRESH_KEY);
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
const idToken = tokens?.idToken;
|
||||
await clearTokens();
|
||||
if (idToken) {
|
||||
void fetch(`${oidcIssuer}/protocol/openid-connect/logout`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ client_id: env.clientId, id_token_hint: idToken }).toString(),
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
const required = (name: string, fallback: string) =>
|
||||
(process.env[name] ?? fallback).replace(/\/$/, "");
|
||||
|
||||
export const env = Object.freeze({
|
||||
apiBaseUrl: required("EXPO_PUBLIC_API_BASE_URL", "http://localhost:8000"),
|
||||
authBaseUrl: required("EXPO_PUBLIC_AUTH_BASE_URL", "http://localhost:8080/auth"),
|
||||
realm: process.env.EXPO_PUBLIC_KEYCLOAK_REALM ?? "han-chat",
|
||||
clientId: process.env.EXPO_PUBLIC_KEYCLOAK_CLIENT_ID ?? "han-chat-frontend",
|
||||
appEnv: process.env.EXPO_PUBLIC_APP_ENV ?? "development",
|
||||
});
|
||||
|
||||
export const oidcIssuer = `${env.authBaseUrl}/realms/${encodeURIComponent(env.realm)}`;
|
||||
export const isProduction = env.appEnv === "production";
|
||||
@@ -0,0 +1,145 @@
|
||||
import { env } from "./config";
|
||||
import { getAccessToken, refreshTokens } from "./auth";
|
||||
import { dialogApi } from "./services";
|
||||
import type { Message } from "./types";
|
||||
export { reconcileMessages } from "./reconcile";
|
||||
|
||||
export type RealtimeState = "idle" | "connecting" | "websocket" | "polling";
|
||||
export type RealtimeEvent =
|
||||
| { type: "message.new"; dialog_id: string; message: Message; cursor?: string }
|
||||
| { type: "message.status"; dialog_id: string; message_id: string; safety_status: Message["safety_status"]; delivery_status: Message["delivery_status"]; cursor?: string }
|
||||
| { type: "dialog.status"; dialog_id: string; status: string; cursor?: string };
|
||||
|
||||
const safeCursors = new Map<string, string>();
|
||||
export const getRealtimeDiagnostics = () =>
|
||||
[...safeCursors.entries()].map(([dialogId, cursor]) => ({
|
||||
dialog: `${dialogId.slice(0, 8)}…`,
|
||||
cursor,
|
||||
}));
|
||||
|
||||
export function websocketJwtProtocol(token: string) {
|
||||
const bytes = new TextEncoder().encode(token);
|
||||
let binary = "";
|
||||
bytes.forEach((byte) => { binary += String.fromCharCode(byte); });
|
||||
return `han.jwt.${btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "")}`;
|
||||
}
|
||||
|
||||
export class RealtimeClient {
|
||||
private socket?: WebSocket;
|
||||
private reconnectTimer?: ReturnType<typeof setTimeout>;
|
||||
private pollingTimer?: ReturnType<typeof setTimeout>;
|
||||
private disconnectedAt = 0;
|
||||
private attempt = 0;
|
||||
private stopped = true;
|
||||
private cursors = new Map<string, string>();
|
||||
|
||||
constructor(
|
||||
private dialogIds: string[],
|
||||
private readonly onEvent: (event: RealtimeEvent) => void,
|
||||
private readonly onMessages: (dialogId: string, messages: Message[]) => void,
|
||||
private readonly onState: (state: RealtimeState) => void,
|
||||
) {}
|
||||
|
||||
updateDialogs(ids: string[]) {
|
||||
this.dialogIds = [...new Set(ids)];
|
||||
if (this.socket?.readyState === WebSocket.OPEN) this.subscribe();
|
||||
}
|
||||
|
||||
start() {
|
||||
if (!this.stopped) return;
|
||||
this.stopped = false;
|
||||
this.connect();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.stopped = true;
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
if (this.pollingTimer) clearTimeout(this.pollingTimer);
|
||||
this.socket?.close();
|
||||
this.onState("idle");
|
||||
}
|
||||
|
||||
private connect() {
|
||||
if (this.stopped) return;
|
||||
const token = getAccessToken();
|
||||
if (!token) return;
|
||||
this.onState("connecting");
|
||||
const url = env.apiBaseUrl.replace(/^http/, "ws") + "/api/v1/realtime";
|
||||
this.socket = new WebSocket(url, ["han-chat-v1", websocketJwtProtocol(token)]);
|
||||
this.socket.onopen = () => {
|
||||
this.attempt = 0;
|
||||
// Сначала подписываемся, затем читаем REST-gap: события в этом окне
|
||||
// уже попадут в merge, а дубли устраняются по message_id.
|
||||
this.subscribe();
|
||||
void this.reconcileAll().then(() => {
|
||||
this.stopPolling();
|
||||
this.onState("websocket");
|
||||
});
|
||||
};
|
||||
this.socket.onmessage = ({ data }) => {
|
||||
try {
|
||||
const event = JSON.parse(String(data)) as RealtimeEvent | { type: string };
|
||||
if (event.type === "ping") return this.socket?.send(JSON.stringify({ type: "pong" }));
|
||||
if (event.type === "message.new" || event.type === "message.status" || event.type === "dialog.status") {
|
||||
const known = event as RealtimeEvent;
|
||||
if (known.cursor) {
|
||||
this.cursors.set(known.dialog_id, known.cursor);
|
||||
safeCursors.set(known.dialog_id, known.cursor);
|
||||
}
|
||||
this.onEvent(known);
|
||||
}
|
||||
} catch { /* unknown and malformed events are safely ignored */ }
|
||||
};
|
||||
this.socket.onclose = (event) => {
|
||||
if (this.stopped) return;
|
||||
if (!this.disconnectedAt) this.disconnectedAt = Date.now();
|
||||
if (event.code === 4401 || event.code === 1008) {
|
||||
void refreshTokens().finally(() => this.scheduleReconnect());
|
||||
} else {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
};
|
||||
this.socket.onerror = () => this.socket?.close();
|
||||
}
|
||||
|
||||
private subscribe() {
|
||||
this.socket?.send(JSON.stringify({ type: "subscribe", dialog_ids: this.dialogIds }));
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.stopped) return;
|
||||
if (Date.now() - this.disconnectedAt >= 30_000) this.startPolling();
|
||||
const base = Math.min(30_000, 1000 * 2 ** this.attempt++);
|
||||
const delay = Math.round(base * (0.8 + Math.random() * 0.4));
|
||||
this.reconnectTimer = setTimeout(() => this.connect(), delay);
|
||||
}
|
||||
|
||||
private async reconcileAll() {
|
||||
await Promise.all(this.dialogIds.map(async (id) => {
|
||||
const page = await dialogApi.messages(id, this.cursors.get(id));
|
||||
if (page.items.length) this.onMessages(id, page.items);
|
||||
if (page.next_cursor) {
|
||||
this.cursors.set(id, page.next_cursor);
|
||||
safeCursors.set(id, page.next_cursor);
|
||||
}
|
||||
}));
|
||||
this.disconnectedAt = 0;
|
||||
}
|
||||
|
||||
private startPolling() {
|
||||
if (this.pollingTimer) return;
|
||||
this.onState("polling");
|
||||
const poll = async () => {
|
||||
if (this.stopped) return;
|
||||
await this.reconcileAll().catch(() => undefined);
|
||||
const delay = typeof document !== "undefined" && document.hidden ? 15_000 : 5_000;
|
||||
this.pollingTimer = setTimeout(poll, delay);
|
||||
};
|
||||
void poll();
|
||||
}
|
||||
|
||||
private stopPolling() {
|
||||
if (this.pollingTimer) clearTimeout(this.pollingTimer);
|
||||
this.pollingTimer = undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Message } from "./types";
|
||||
|
||||
export function reconcileMessages(current: Message[], incoming: Message[]) {
|
||||
const byId = new Map(current.map((message) => [message.message_id, message]));
|
||||
for (const message of incoming) byId.set(message.message_id, { ...byId.get(message.message_id), ...message });
|
||||
return [...byId.values()].sort((a, b) => a.created_at.localeCompare(b.created_at));
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { apiRequest, idempotencyHeaders, json, sessionMemory } from "./api";
|
||||
import { Platform } from "react-native";
|
||||
import type {
|
||||
Consents, Dialog, DocumentItem, Message, Page, Profile, PublicConfig, PublicContent,
|
||||
} from "./types";
|
||||
|
||||
export const publicApi = {
|
||||
config: () => apiRequest<PublicConfig>("/api/v1/public/app-config"),
|
||||
content: () => apiRequest<PublicContent>("/api/v1/public/content"),
|
||||
};
|
||||
|
||||
export const authApi = {
|
||||
bootstrap: (consents: Consents) =>
|
||||
apiRequest<{ user_id: string; profile_ready: boolean }>("/api/v1/auth/bootstrap", {
|
||||
method: "POST", protected: true,
|
||||
body: json({ consents, device: deviceMetadata() }),
|
||||
}),
|
||||
startSession: async (reason: "first_launch" | "cold_start" | "idle_timeout") => {
|
||||
const result = await apiRequest<{ ux_session_id: string; started_at: string }>(
|
||||
"/api/v1/analytics/session-start",
|
||||
{ method: "POST", protected: true, body: json({ start_reason: reason, device: deviceMetadata() }) },
|
||||
);
|
||||
sessionMemory.set(result.ux_session_id);
|
||||
return result;
|
||||
},
|
||||
saveConsents: (consents: Consents) =>
|
||||
apiRequest<void>("/api/v1/consents", {
|
||||
method: "POST", protected: true, body: json({ consents }),
|
||||
}),
|
||||
};
|
||||
|
||||
function deviceMetadata() {
|
||||
const platform = Platform.OS;
|
||||
if (platform !== "ios" && platform !== "android" && platform !== "web") {
|
||||
throw new Error(`Unsupported platform: ${platform}`);
|
||||
}
|
||||
return {
|
||||
platform,
|
||||
app_version: "1.0.0",
|
||||
device_id: "frontend-test-site",
|
||||
};
|
||||
}
|
||||
|
||||
export const dialogApi = {
|
||||
list: (cursor?: string) =>
|
||||
apiRequest<Page<Dialog>>(`/api/v1/dialogs${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`, { protected: true }),
|
||||
get: (id: string) => apiRequest<Dialog>(`/api/v1/dialogs/${encodeURIComponent(id)}`, { protected: true }),
|
||||
create: (key: string) =>
|
||||
apiRequest<Dialog>("/api/v1/dialogs", {
|
||||
method: "POST", protected: true, headers: idempotencyHeaders(key), body: "{}",
|
||||
}),
|
||||
messages: (id: string, after?: string) =>
|
||||
apiRequest<Page<Message>>(
|
||||
`/api/v1/dialogs/${encodeURIComponent(id)}/messages?limit=50${after ? `&after=${encodeURIComponent(after)}` : ""}`,
|
||||
{ protected: true },
|
||||
),
|
||||
sendText: (id: string, text: string, key: string) =>
|
||||
apiRequest<Message>(`/api/v1/dialogs/${encodeURIComponent(id)}/messages`, {
|
||||
method: "POST", protected: true, headers: idempotencyHeaders(key),
|
||||
body: json({ content_kind: "text", text }),
|
||||
}),
|
||||
sendFile: (id: string, attachmentId: string, checksum: string, key: string) =>
|
||||
apiRequest<Message>(`/api/v1/dialogs/${encodeURIComponent(id)}/messages`, {
|
||||
method: "POST", protected: true, headers: idempotencyHeaders(key),
|
||||
body: json({ content_kind: "file", attachment_id: attachmentId, checksum }),
|
||||
}),
|
||||
};
|
||||
|
||||
export async function uploadAttachment(dialogId: string, file: File, key = crypto.randomUUID()) {
|
||||
const checksum = await sha256(file);
|
||||
const init = await apiRequest<{
|
||||
attachment_id: string;
|
||||
upload_url: string;
|
||||
upload_headers?: Record<string, string>;
|
||||
expires_at: string;
|
||||
}>(`/api/v1/dialogs/${encodeURIComponent(dialogId)}/attachments/init`, {
|
||||
method: "POST", protected: true, headers: idempotencyHeaders(key),
|
||||
body: json({ file_name: file.name, mime_type: file.type, size_bytes: file.size }),
|
||||
});
|
||||
const upload = await fetch(init.upload_url, {
|
||||
method: "PUT",
|
||||
headers: init.upload_headers ?? { "Content-Type": file.type },
|
||||
body: file,
|
||||
});
|
||||
if (!upload.ok) throw new Error("Не удалось загрузить файл в хранилище");
|
||||
await apiRequest<void>(
|
||||
`/api/v1/dialogs/${encodeURIComponent(dialogId)}/attachments/${encodeURIComponent(init.attachment_id)}/complete`,
|
||||
{ method: "POST", protected: true, headers: idempotencyHeaders(key), body: json({ checksum }) },
|
||||
);
|
||||
return { attachmentId: init.attachment_id, checksum };
|
||||
}
|
||||
|
||||
async function sha256(file: Blob) {
|
||||
const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
|
||||
return `sha256:${Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("")}`;
|
||||
}
|
||||
|
||||
export const profileApi = {
|
||||
me: () => apiRequest<Profile>("/api/v1/me", { protected: true }),
|
||||
documents: () => apiRequest<Page<DocumentItem>>("/api/v1/me/documents", { protected: true }),
|
||||
documentUrl: (id: string) =>
|
||||
apiRequest<{ download_url: string }>(`/api/v1/documents/${encodeURIComponent(id)}/download-url`, { protected: true }),
|
||||
attachmentUrl: (dialogId: string, id: string) =>
|
||||
apiRequest<{ download_url: string }>(
|
||||
`/api/v1/dialogs/${encodeURIComponent(dialogId)}/attachments/${encodeURIComponent(id)}/download-url`,
|
||||
{ protected: true },
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
let uxSessionId: string | null = null;
|
||||
let lastActivityAt = Date.now();
|
||||
|
||||
export const sessionMemory = {
|
||||
get id() { return uxSessionId; },
|
||||
get lastActivityAt() { return lastActivityAt; },
|
||||
touch() { lastActivityAt = Date.now(); },
|
||||
set(id: string) { uxSessionId = id; lastActivityAt = Date.now(); },
|
||||
clear() { uxSessionId = null; lastActivityAt = Date.now(); },
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
export class SingleFlight<T> {
|
||||
private running: Promise<T> | null = null;
|
||||
|
||||
run(operation: () => Promise<T>): Promise<T> {
|
||||
if (this.running) return this.running;
|
||||
this.running = operation().finally(() => {
|
||||
this.running = null;
|
||||
});
|
||||
return this.running;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
export type Consent = { accepted: boolean; version: string };
|
||||
export type Consents = {
|
||||
personal_data: Consent;
|
||||
user_agreement: Consent;
|
||||
marketing: Consent;
|
||||
};
|
||||
|
||||
export type TokenSet = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: number;
|
||||
idToken?: string;
|
||||
};
|
||||
|
||||
export type DialogStatus = "open" | "waiting_for_company" | "waiting_for_client" | "closed";
|
||||
export type Dialog = { dialog_id: string; status: DialogStatus; updated_at?: string };
|
||||
export type Attachment = {
|
||||
attachment_id: string;
|
||||
file_name: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
scan_status: "pending" | "clean" | "infected" | "failed";
|
||||
};
|
||||
export type Message = {
|
||||
message_id: string;
|
||||
dialog_id: string;
|
||||
sender_type: "client" | "company";
|
||||
content_kind: "text" | "file";
|
||||
text: string;
|
||||
attachments: Attachment[];
|
||||
safety_status: "pending" | "allowed" | "blocked";
|
||||
delivery_status: "accepted" | "delivered" | "failed" | "rejected";
|
||||
created_at: string;
|
||||
};
|
||||
export type Page<T> = { items: T[]; next_cursor: string | null };
|
||||
|
||||
export type Profile = {
|
||||
user_id: string;
|
||||
profile: {
|
||||
personal_data: {
|
||||
full_name: string | null;
|
||||
citizenship: string | null;
|
||||
russian_phone: string | null;
|
||||
foreign_phone: string | null;
|
||||
email: string | null;
|
||||
};
|
||||
documents: { count: number };
|
||||
};
|
||||
};
|
||||
export type DocumentItem = {
|
||||
document_id: string;
|
||||
name: string;
|
||||
sent_at: string;
|
||||
};
|
||||
|
||||
export type PublicConfig = {
|
||||
auth: { phone_enabled: boolean; password_enabled: boolean };
|
||||
operator: { call_phone: string };
|
||||
consents: Record<string, {
|
||||
required: boolean;
|
||||
document_url: string | null;
|
||||
version: string;
|
||||
}>;
|
||||
attachments: {
|
||||
max_size_mb: number;
|
||||
allowed_extensions: string[];
|
||||
allowed_mime_types: string[];
|
||||
};
|
||||
ux: { idle_timeout_minutes: number };
|
||||
};
|
||||
export type PublicContent = {
|
||||
locale: string;
|
||||
texts: Record<string, string>;
|
||||
popular_questions: Array<{ id: string; mnemonic: string; text: string }>;
|
||||
version: string;
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Link } from "expo-router";
|
||||
import React from "react";
|
||||
import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
|
||||
import { ApiError } from "./api";
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
page: { flex: 1, width: "100%", maxWidth: 920, alignSelf: "center", padding: 20, gap: 16 },
|
||||
header: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 12, paddingBottom: 12, borderBottomWidth: 1, borderColor: "#d7dee8" },
|
||||
title: { fontSize: 28, lineHeight: 34, fontWeight: "700", color: "#12233f" },
|
||||
heading: { fontSize: 20, lineHeight: 26, fontWeight: "700", color: "#12233f" },
|
||||
text: { fontSize: 16, lineHeight: 23, color: "#233653" },
|
||||
muted: { fontSize: 14, lineHeight: 20, color: "#5f6f85" },
|
||||
card: { padding: 16, gap: 10, borderWidth: 1, borderColor: "#d7dee8", borderRadius: 12, backgroundColor: "#fff" },
|
||||
input: { minHeight: 48, borderWidth: 1, borderColor: "#8493a8", borderRadius: 8, padding: 12, fontSize: 16, backgroundColor: "#fff" },
|
||||
textarea: { minHeight: 104, textAlignVertical: "top" },
|
||||
row: { flexDirection: "row", flexWrap: "wrap", gap: 10, alignItems: "center" },
|
||||
button: { minHeight: 44, justifyContent: "center", paddingHorizontal: 18, borderRadius: 8, backgroundColor: "#185abd" },
|
||||
buttonSecondary: { backgroundColor: "#e8eef8" },
|
||||
buttonDanger: { backgroundColor: "#b42318" },
|
||||
buttonDisabled: { opacity: 0.5 },
|
||||
buttonText: { color: "#fff", fontWeight: "700", fontSize: 15 },
|
||||
buttonTextSecondary: { color: "#173b70" },
|
||||
link: { color: "#075db7", fontSize: 16, textDecorationLine: "underline", paddingVertical: 10 },
|
||||
badge: { borderRadius: 20, backgroundColor: "#edf2f8", color: "#263b58", paddingHorizontal: 10, paddingVertical: 5, fontSize: 13 },
|
||||
error: { borderLeftWidth: 4, borderColor: "#b42318", backgroundColor: "#fff1f0", padding: 12, color: "#7a271a" },
|
||||
success: { borderLeftWidth: 4, borderColor: "#16803c", backgroundColor: "#edfdf2", padding: 12, color: "#14532d" },
|
||||
modalBackdrop: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, zIndex: 10, backgroundColor: "rgba(10,25,45,.45)", alignItems: "center", justifyContent: "center", padding: 20 },
|
||||
modal: { width: "100%", maxWidth: 560, borderRadius: 14, backgroundColor: "#fff", padding: 20, gap: 14 },
|
||||
messageClient: { alignSelf: "flex-end", maxWidth: "82%", backgroundColor: "#e4efff", padding: 12, borderRadius: 12 },
|
||||
messageCompany: { alignSelf: "flex-start", maxWidth: "82%", backgroundColor: "#f0f2f5", padding: 12, borderRadius: 12 },
|
||||
});
|
||||
|
||||
export function Header({ status, realtime, onLogout }: { status: string; realtime: string; onLogout?: () => void }) {
|
||||
return <View style={styles.header}>
|
||||
<Link href="/" style={styles.link}>HAN Chat</Link>
|
||||
<Link href="/dialogs" style={styles.link}>Диалоги</Link>
|
||||
<Link href="/profile" style={styles.link}>Профиль</Link>
|
||||
<Link href="/diagnostics" style={styles.link}>Диагностика</Link>
|
||||
<Text style={styles.badge}>{status === "authenticated" ? "Авторизован" : "Гость"} · {realtime}</Text>
|
||||
{onLogout && <Button title="Выйти" secondary onPress={onLogout} />}
|
||||
</View>;
|
||||
}
|
||||
|
||||
export function Button({ title, onPress, disabled, secondary, danger }: {
|
||||
title: string; onPress: () => void; disabled?: boolean; secondary?: boolean; danger?: boolean;
|
||||
}) {
|
||||
return <Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={({ focused }) => [
|
||||
styles.button, secondary && styles.buttonSecondary, danger && styles.buttonDanger,
|
||||
disabled && styles.buttonDisabled, focused && { borderWidth: 3, borderColor: "#ffbf47" },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.buttonText, secondary && styles.buttonTextSecondary]}>{title}</Text>
|
||||
</Pressable>;
|
||||
}
|
||||
|
||||
export function Field(props: React.ComponentProps<typeof TextInput> & { label: string; error?: string }) {
|
||||
return <View style={{ gap: 6 }}>
|
||||
<Text style={styles.text}>{props.label}</Text>
|
||||
<TextInput accessibilityLabel={props.label} {...props} style={[styles.input, props.multiline && styles.textarea, props.style]} />
|
||||
{props.error && <Text accessibilityRole="alert" style={styles.error}>{props.error}</Text>}
|
||||
</View>;
|
||||
}
|
||||
|
||||
export function Loading() {
|
||||
return <View accessibilityRole="progressbar" style={styles.row}><ActivityIndicator /><Text style={styles.muted}>Загрузка…</Text></View>;
|
||||
}
|
||||
|
||||
export function ErrorNotice({ error, retry }: { error: unknown; retry?: () => void }) {
|
||||
const requestId = error instanceof ApiError ? error.requestId : undefined;
|
||||
return <View style={{ gap: 8 }}>
|
||||
<Text accessibilityRole="alert" style={styles.error}>
|
||||
{error instanceof ApiError ? error.message : error instanceof Error ? error.message : "Произошла ошибка"}
|
||||
{requestId ? `\nКод обращения: ${requestId}` : ""}
|
||||
</Text>
|
||||
{retry && <Button title="Повторить" secondary onPress={retry} />}
|
||||
</View>;
|
||||
}
|
||||
Reference in New Issue
Block a user