Разработана первая версия приложений

This commit is contained in:
mi
2026-07-10 18:06:14 +03:00
parent aa8761d1b3
commit 8c7b4074c4
162 changed files with 12178 additions and 16 deletions
@@ -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>;
}