Первая версия мобильного приложения
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
@@ -0,0 +1,7 @@
|
||||
EXPO_PUBLIC_API_BASE_URL=https://chat.example.ru
|
||||
EXPO_PUBLIC_AUTH_BASE_URL=https://chat.example.ru/auth
|
||||
EXPO_PUBLIC_KEYCLOAK_REALM=han-chat
|
||||
EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=han-chat-frontend
|
||||
EXPO_PUBLIC_APP_ENV=production-like
|
||||
|
||||
# Только публичные значения. Service tokens, S3 credentials и OTP-код запрещены.
|
||||
@@ -0,0 +1,16 @@
|
||||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
android/
|
||||
ios/
|
||||
.DS_Store
|
||||
|
||||
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
|
||||
# The following patterns were generated by expo-cli
|
||||
|
||||
expo-env.d.ts
|
||||
# @end expo-cli
|
||||
@@ -0,0 +1 @@
|
||||
legacy-peer-deps=true
|
||||
@@ -0,0 +1,28 @@
|
||||
# HAN Chat Mobile
|
||||
|
||||
Мобильный Android-клиент HAN Chat на Expo SDK 57.
|
||||
|
||||
## Подготовка
|
||||
|
||||
1. Установить Node.js и npm.
|
||||
2. Скопировать `.env.example` в `.env` и указать адреса API и Keycloak.
|
||||
3. Установить зависимости:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
4. Зарегистрировать `han-chat://auth/callback` как допустимый redirect URI клиента Keycloak.
|
||||
|
||||
## Локальные проверки
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm test
|
||||
```
|
||||
|
||||
Запуск приложения выполняется отдельно командой `npm run android`. Проект использует managed Expo workflow и не хранит каталоги `android/` и `ios/`.
|
||||
|
||||
## Сборка
|
||||
|
||||
Профили development, preview и production определены в `eas.json`. Перед первой EAS-сборкой потребуется привязать Expo-проект и настроить signing credentials.
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ExpoConfig } from "expo/config";
|
||||
|
||||
const config: ExpoConfig = {
|
||||
name: "HAN Chat",
|
||||
slug: "han-chat",
|
||||
owner: "anzh",
|
||||
version: "1.0.0",
|
||||
scheme: "han-chat",
|
||||
icon: "./.assets/icons/icon.png",
|
||||
orientation: "portrait",
|
||||
userInterfaceStyle: "light",
|
||||
android: {
|
||||
package: "ru.han.chat",
|
||||
softwareKeyboardLayoutMode: "resize",
|
||||
adaptiveIcon: {
|
||||
foregroundImage: "./.assets/icons/adaptive-foreground.png",
|
||||
monochromeImage: "./.assets/icons/adaptive-monochrome.png",
|
||||
backgroundColor: "#4A0E1E",
|
||||
},
|
||||
intentFilters: [
|
||||
{
|
||||
action: "VIEW",
|
||||
autoVerify: false,
|
||||
data: [
|
||||
{
|
||||
scheme: "han-chat",
|
||||
host: "auth",
|
||||
pathPrefix: "/callback",
|
||||
},
|
||||
],
|
||||
category: ["BROWSABLE", "DEFAULT"],
|
||||
},
|
||||
],
|
||||
},
|
||||
experiments: {
|
||||
typedRoutes: true,
|
||||
},
|
||||
extra: {
|
||||
eas: {
|
||||
projectId: "53d1a38c-f888-40ca-b485-58f5301e4dff",
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
"expo-router",
|
||||
"expo-secure-store",
|
||||
"expo-document-picker",
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import React from "react";
|
||||
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
|
||||
import { AppProvider } from "../src/app-context";
|
||||
import { colors } from "../src/theme";
|
||||
|
||||
export default function RootLayout() {
|
||||
return <SafeAreaProvider>
|
||||
<AppProvider>
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<StatusBar style="dark" />
|
||||
<Stack screenOptions={{ headerShown: false }} />
|
||||
</SafeAreaView>
|
||||
</AppProvider>
|
||||
</SafeAreaProvider>;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { clearPendingConsents, loadPendingConsents, useApp } from "../../src/app-context";
|
||||
import { AuthLoadingView } from "../../src/components/AuthLoadingView";
|
||||
import { ScreenShell } from "../../src/components/ScreenShell";
|
||||
import { pendingDialogKey, restorePendingIntents } from "../../src/pending-intent";
|
||||
import { spacing } from "../../src/theme";
|
||||
import { Button, ErrorNotice, styles } from "../../src/ui";
|
||||
|
||||
export default function AuthCallbackScreen() {
|
||||
const params = useLocalSearchParams<{ code?: string; state?: string; error?: string }>();
|
||||
const app = useApp();
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState<unknown>();
|
||||
const completionStarted = useRef(false);
|
||||
|
||||
const complete = async () => {
|
||||
if (!params.code || !params.state) return;
|
||||
const consents = await loadPendingConsents();
|
||||
if (!consents) throw new Error("Не найдены локально принятые согласия. Начните вход заново.");
|
||||
setError(undefined);
|
||||
await app.finishCallback(params.code, params.state, consents);
|
||||
await clearPendingConsents();
|
||||
await restorePendingIntents();
|
||||
|
||||
if (pendingDialogKey()) {
|
||||
router.replace("/dialogs?pending=1");
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace("/");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (completionStarted.current) return;
|
||||
if (params.error) {
|
||||
completionStarted.current = true;
|
||||
setError(new Error("Авторизация отменена или отклонена."));
|
||||
return;
|
||||
}
|
||||
if (!params.code || !params.state) return;
|
||||
completionStarted.current = true;
|
||||
void complete().catch(setError);
|
||||
}, [params.code, params.state, params.error]);
|
||||
|
||||
return (
|
||||
<ScreenShell>
|
||||
{!error ? (
|
||||
<AuthLoadingView />
|
||||
) : (
|
||||
<View style={{ flex: 1, justifyContent: "center", padding: spacing.lg, gap: spacing.lg }}>
|
||||
<Text accessibilityRole="header" style={styles.title}>Не удалось завершить вход</Text>
|
||||
<>
|
||||
<ErrorNotice error={error} />
|
||||
<Button title="Повторить" onPress={() => void complete().catch(setError)} />
|
||||
<Button title="На главную" secondary onPress={() => router.replace("/")} />
|
||||
</>
|
||||
</View>
|
||||
)}
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { useRouter } from "expo-router";
|
||||
import { getDiagnostics, sessionMemory } from "../src/api";
|
||||
import { getTokenInfo } from "../src/auth";
|
||||
import { useApp } from "../src/app-context";
|
||||
import { isProduction } from "../src/config";
|
||||
import { getRealtimeDiagnostics } from "../src/realtime";
|
||||
import { ScreenShell } from "../src/components/ScreenShell";
|
||||
import { styles } from "../src/ui";
|
||||
import { colors, radii, spacing } from "../src/theme";
|
||||
|
||||
export default function DiagnosticsScreen() {
|
||||
const app = useApp();
|
||||
const router = useRouter();
|
||||
const [, render] = useState(0);
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => render((value) => value + 1), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
if (isProduction) {
|
||||
return (
|
||||
<ScreenShell>
|
||||
<View style={stylesLocal.topBar}>
|
||||
<Pressable onPress={() => router.back()} style={stylesLocal.backButton}>
|
||||
<Feather name="arrow-left" size={20} color={colors.foreground} />
|
||||
</Pressable>
|
||||
<Text accessibilityRole="header" style={styles.title}>Диагностика</Text>
|
||||
</View>
|
||||
<View style={{ padding: spacing.lg }}>
|
||||
<Text style={styles.error}>Экран отключён в production.</Text>
|
||||
</View>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const token = getTokenInfo();
|
||||
return (
|
||||
<ScreenShell>
|
||||
<ScrollView contentContainerStyle={{ padding: spacing.lg, gap: spacing.lg, paddingBottom: spacing.xl }}>
|
||||
<View style={stylesLocal.topBar}>
|
||||
<Pressable onPress={() => router.back()} style={stylesLocal.backButton}>
|
||||
<Feather name="arrow-left" size={20} color={colors.foreground} />
|
||||
</Pressable>
|
||||
<Text accessibilityRole="header" style={styles.title}>Безопасная диагностика</Text>
|
||||
</View>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.text}>Режим: {app.authStatus}</Text>
|
||||
<Text style={styles.text}>Realtime: {app.realtimeState}</Text>
|
||||
<Text style={styles.text}>UX-сессия: {sessionMemory.id ?? "нет"}</Text>
|
||||
<Text style={styles.text}>Access token истекает: {token ? new Date(token.expiresAt).toLocaleString("ru-RU") : "нет"}</Text>
|
||||
{getRealtimeDiagnostics().map((item) =>
|
||||
<Text key={item.dialog} style={styles.text}>Cursor {item.dialog}: {item.cursor}</Text>,
|
||||
)}
|
||||
<Text style={styles.muted}>Токены, OTP, персональные данные, сообщения и presigned URL здесь никогда не отображаются.</Text>
|
||||
</View>
|
||||
<View style={styles.card}>
|
||||
<Text accessibilityRole="header" style={styles.heading}>Последние запросы</Text>
|
||||
{!getDiagnostics().length && <Text style={styles.muted}>Запросов ещё нет.</Text>}
|
||||
{getDiagnostics().map((item) => <View key={`${item.at}-${item.requestId}`} style={styles.row}>
|
||||
<Text style={styles.badge}>{item.status}</Text>
|
||||
<Text style={styles.text}>{item.method} {item.path}</Text>
|
||||
<Text style={styles.muted}>request_id: {item.requestId}</Text>
|
||||
</View>)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const stylesLocal = StyleSheet.create({
|
||||
topBar: { flexDirection: "row", alignItems: "center", gap: spacing.md, marginBottom: spacing.sm },
|
||||
backButton: { width: 36, height: 36, borderRadius: radii.full, alignItems: "center", justifyContent: "center" },
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as Crypto from "expo-crypto";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AppState, FlatList, Text, View } from "react-native";
|
||||
import { useApp } from "../../src/app-context";
|
||||
import { ChatInputBar } from "../../src/components/ChatInputBar";
|
||||
import { ChatScreenHeader } from "../../src/components/ChatScreenHeader";
|
||||
import { GuestAuthGate } from "../../src/components/GuestAuthGate";
|
||||
import { MessageBubble } from "../../src/components/MessageBubble";
|
||||
import { ScreenShell } from "../../src/components/ScreenShell";
|
||||
import { isMessageBlockedError } from "../../src/api";
|
||||
import {
|
||||
clearPendingFileIntent,
|
||||
clearPendingTextIntent,
|
||||
loadPendingFileIntent,
|
||||
loadPendingTextIntent,
|
||||
} from "../../src/pending-intent";
|
||||
import { DEFAULT_MESSAGE_MAX_LENGTH, normalizeMessageText } from "../../src/message-text";
|
||||
import { pickFiles, type NativeFile } from "../../src/native-files";
|
||||
import { RealtimeClient, reconcileMessages } from "../../src/realtime";
|
||||
import { dialogApi, profileApi, publicApi, uploadAttachment } from "../../src/services";
|
||||
import type { Message } from "../../src/types";
|
||||
import { Button, ErrorNotice, Loading, styles } from "../../src/ui";
|
||||
import { colors, spacing } from "../../src/theme";
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
open: "Открыт",
|
||||
waiting_for_company: "Ожидает ответа",
|
||||
waiting_for_client: "Ожидает вашего ответа",
|
||||
closed: "Закрыт",
|
||||
};
|
||||
|
||||
export default function ChatScreen() {
|
||||
const { dialogId } = useLocalSearchParams<{ dialogId: string }>();
|
||||
const app = useApp();
|
||||
const client = useQueryClient();
|
||||
const router = useRouter();
|
||||
const listRef = useRef<FlatList<Message>>(null);
|
||||
const [text, setText] = useState("");
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [sending, setSending] = useState(false);
|
||||
const [retryPending, setRetryPending] = useState<(() => void) | undefined>();
|
||||
const pendingStarted = useRef<string | undefined>(undefined);
|
||||
const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config });
|
||||
const dialog = useQuery({ queryKey: ["dialog", dialogId], queryFn: () => dialogApi.get(dialogId), enabled: Boolean(dialogId) && app.authStatus === "authenticated" });
|
||||
const messages = useQuery({ queryKey: ["messages", dialogId], queryFn: () => dialogApi.messages(dialogId), enabled: Boolean(dialogId) && app.authStatus === "authenticated" });
|
||||
|
||||
const realtime = useMemo(() => new RealtimeClient(
|
||||
dialogId ? [dialogId] : [],
|
||||
(event) => {
|
||||
if (event.type === "message.new") merge([event.message]);
|
||||
if (event.type === "message.status") {
|
||||
client.setQueryData(["messages", dialogId], (old: typeof messages.data) => old && ({
|
||||
...old,
|
||||
items: old.items.map((item) => item.message_id === event.message_id ? { ...item, safety_status: event.safety_status, delivery_status: event.delivery_status } : item),
|
||||
}));
|
||||
}
|
||||
if (event.type === "dialog.status") void dialog.refetch();
|
||||
},
|
||||
(_, incoming) => merge(incoming),
|
||||
app.setRealtimeState,
|
||||
), [dialogId]);
|
||||
|
||||
function merge(incoming: Message[]) {
|
||||
client.setQueryData(["messages", dialogId], (old: typeof messages.data) => ({
|
||||
items: reconcileMessages(old?.items ?? [], incoming),
|
||||
next_cursor: old?.next_cursor ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (app.authStatus !== "authenticated") return;
|
||||
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();
|
||||
};
|
||||
}, [realtime, app.authStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
const items = messages.data?.items ?? [];
|
||||
if (items.length) listRef.current?.scrollToEnd({ animated: true });
|
||||
}, [messages.data?.items.length]);
|
||||
|
||||
const handleSendError = async (reason: unknown) => {
|
||||
if (isMessageBlockedError(reason)) {
|
||||
setError(undefined);
|
||||
await messages.refetch();
|
||||
return true;
|
||||
}
|
||||
setError(reason);
|
||||
return false;
|
||||
};
|
||||
|
||||
const sendText = async (value = text, messageKey = Crypto.randomUUID()) => {
|
||||
const normalized = normalizeMessageText(value);
|
||||
if (!normalized || !dialogId) return;
|
||||
const maxLength = config.data?.messages?.max_text_length ?? DEFAULT_MESSAGE_MAX_LENGTH;
|
||||
if (normalized.length > maxLength) {
|
||||
setError(new Error(`Сообщение слишком длинное. Максимум — ${maxLength} символов.`));
|
||||
return false;
|
||||
}
|
||||
setSending(true); setError(undefined);
|
||||
try {
|
||||
const message = await dialogApi.sendText(dialogId, normalized, messageKey);
|
||||
merge([message]); setText("");
|
||||
return true;
|
||||
} catch (reason) {
|
||||
return await handleSendError(reason);
|
||||
}
|
||||
finally { setSending(false); }
|
||||
};
|
||||
|
||||
const chooseFile = async () => {
|
||||
try {
|
||||
const [file] = await pickFiles({ mimeTypes: ["image/*", "application/pdf"] });
|
||||
if (file) await sendFile(file);
|
||||
} catch (reason) {
|
||||
setError(reason);
|
||||
}
|
||||
};
|
||||
|
||||
const sendFile = async (file: NativeFile, messageKey = Crypto.randomUUID()) => {
|
||||
const limits = config.data?.attachments;
|
||||
const max = (limits?.max_size_mb ?? 5) * 1024 * 1024;
|
||||
const allowed = limits?.allowed_mime_types ?? ["image/jpeg", "image/png", "image/webp", "application/pdf"];
|
||||
if (file.size > max || !allowed.includes(file.mimeType)) {
|
||||
setError(new Error("Недопустимый тип файла или превышен допустимый размер."));
|
||||
return;
|
||||
}
|
||||
setSending(true); setError(undefined);
|
||||
try {
|
||||
const uploaded = await uploadAttachment(dialogId, file);
|
||||
const message = await dialogApi.sendFile(dialogId, uploaded.attachmentId, uploaded.checksum, messageKey);
|
||||
merge([message]);
|
||||
return true;
|
||||
} catch (reason) {
|
||||
return await handleSendError(reason);
|
||||
}
|
||||
finally { setSending(false); }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogId || app.authStatus !== "authenticated") return;
|
||||
const textIntent = loadPendingTextIntent();
|
||||
const fileIntent = loadPendingFileIntent(dialogId);
|
||||
const intentKey = textIntent?.dialogId === dialogId
|
||||
? textIntent.messageKey
|
||||
: fileIntent?.messageKey;
|
||||
if (!intentKey || pendingStarted.current === intentKey) return;
|
||||
pendingStarted.current = intentKey;
|
||||
|
||||
const submit = async () => {
|
||||
const completed = textIntent?.dialogId === dialogId
|
||||
? await sendText(textIntent.text, textIntent.messageKey)
|
||||
: fileIntent
|
||||
? await sendFile(fileIntent.file, fileIntent.messageKey)
|
||||
: true;
|
||||
if (completed) {
|
||||
await Promise.all([
|
||||
clearPendingTextIntent(),
|
||||
clearPendingFileIntent(),
|
||||
]);
|
||||
setRetryPending(undefined);
|
||||
return;
|
||||
}
|
||||
setRetryPending(() => () => {
|
||||
setRetryPending(undefined);
|
||||
void submit().catch(setError);
|
||||
});
|
||||
};
|
||||
void submit().catch(setError);
|
||||
}, [dialogId, app.authStatus]);
|
||||
|
||||
const getAttachmentUrl = useCallback(async (attachmentId: string) => {
|
||||
const result = await profileApi.attachmentUrl(dialogId, attachmentId);
|
||||
return result.download_url;
|
||||
}, [dialogId]);
|
||||
|
||||
const closed = dialog.data?.status === "closed";
|
||||
const items = messages.data?.items ?? [];
|
||||
const subtitle = dialog.data?.status ? (statusLabel[dialog.data.status] ?? "Онлайн") : "Онлайн";
|
||||
|
||||
if (app.authStatus !== "authenticated") {
|
||||
return (
|
||||
<ScreenShell>
|
||||
<ChatScreenHeader title="Чат" subtitle="Требуется вход" />
|
||||
<GuestAuthGate
|
||||
icon="message-circle"
|
||||
title="Чат доступен после входа"
|
||||
description="Авторизуйтесь, чтобы переписываться с оператором и получать ответы."
|
||||
/>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScreenShell>
|
||||
<ChatScreenHeader subtitle={subtitle} />
|
||||
<View style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
{(dialog.isLoading || messages.isLoading) && (
|
||||
<View style={{ padding: spacing.lg }}><Loading /></View>
|
||||
)}
|
||||
{(dialog.error || messages.error) && (
|
||||
<View style={{ padding: spacing.lg }}>
|
||||
<ErrorNotice error={dialog.error ?? messages.error} retry={() => { void dialog.refetch(); void messages.refetch(); }} />
|
||||
</View>
|
||||
)}
|
||||
<FlatList
|
||||
accessibilityLiveRegion="polite"
|
||||
ref={listRef}
|
||||
data={items}
|
||||
keyExtractor={(item) => item.message_id}
|
||||
keyboardDismissMode="on-drag"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentContainerStyle={{ paddingHorizontal: spacing.lg, paddingVertical: spacing.lg, flexGrow: 1 }}
|
||||
ListEmptyComponent={!messages.isLoading ? <Text style={[styles.muted, { textAlign: "center", marginTop: 40 }]}>Сообщений пока нет.</Text> : null}
|
||||
renderItem={({ item }) => (
|
||||
<MessageBubble
|
||||
getAttachmentUrl={getAttachmentUrl}
|
||||
message={item}
|
||||
onAttachmentError={setError}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{closed ? (
|
||||
<View style={{ padding: spacing.lg, borderTopWidth: 1, borderTopColor: colors.border }}>
|
||||
<Text style={[styles.muted, { marginBottom: spacing.sm }]}>Предыдущая беседа завершена.</Text>
|
||||
<Button title="Продолжить общение" onPress={() => router.replace("/dialogs")} />
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<ChatInputBar
|
||||
disabled={sending}
|
||||
hint=""
|
||||
onAttach={() => void chooseFile()}
|
||||
onChangeText={setText}
|
||||
onSubmit={() => void sendText()}
|
||||
placeholder="Напишите сообщение..."
|
||||
sending={sending}
|
||||
value={text}
|
||||
maxLength={config.data?.messages?.max_text_length ?? DEFAULT_MESSAGE_MAX_LENGTH}
|
||||
/>
|
||||
{error && (
|
||||
<View style={{ paddingHorizontal: spacing.lg, paddingBottom: spacing.sm }}>
|
||||
<ErrorNotice
|
||||
error={error}
|
||||
{...(retryPending ? { retry: retryPending } : {})}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as Crypto from "expo-crypto";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useApp } from "../../src/app-context";
|
||||
import { AppHeader } from "../../src/components/AppHeader";
|
||||
import { ChatScreenHeader } from "../../src/components/ChatScreenHeader";
|
||||
import { GuestAuthGate } from "../../src/components/GuestAuthGate";
|
||||
import { ScreenShell } from "../../src/components/ScreenShell";
|
||||
import {
|
||||
bindPendingFileIntent,
|
||||
bindPendingTextIntent,
|
||||
loadPendingTextIntent,
|
||||
pendingDialogKey,
|
||||
} from "../../src/pending-intent";
|
||||
import { dialogApi } from "../../src/services";
|
||||
import { ErrorNotice, Loading, styles } from "../../src/ui";
|
||||
import { spacing } from "../../src/theme";
|
||||
|
||||
export default function DialogsScreen() {
|
||||
const app = useApp();
|
||||
const router = useRouter();
|
||||
const [requestKey, setRequestKey] = useState<string>();
|
||||
const [bindError, setBindError] = useState<unknown>();
|
||||
const [bindAttempt, setBindAttempt] = useState(0);
|
||||
useEffect(() => {
|
||||
if (app.authStatus === "authenticated" && !requestKey) {
|
||||
setRequestKey(pendingDialogKey() ?? Crypto.randomUUID());
|
||||
}
|
||||
}, [app.authStatus, requestKey]);
|
||||
const chat = useQuery({
|
||||
queryKey: ["current-dialog", requestKey],
|
||||
queryFn: () => dialogApi.create(requestKey!),
|
||||
enabled: app.authStatus === "authenticated" && Boolean(requestKey),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (chat.data) {
|
||||
const bindAndOpen = async () => {
|
||||
const intent = loadPendingTextIntent();
|
||||
if (intent && !intent.dialogId) {
|
||||
await bindPendingTextIntent(intent, chat.data.dialog_id);
|
||||
}
|
||||
await bindPendingFileIntent(chat.data.dialog_id);
|
||||
router.replace(`/dialogs/${chat.data.dialog_id}`);
|
||||
};
|
||||
void bindAndOpen().catch(setBindError);
|
||||
}
|
||||
}, [bindAttempt, chat.data, router]);
|
||||
|
||||
if (app.authStatus !== "authenticated") {
|
||||
return (
|
||||
<ScreenShell>
|
||||
<ChatScreenHeader title="Чат" subtitle="Требуется вход" />
|
||||
<GuestAuthGate
|
||||
icon="message-circle"
|
||||
title="Чат доступен после входа"
|
||||
description="Авторизуйтесь, чтобы переписываться с оператором и получать ответы."
|
||||
/>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScreenShell>
|
||||
<AppHeader />
|
||||
<View style={{ flex: 1, padding: spacing.lg, justifyContent: "center" }}>
|
||||
<Text accessibilityRole="header" style={styles.title}>Открываем чат…</Text>
|
||||
{chat.isLoading && <Loading />}
|
||||
{Boolean(chat.error || bindError) && (
|
||||
<ErrorNotice
|
||||
error={chat.error ?? bindError}
|
||||
retry={() => {
|
||||
setBindError(undefined);
|
||||
if (chat.error) void chat.refetch();
|
||||
else setBindAttempt((attempt) => attempt + 1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as Crypto from "expo-crypto";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { useApp } from "../src/app-context";
|
||||
import { AppHeader } from "../src/components/AppHeader";
|
||||
import { ChatInputBar } from "../src/components/ChatInputBar";
|
||||
import { ConsentModal } from "../src/components/ConsentModal";
|
||||
import { HanLogo } from "../src/components/HanLogo";
|
||||
import { NotificationCarousel } from "../src/components/NotificationCarousel";
|
||||
import { PopularQuestionsList } from "../src/components/PopularQuestionsList";
|
||||
import { QuickActions } from "../src/components/QuickActions";
|
||||
import { ScreenShell } from "../src/components/ScreenShell";
|
||||
import {
|
||||
clearPendingTextIntent,
|
||||
createPendingTextIntent,
|
||||
savePendingTextIntent,
|
||||
savePendingFileIntent,
|
||||
type PendingTextIntent,
|
||||
} from "../src/pending-intent";
|
||||
import { DEFAULT_MESSAGE_MAX_LENGTH, normalizeMessageText } from "../src/message-text";
|
||||
import { pickFiles, type NativeFile } from "../src/native-files";
|
||||
import { publicApi } from "../src/services";
|
||||
import type { Consents } from "../src/types";
|
||||
import { ErrorNotice, Loading, styles } from "../src/ui";
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { authStatus, authorize } = useApp();
|
||||
const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config });
|
||||
const content = useQuery({ queryKey: ["public-content"], queryFn: publicApi.content });
|
||||
const [consentOpen, setConsentOpen] = useState(false);
|
||||
const [pending, setPending] = useState<PendingTextIntent | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
const [sendError, setSendError] = useState<unknown>();
|
||||
const [sending] = useState(false);
|
||||
const [afterNotificationAuth, setAfterNotificationAuth] = useState<(() => Promise<void>) | undefined>();
|
||||
const { authorize: authorizeParam } = useLocalSearchParams<{ authorize?: string }>();
|
||||
const handledAuthorizeParam = useRef(false);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (authorizeParam === "1" && !handledAuthorizeParam.current && authStatus !== "authenticated") {
|
||||
handledAuthorizeParam.current = true;
|
||||
setConsentOpen(true);
|
||||
}
|
||||
}, [authStatus, authorizeParam]);
|
||||
|
||||
const openChatWithText = async (intent: PendingTextIntent) => {
|
||||
setSendError(undefined);
|
||||
await savePendingTextIntent(intent);
|
||||
setMessage("");
|
||||
setPending(null);
|
||||
router.push("/dialogs?pending=1");
|
||||
};
|
||||
|
||||
const send = async (text: string) => {
|
||||
const normalized = normalizeMessageText(text);
|
||||
if (!normalized) return;
|
||||
const maxLength = config.data?.messages?.max_text_length ?? DEFAULT_MESSAGE_MAX_LENGTH;
|
||||
if (normalized.length > maxLength) {
|
||||
setSendError(new Error(`Сообщение слишком длинное. Максимум — ${maxLength} символов.`));
|
||||
return;
|
||||
}
|
||||
const intent = createPendingTextIntent(normalized);
|
||||
try {
|
||||
if (authStatus !== "authenticated") {
|
||||
await savePendingTextIntent(intent);
|
||||
setPending(intent);
|
||||
setConsentOpen(true);
|
||||
return;
|
||||
}
|
||||
await openChatWithText(intent);
|
||||
} catch (error) {
|
||||
setSendError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const chooseFile = async () => {
|
||||
if (authStatus !== "authenticated") {
|
||||
setConsentOpen(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [file] = await pickFiles({ mimeTypes: ["image/*", "application/pdf"] });
|
||||
if (file) await sendFile(file);
|
||||
} catch (error) {
|
||||
setSendError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const sendFile = async (file: NativeFile) => {
|
||||
const limits = config.data?.attachments;
|
||||
const max = (limits?.max_size_mb ?? 5) * 1024 * 1024;
|
||||
const allowed = limits?.allowed_mime_types ?? ["image/jpeg", "image/png", "image/webp", "application/pdf"];
|
||||
if (file.size > max || !allowed.includes(file.mimeType)) {
|
||||
setSendError(new Error("Недопустимый тип файла или превышен допустимый размер."));
|
||||
return;
|
||||
}
|
||||
setSendError(undefined);
|
||||
await savePendingFileIntent({
|
||||
file,
|
||||
dialogKey: Crypto.randomUUID(),
|
||||
messageKey: Crypto.randomUUID(),
|
||||
});
|
||||
router.push("/dialogs?pending=1");
|
||||
};
|
||||
|
||||
const accept = async (accepted: {
|
||||
personal_data: boolean;
|
||||
user_agreement: boolean;
|
||||
marketing: boolean;
|
||||
}) => {
|
||||
const versions = config.data?.consents;
|
||||
const consents: Consents = {
|
||||
personal_data: { accepted: accepted.personal_data, version: versions?.personal_data?.version ?? "current" },
|
||||
user_agreement: { accepted: accepted.user_agreement, version: versions?.user_agreement?.version ?? "current" },
|
||||
marketing: { accepted: accepted.marketing, version: versions?.marketing?.version ?? "current" },
|
||||
};
|
||||
setConsentOpen(false);
|
||||
try {
|
||||
const authorized = await authorize(consents);
|
||||
if (authorized && pending) await openChatWithText(pending);
|
||||
else if (authorized && afterNotificationAuth) await afterNotificationAuth();
|
||||
} catch (error) {
|
||||
setSendError(error);
|
||||
} finally {
|
||||
setAfterNotificationAuth(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const welcome = content.data?.texts.welcome;
|
||||
const questions = content.data?.popular_questions ?? [];
|
||||
|
||||
return (
|
||||
<ScreenShell>
|
||||
<AppHeader guestLabel={authStatus === "authenticated" ? undefined : "Гость"} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<HanLogo />
|
||||
<ScrollView
|
||||
contentContainerStyle={{ paddingBottom: spacing }}
|
||||
keyboardDismissMode="on-drag"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{(config.isLoading || content.isLoading) && <Loading />}
|
||||
{(config.error || content.error) && (
|
||||
<View style={{ paddingHorizontal: 16 }}>
|
||||
<ErrorNotice error={config.error ?? content.error} retry={() => { void config.refetch(); void content.refetch(); }} />
|
||||
</View>
|
||||
)}
|
||||
{welcome ? (
|
||||
<Text style={[styles.muted, { paddingHorizontal: 16, marginBottom: 8 }]}>{welcome}</Text>
|
||||
) : null}
|
||||
<NotificationCarousel
|
||||
authenticated={authStatus === "authenticated"}
|
||||
autoplay={config.data?.notification?.carousel_autoplay_enabled ?? false}
|
||||
autoplayIntervalMs={config.data?.notification?.carousel_autoplay_interval_ms ?? 5000}
|
||||
requireAuth={(afterAuth) => {
|
||||
setAfterNotificationAuth(afterAuth ? () => afterAuth : undefined);
|
||||
setConsentOpen(true);
|
||||
}}
|
||||
/>
|
||||
</ScrollView>
|
||||
<PopularQuestionsList questions={questions} onSelect={(text) => { setMessage(text); void send(text); }} />
|
||||
<ChatInputBar
|
||||
disabled={sending}
|
||||
onAttach={() => void chooseFile()}
|
||||
onChangeText={setMessage}
|
||||
onSubmit={() => void send(message)}
|
||||
sending={sending}
|
||||
value={message}
|
||||
maxLength={config.data?.messages?.max_text_length ?? DEFAULT_MESSAGE_MAX_LENGTH}
|
||||
/>
|
||||
<QuickActions
|
||||
authenticated={authStatus === "authenticated"}
|
||||
phone={config.data?.operator.call_phone}
|
||||
/>
|
||||
{Boolean(sendError) && (
|
||||
<View style={{ paddingHorizontal: 16, paddingBottom: 8 }}>
|
||||
<ErrorNotice error={sendError} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{consentOpen && (
|
||||
<ConsentModal
|
||||
consents={config.data?.consents}
|
||||
onAccept={(accepted) => void accept(accepted)}
|
||||
onCancel={() => {
|
||||
setConsentOpen(false);
|
||||
setPending(null);
|
||||
setAfterNotificationAuth(undefined);
|
||||
void clearPendingTextIntent().catch(setSendError);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const spacing = 16;
|
||||
@@ -0,0 +1,284 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { ApiError } from "../../src/api";
|
||||
import { useApp } from "../../src/app-context";
|
||||
import { ScreenShell } from "../../src/components/ScreenShell";
|
||||
import { notificationApi, notificationKeys, uploadDraftApi } from "../../src/notification-api";
|
||||
import { formatNotificationPrice, typeMap } from "../../src/notification-presenter";
|
||||
import { downloadAndOpen, pickFiles, type NativeFile } from "../../src/native-files";
|
||||
import { publicApi } from "../../src/services";
|
||||
import { colors, radii, spacing } from "../../src/theme";
|
||||
import type { NotificationButton, UploadDraft } from "../../src/types";
|
||||
import { Button, ErrorNotice, Loading, styles } from "../../src/ui";
|
||||
|
||||
export default function NotificationDetailScreen() {
|
||||
const { id = "" } = useLocalSearchParams<{ id: string }>();
|
||||
const { authStatus } = useApp();
|
||||
const authenticated = authStatus === "authenticated";
|
||||
const router = useRouter();
|
||||
const client = useQueryClient();
|
||||
const readSent = useRef(false);
|
||||
const [error, setError] = useState<unknown>();
|
||||
const detail = useQuery({
|
||||
queryKey: notificationKeys.detail(id),
|
||||
queryFn: () => notificationApi.detail(id),
|
||||
enabled: authenticated && Boolean(id),
|
||||
retry: (count, reason) => !(reason instanceof ApiError && reason.status === 404) && count < 1,
|
||||
});
|
||||
const catalog = useQuery({
|
||||
queryKey: notificationKeys.catalog,
|
||||
queryFn: notificationApi.catalog,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config });
|
||||
const byCode = useMemo(() => typeMap(catalog.data ?? []), [catalog.data]);
|
||||
const type = detail.data ? byCode.get(detail.data.notification_type) : undefined;
|
||||
const canUpload = Boolean(detail.data?.details?.send_documents);
|
||||
const drafts = useQuery({
|
||||
queryKey: ["uploads", "notification", id],
|
||||
queryFn: () => uploadDraftApi.list(id),
|
||||
enabled: authenticated && Boolean(id) && canUpload,
|
||||
});
|
||||
const pending = drafts.data ?? detail.data?.details?.pending_documents ?? [];
|
||||
const closed = detail.data?.lifecycle_status === "closed";
|
||||
|
||||
useEffect(() => {
|
||||
if (!detail.data || readSent.current || detail.data.is_read !== false) return;
|
||||
readSent.current = true;
|
||||
void notificationApi.read(id).then((state) => {
|
||||
client.setQueryData(notificationKeys.detail(id), { ...detail.data, ...state });
|
||||
client.setQueryData(notificationKeys.counter, { unread_count: state.unread_count });
|
||||
}).catch(setError);
|
||||
}, [client, detail.data, id]);
|
||||
|
||||
const pressButton = useMutation({
|
||||
mutationFn: (button: NotificationButton) => notificationApi.button(id, button.code),
|
||||
onSuccess: async (state) => {
|
||||
client.setQueryData(notificationKeys.counter, { unread_count: state.unread_count });
|
||||
await client.invalidateQueries({ queryKey: ["notifications"] });
|
||||
router.replace("/notifications");
|
||||
},
|
||||
onError: setError,
|
||||
});
|
||||
const removeDraft = useMutation({
|
||||
mutationFn: uploadDraftApi.remove,
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: ["uploads", "notification", id] }),
|
||||
onError: setError,
|
||||
});
|
||||
|
||||
const chooseFile = async () => {
|
||||
try {
|
||||
const files = await pickFiles({
|
||||
multiple: true,
|
||||
mimeTypes: config.data?.attachments.allowed_mime_types,
|
||||
});
|
||||
if (files.length) await uploadFiles(files);
|
||||
} catch (reason) {
|
||||
setError(reason);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFiles = async (files: NativeFile[]) => {
|
||||
const limits = config.data?.attachments;
|
||||
const maxBytes = (limits?.max_size_mb ?? 5) * 1024 * 1024;
|
||||
const allowed = limits?.allowed_mime_types ?? [];
|
||||
if (pending.length + files.length > 10) {
|
||||
setError(new Error("К одному уведомлению можно приложить не более 10 файлов."));
|
||||
return;
|
||||
}
|
||||
const invalid = files.find((file) => file.size > maxBytes || (allowed.length > 0 && !allowed.includes(file.mimeType)));
|
||||
if (invalid) {
|
||||
setError(new Error(`Файл «${invalid.name}» имеет недопустимый тип или размер.`));
|
||||
return;
|
||||
}
|
||||
setError(undefined);
|
||||
try {
|
||||
for (const file of files) await uploadDraftApi.upload(id, file);
|
||||
await drafts.refetch();
|
||||
await detail.refetch();
|
||||
} catch (reason) {
|
||||
setError(reason);
|
||||
}
|
||||
};
|
||||
|
||||
const download = async (documentId: string) => {
|
||||
setError(undefined);
|
||||
try {
|
||||
const result = await notificationApi.documentUrl(id, documentId);
|
||||
const title = detail.data?.details?.documents?.find((item) => item.document_id === documentId)?.title;
|
||||
await downloadAndOpen(result.download_url, title ?? "document");
|
||||
await Promise.all([detail.refetch(), client.invalidateQueries({ queryKey: ["notifications"] })]);
|
||||
} catch (reason) {
|
||||
setError(reason);
|
||||
}
|
||||
};
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<ScreenShell>
|
||||
<DetailHeader title="Уведомление" onBack={() => router.replace("/notifications")} />
|
||||
<View style={local.center}>
|
||||
<Text style={styles.text}>Для просмотра уведомления требуется авторизация.</Text>
|
||||
<Button title="Перейти в Центр" onPress={() => router.replace("/notifications")} />
|
||||
</View>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const unavailable = detail.error instanceof ApiError && detail.error.status === 404;
|
||||
const notification = detail.data;
|
||||
const details = notification?.details;
|
||||
const price = formatNotificationPrice(notification?.price);
|
||||
const oldPrice = formatNotificationPrice(notification?.old_price);
|
||||
|
||||
return (
|
||||
<ScreenShell>
|
||||
<DetailHeader title={type?.label ?? "Уведомление"} onBack={() => router.back()} />
|
||||
<ScrollView contentContainerStyle={local.content}>
|
||||
{(detail.isLoading || catalog.isLoading) && <Loading />}
|
||||
{unavailable ? (
|
||||
<View style={local.empty}>
|
||||
<Feather name="slash" size={32} color={colors.mutedForeground} />
|
||||
<Text style={styles.title}>Уведомление недоступно</Text>
|
||||
<Text style={styles.muted}>Возможно, оно уже закрыто или было удалено.</Text>
|
||||
</View>
|
||||
) : (detail.error || catalog.error) ? (
|
||||
<ErrorNotice error={detail.error ?? catalog.error} retry={() => { void detail.refetch(); void catalog.refetch(); }} />
|
||||
) : notification ? (
|
||||
<>
|
||||
{closed && <Text style={styles.error}>Уведомление больше не актуально. Действия недоступны.</Text>}
|
||||
{details?.deadline ? (
|
||||
<View style={local.deadline}>
|
||||
<Feather name="clock" size={16} color={colors.warning} />
|
||||
<Text style={styles.text}>Срок: {new Date(details.deadline).toLocaleString("ru-RU")}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<Text accessibilityRole="header" style={styles.title}>{details?.details_header ?? notification.header}</Text>
|
||||
{details?.details_text || notification.text ? <Text style={styles.text}>{details?.details_text ?? notification.text}</Text> : null}
|
||||
{price ? (
|
||||
<View style={local.priceRow}>
|
||||
<Text style={local.price}>{price}</Text>
|
||||
{oldPrice ? <Text style={local.oldPrice}>{oldPrice}</Text> : null}
|
||||
</View>
|
||||
) : null}
|
||||
{details?.todo_header ? <Text style={styles.heading}>{details.todo_header}</Text> : null}
|
||||
{details?.todo_plan?.map((step) => (
|
||||
<View key={`${step.number}-${step.text}`} style={local.step}>
|
||||
<View style={local.stepNumber}><Text style={local.stepNumberText}>{step.number}</Text></View>
|
||||
<Text style={[styles.text, { flex: 1 }]}>{step.text}</Text>
|
||||
</View>
|
||||
))}
|
||||
{details?.documents?.length ? (
|
||||
<View style={local.block}>
|
||||
<Text style={styles.heading}>Документы</Text>
|
||||
{details.documents.map((document) => (
|
||||
<Pressable key={document.document_id} onPress={() => void download(document.document_id)} style={local.file}>
|
||||
<Feather name="file-text" size={20} color={colors.info} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.text}>{document.title}</Text>
|
||||
<Text style={styles.muted}>{formatBytes(document.size_bytes)}</Text>
|
||||
</View>
|
||||
<Feather name="download" size={18} color={colors.foreground} />
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{canUpload ? (
|
||||
<View style={local.block}>
|
||||
<Text style={styles.heading}>Приложить документы</Text>
|
||||
<Text style={styles.muted}>Черновики сохраняются, пока вы не отправите или не удалите их.</Text>
|
||||
{drafts.isLoading && <Loading />}
|
||||
{pending.map((draft) => (
|
||||
<DraftRow
|
||||
key={draft.draft_id}
|
||||
draft={draft}
|
||||
disabled={removeDraft.isPending || closed}
|
||||
onRemove={() => removeDraft.mutate(draft.draft_id)}
|
||||
/>
|
||||
))}
|
||||
<Button title="Добавить файлы" secondary disabled={closed || pending.length >= 10} onPress={() => void chooseFile()} />
|
||||
</View>
|
||||
) : null}
|
||||
<View style={local.buttons}>
|
||||
{type?.button_primary ? (
|
||||
<Button
|
||||
title={type.button_primary.label}
|
||||
disabled={closed || pressButton.isPending || (type.button_primary.code === "send_docs" && !pending.some((draft) => draft.scan_status === "clean"))}
|
||||
onPress={() => pressButton.mutate(type.button_primary!)}
|
||||
/>
|
||||
) : null}
|
||||
{type?.button_secondary ? (
|
||||
<Button
|
||||
secondary
|
||||
title={type.button_secondary.label}
|
||||
disabled={closed || pressButton.isPending}
|
||||
onPress={() => pressButton.mutate(type.button_secondary!)}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
{error && <ErrorNotice error={error} />}
|
||||
</>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailHeader({ title, onBack }: { title: string; onBack: () => void }) {
|
||||
return (
|
||||
<View style={local.header}>
|
||||
<Pressable accessibilityLabel="Назад" accessibilityRole="button" onPress={onBack} style={local.back}>
|
||||
<Feather name="arrow-left" size={20} color={colors.foreground} />
|
||||
</Pressable>
|
||||
<Text numberOfLines={1} style={[styles.heading, { flex: 1 }]}>{title}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function DraftRow({ draft, disabled, onRemove }: { draft: UploadDraft; disabled: boolean; onRemove: () => void }) {
|
||||
const title = draft.title;
|
||||
const status = {
|
||||
pending: "Проверяется",
|
||||
clean: "Готов к отправке",
|
||||
infected: "Файл отклонён",
|
||||
failed: "Ошибка проверки",
|
||||
}[draft.scan_status];
|
||||
return (
|
||||
<View style={local.file}>
|
||||
<Feather name={draft.scan_status === "clean" ? "check-circle" : "file"} size={20} color={draft.scan_status === "clean" ? colors.success : colors.warning} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.text}>{title}</Text>
|
||||
<Text style={styles.muted}>{status} · {formatBytes(draft.size_bytes)}</Text>
|
||||
</View>
|
||||
<Pressable disabled={disabled} accessibilityLabel={`Удалить ${title}`} onPress={onRemove}>
|
||||
<Feather name="trash-2" size={18} color={colors.destructive} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} КБ`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
|
||||
}
|
||||
|
||||
const local = StyleSheet.create({
|
||||
header: { flexDirection: "row", alignItems: "center", gap: spacing.md, padding: spacing.lg, borderBottomWidth: 1, borderBottomColor: colors.border },
|
||||
back: { width: 36, height: 36, borderRadius: radii.full, alignItems: "center", justifyContent: "center" },
|
||||
content: { padding: spacing.lg, gap: spacing.md, paddingBottom: 40 },
|
||||
center: { flex: 1, justifyContent: "center", gap: spacing.md, padding: spacing.lg },
|
||||
empty: { alignItems: "center", gap: spacing.sm, paddingVertical: 48 },
|
||||
deadline: { flexDirection: "row", alignItems: "center", gap: spacing.sm, borderRadius: radii.md, backgroundColor: "#fff7df", padding: spacing.md },
|
||||
priceRow: { flexDirection: "row", alignItems: "baseline", gap: spacing.sm },
|
||||
price: { fontSize: 22, fontWeight: "700", color: colors.foreground },
|
||||
oldPrice: { fontSize: 14, color: colors.mutedForeground, textDecorationLine: "line-through" },
|
||||
step: { flexDirection: "row", alignItems: "flex-start", gap: spacing.md },
|
||||
stepNumber: { width: 28, height: 28, borderRadius: radii.full, alignItems: "center", justifyContent: "center", backgroundColor: colors.primary },
|
||||
stepNumberText: { color: colors.primaryForeground, fontSize: 13, fontWeight: "700" },
|
||||
block: { gap: spacing.sm, paddingVertical: spacing.sm },
|
||||
file: { flexDirection: "row", alignItems: "center", gap: spacing.md, borderWidth: 1, borderColor: colors.border, borderRadius: radii.md, padding: spacing.md, backgroundColor: colors.card },
|
||||
buttons: { gap: spacing.sm, paddingTop: spacing.sm },
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { useApp } from "../../src/app-context";
|
||||
import { GuestAuthGate } from "../../src/components/GuestAuthGate";
|
||||
import { NotificationCard } from "../../src/components/NotificationCard";
|
||||
import { ScreenShell } from "../../src/components/ScreenShell";
|
||||
import { useNotificationAction } from "../../src/notification-actions";
|
||||
import { notificationApi, notificationKeys } from "../../src/notification-api";
|
||||
import { typeMap } from "../../src/notification-presenter";
|
||||
import { colors, radii, spacing } from "../../src/theme";
|
||||
import { ErrorNotice, Loading, styles } from "../../src/ui";
|
||||
|
||||
export default function NotificationCenterScreen() {
|
||||
const { authStatus } = useApp();
|
||||
const authenticated = authStatus === "authenticated";
|
||||
const router = useRouter();
|
||||
const [actionError, setActionError] = useState<unknown>();
|
||||
const catalog = useQuery({
|
||||
queryKey: notificationKeys.catalog,
|
||||
queryFn: notificationApi.catalog,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
const notifications = useQuery({
|
||||
queryKey: notificationKeys.center,
|
||||
queryFn: () => notificationApi.list("center"),
|
||||
enabled: authenticated,
|
||||
});
|
||||
const byCode = useMemo(() => typeMap(catalog.data ?? []), [catalog.data]);
|
||||
const action = useNotificationAction({
|
||||
authenticated,
|
||||
onError: setActionError,
|
||||
requireAuth: () => router.replace({ pathname: "/", params: { authorize: "1" } }),
|
||||
});
|
||||
|
||||
return (
|
||||
<ScreenShell>
|
||||
<View style={local.header}>
|
||||
<Pressable accessibilityLabel="Назад" accessibilityRole="button" onPress={() => router.back()} style={local.back}>
|
||||
<Feather name="arrow-left" size={20} color={colors.foreground} />
|
||||
</Pressable>
|
||||
<Text accessibilityRole="header" style={styles.title}>Центр уведомлений</Text>
|
||||
</View>
|
||||
|
||||
{!authenticated ? (
|
||||
<GuestAuthGate
|
||||
icon="bell"
|
||||
title="Уведомления доступны после входа"
|
||||
description="Авторизуйтесь, чтобы видеть важные напоминания, документы и статусы услуг."
|
||||
/>
|
||||
) : (
|
||||
<ScrollView contentContainerStyle={local.content}>
|
||||
{(notifications.isLoading || catalog.isLoading) && <Loading />}
|
||||
{(notifications.error || catalog.error) && (
|
||||
<ErrorNotice
|
||||
error={notifications.error ?? catalog.error}
|
||||
retry={() => { void notifications.refetch(); void catalog.refetch(); }}
|
||||
/>
|
||||
)}
|
||||
{!notifications.isLoading && !notifications.error && notifications.data?.length === 0 && (
|
||||
<View style={local.empty}>
|
||||
<Feather name="check-circle" size={32} color={colors.success} />
|
||||
<Text style={styles.heading}>Новых уведомлений нет</Text>
|
||||
<Text style={styles.muted}>Здесь появятся важные сообщения и задачи.</Text>
|
||||
</View>
|
||||
)}
|
||||
{notifications.data?.map((item) => (
|
||||
<NotificationCard
|
||||
key={item.id}
|
||||
compact
|
||||
item={item}
|
||||
type={byCode.get(item.notification_type)}
|
||||
onCta={() => void action(item, byCode.get(item.notification_type))}
|
||||
/>
|
||||
))}
|
||||
{Boolean(actionError) && <ErrorNotice error={actionError} />}
|
||||
</ScrollView>
|
||||
)}
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const local = StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.md,
|
||||
padding: spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
back: { width: 36, height: 36, borderRadius: radii.full, alignItems: "center", justifyContent: "center" },
|
||||
content: { padding: spacing.lg, gap: spacing.md, paddingBottom: 40 },
|
||||
empty: { alignItems: "center", gap: spacing.sm, paddingVertical: 48 },
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, useRouter } from "expo-router";
|
||||
import React, { useState } from "react";
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { useApp } from "../src/app-context";
|
||||
import { AccordionSection } from "../src/components/AccordionSection";
|
||||
import { GuestAuthGate } from "../src/components/GuestAuthGate";
|
||||
import { ScreenShell } from "../src/components/ScreenShell";
|
||||
import { isProduction } from "../src/config";
|
||||
import { downloadAndOpen } from "../src/native-files";
|
||||
import { profileApi } from "../src/services";
|
||||
import { Button, ErrorNotice, Loading, styles } from "../src/ui";
|
||||
import { colors, radii, spacing } from "../src/theme";
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const app = useApp();
|
||||
const router = useRouter();
|
||||
const enabled = app.authStatus === "authenticated";
|
||||
const profile = useQuery({ queryKey: ["profile"], queryFn: profileApi.me, enabled });
|
||||
const documents = useQuery({ queryKey: ["documents"], queryFn: profileApi.documents, enabled });
|
||||
const [downloadError, setDownloadError] = useState<unknown>();
|
||||
|
||||
const download = async (id: string) => {
|
||||
try {
|
||||
const result = await profileApi.documentUrl(id);
|
||||
const name = documents.data?.items.find((item) => item.document_id === id)?.name;
|
||||
await downloadAndOpen(result.download_url, name ?? "document");
|
||||
} catch (error) { setDownloadError(error); }
|
||||
};
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<ScreenShell>
|
||||
<View style={stylesLocal.topBar}>
|
||||
<Pressable accessibilityRole="button" accessibilityLabel="Назад" onPress={() => router.back()} style={({ pressed }) => [stylesLocal.backButton, pressed && stylesLocal.pressed]}>
|
||||
<Feather name="arrow-left" size={20} color={colors.foreground} />
|
||||
</Pressable>
|
||||
<Text accessibilityRole="header" style={styles.title}>Профиль</Text>
|
||||
</View>
|
||||
<GuestAuthGate
|
||||
icon="user"
|
||||
title="Профиль доступен после входа"
|
||||
description="Авторизуйтесь, чтобы видеть личные данные и документы компании."
|
||||
/>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const personal = profile.data?.profile.personal_data;
|
||||
const fullName = personal?.full_name ?? "Пользователь";
|
||||
|
||||
return (
|
||||
<ScreenShell>
|
||||
<ScrollView contentContainerStyle={{ paddingBottom: spacing.xl }}>
|
||||
<View style={stylesLocal.topBar}>
|
||||
<Pressable accessibilityRole="button" accessibilityLabel="Назад" onPress={() => router.back()} style={({ pressed }) => [stylesLocal.backButton, pressed && stylesLocal.pressed]}>
|
||||
<Feather name="arrow-left" size={20} color={colors.foreground} />
|
||||
</Pressable>
|
||||
<Text accessibilityRole="header" style={styles.title}>Профиль</Text>
|
||||
</View>
|
||||
|
||||
<View style={stylesLocal.avatarBlock}>
|
||||
<View style={stylesLocal.avatar}>
|
||||
<Feather name="user" size={40} color={colors.primaryForeground} />
|
||||
</View>
|
||||
<Text style={stylesLocal.name}>{fullName}</Text>
|
||||
<Text style={styles.muted}>{personal?.citizenship ? `Гражданство: ${personal.citizenship}` : "Мигрант"}</Text>
|
||||
</View>
|
||||
|
||||
{(profile.isLoading || documents.isLoading) && <View style={{ padding: spacing.lg }}><Loading /></View>}
|
||||
{(profile.error || documents.error) && (
|
||||
<View style={{ paddingHorizontal: spacing.lg }}>
|
||||
<ErrorNotice error={profile.error ?? documents.error} retry={() => { void profile.refetch(); void documents.refetch(); }} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={{ paddingHorizontal: spacing.lg }}>
|
||||
<AccordionSection
|
||||
defaultOpen
|
||||
title="Личная информация"
|
||||
items={[
|
||||
{ title: "Имя", value: personal?.full_name ?? "Не указано", icon: "user" },
|
||||
{ title: "Телефон в РФ", value: personal?.russian_phone ?? "Не указано", icon: "phone" },
|
||||
{ title: "Зарубежный телефон", value: personal?.foreign_phone ?? "Не указано", icon: "phone" },
|
||||
{ title: "Email", value: personal?.email ?? "Не указано", icon: "mail" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<AccordionSection
|
||||
title="Готовые документы"
|
||||
items={documents.data?.items.length
|
||||
? documents.data.items.map((doc) => ({
|
||||
title: doc.name,
|
||||
value: new Date(doc.sent_at).toLocaleDateString("ru-RU"),
|
||||
icon: "file-text" as const,
|
||||
action: "download",
|
||||
}))
|
||||
: [{ title: "Документов пока нет", value: "Оператор отправит их в этот раздел", icon: "file-text" }]}
|
||||
onItemPress={(index) => {
|
||||
const doc = documents.data?.items[index];
|
||||
if (doc) void download(doc.document_id);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Text style={[styles.muted, { marginBottom: spacing.md }]}>
|
||||
Редактирование профиля недоступно. Для изменения данных напишите оператору.
|
||||
</Text>
|
||||
<Link href="/" style={styles.link}>Написать оператору</Link>
|
||||
|
||||
{!isProduction && (
|
||||
<Pressable onPress={() => router.push("/diagnostics")} style={({ pressed }) => [stylesLocal.menuItem, pressed && stylesLocal.pressed]}>
|
||||
<Text style={stylesLocal.menuText}>Диагностика (dev)</Text>
|
||||
<Feather name="chevron-right" size={20} color={colors.mutedForeground} />
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
<Button title="Выйти из аккаунта" danger onPress={() => void app.signOut()} />
|
||||
{Boolean(downloadError) && <ErrorNotice error={downloadError} />}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const stylesLocal = StyleSheet.create({
|
||||
topBar: { flexDirection: "row", alignItems: "center", gap: spacing.md, paddingHorizontal: spacing.lg, paddingVertical: spacing.lg },
|
||||
backButton: { width: 36, height: 36, borderRadius: radii.full, alignItems: "center", justifyContent: "center" },
|
||||
avatarBlock: { alignItems: "center", paddingVertical: spacing.lg, marginBottom: spacing.sm },
|
||||
avatar: { width: 80, height: 80, borderRadius: radii.full, backgroundColor: colors.primary, alignItems: "center", justifyContent: "center", marginBottom: spacing.md },
|
||||
name: { fontSize: 18, fontWeight: "500", color: colors.foreground, marginBottom: 4 },
|
||||
menuItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
backgroundColor: colors.card,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: radii.lg,
|
||||
padding: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
menuText: { fontSize: 14, fontWeight: "500", color: colors.foreground },
|
||||
pressed: { backgroundColor: colors.accent },
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 16.0.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"autoIncrement": true,
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
},
|
||||
"env": {
|
||||
"EXPO_PUBLIC_API_BASE_URL": "https://dev-chat.han0107.ru",
|
||||
"EXPO_PUBLIC_AUTH_BASE_URL": "https://dev-chat.han0107.ru/auth",
|
||||
"EXPO_PUBLIC_KEYCLOAK_REALM": "han-chat",
|
||||
"EXPO_PUBLIC_KEYCLOAK_CLIENT_ID": "han-chat-frontend",
|
||||
"EXPO_PUBLIC_APP_ENV": "production-like"
|
||||
}
|
||||
},
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
Generated
+8967
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "han-chat",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"go": "expo start --go",
|
||||
"web": "expo start --web",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "15.0.3",
|
||||
"@tanstack/react-query": "5.101.2",
|
||||
"expo": "~57.0.15",
|
||||
"expo-auth-session": "~57.0.8",
|
||||
"expo-constants": "~57.0.13",
|
||||
"expo-crypto": "~57.0.1",
|
||||
"expo-dev-client": "~57.0.0",
|
||||
"expo-document-picker": "~57.0.0",
|
||||
"expo-file-system": "~57.0.0",
|
||||
"expo-font": "~57.0.0",
|
||||
"expo-linking": "~57.0.7",
|
||||
"expo-router": "~57.0.15",
|
||||
"expo-secure-store": "~57.0.1",
|
||||
"expo-sharing": "~57.0.0",
|
||||
"expo-status-bar": "~57.0.1",
|
||||
"expo-web-browser": "~57.0.2",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-native": "0.86.2",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "4.26.0",
|
||||
"react-native-web": "~0.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.2.17",
|
||||
"@vitejs/plugin-react": "6.0.3",
|
||||
"jsdom": "29.1.1",
|
||||
"typescript": "~6.0.3",
|
||||
"vite": "8.1.4",
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import * as Crypto from "expo-crypto";
|
||||
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 const isMessageBlockedError = (error: unknown) =>
|
||||
error instanceof ApiError && error.code === "message_blocked";
|
||||
|
||||
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,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;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
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 { env, oidcIssuer } from "./config";
|
||||
import { buildOidcDeviceMetadata } from "./oidc-device";
|
||||
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 authorizationFlight = new SingleFlight<TokenSet>();
|
||||
const refreshFlight = new SingleFlight<TokenSet>();
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let authFailure: (() => void) | undefined;
|
||||
|
||||
class TokenEndpointError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
) {
|
||||
super(code);
|
||||
this.name = "TokenEndpointError";
|
||||
}
|
||||
}
|
||||
|
||||
const secureStore = {
|
||||
get: (key: string) => SecureStore.getItemAsync(key),
|
||||
set: (key: string, value: string) => SecureStore.setItemAsync(key, value),
|
||||
del: (key: string) => 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 TokenEndpointError(
|
||||
response.status,
|
||||
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("=", "");
|
||||
const deviceMetadata = await buildOidcDeviceMetadata(secureStore);
|
||||
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 offline_access",
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
state,
|
||||
nonce,
|
||||
...deviceMetadata,
|
||||
})}`;
|
||||
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 function completeAuthorization(code: string, state: string) {
|
||||
return authorizationFlight.run(() => completeAuthorizationOnce(code, state));
|
||||
}
|
||||
|
||||
async function completeAuthorizationOnce(code: string, state: string) {
|
||||
if (tokens) return tokens;
|
||||
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) {
|
||||
if (
|
||||
error instanceof TokenEndpointError
|
||||
&& (error.code === "invalid_grant" || error.code === "invalid_client")
|
||||
) {
|
||||
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,71 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import React, { useState } from "react";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { colors, radii, spacing } from "../theme";
|
||||
|
||||
type Item = { title: string; value?: string; action?: string; icon?: keyof typeof Feather.glyphMap };
|
||||
|
||||
export function AccordionSection({
|
||||
title,
|
||||
defaultOpen = false,
|
||||
items,
|
||||
onItemPress,
|
||||
}: {
|
||||
title: string;
|
||||
defaultOpen?: boolean;
|
||||
items: Item[];
|
||||
onItemPress?: (index: number) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => setOpen((value) => !value)}
|
||||
style={({ pressed }) => [styles.trigger, pressed && styles.pressed]}
|
||||
>
|
||||
<Text style={styles.triggerText}>{title}</Text>
|
||||
<Feather name={open ? "chevron-up" : "chevron-down"} size={20} color={colors.mutedForeground} />
|
||||
</Pressable>
|
||||
{open && (
|
||||
<View style={styles.content}>
|
||||
{items.map((item, index) => (
|
||||
<Pressable
|
||||
key={`${item.title}-${index}`}
|
||||
accessibilityRole={onItemPress ? "button" : "text"}
|
||||
disabled={!onItemPress}
|
||||
onPress={() => onItemPress?.(index)}
|
||||
style={({ pressed }) => [styles.item, index > 0 && styles.itemBorder, pressed && onItemPress && styles.pressed]}
|
||||
>
|
||||
{item.icon && (
|
||||
<View style={styles.iconWrap}>
|
||||
<Feather name={item.icon} size={18} color={colors.mutedForeground} />
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.itemBody}>
|
||||
<Text style={styles.itemTitle}>{item.title}</Text>
|
||||
{item.value ? <Text style={styles.itemValue}>{item.value}</Text> : null}
|
||||
</View>
|
||||
{item.action && <Feather name="chevron-right" size={20} color={colors.mutedForeground} />}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
section: { backgroundColor: colors.card, borderWidth: 1, borderColor: colors.border, borderRadius: radii.lg, overflow: "hidden", marginBottom: spacing.md },
|
||||
trigger: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: spacing.lg, paddingVertical: spacing.md },
|
||||
triggerText: { fontSize: 14, fontWeight: "500", color: colors.foreground },
|
||||
content: { borderTopWidth: 1, borderTopColor: colors.border },
|
||||
item: { flexDirection: "row", alignItems: "center", gap: spacing.md, padding: spacing.md },
|
||||
itemBorder: { borderTopWidth: 1, borderTopColor: colors.border },
|
||||
iconWrap: { width: 36, height: 36, borderRadius: radii.full, backgroundColor: colors.muted, alignItems: "center", justifyContent: "center" },
|
||||
itemBody: { flex: 1 },
|
||||
itemTitle: { fontSize: 14, fontWeight: "500", color: colors.foreground },
|
||||
itemValue: { fontSize: 12, color: colors.mutedForeground, marginTop: 2 },
|
||||
pressed: { backgroundColor: colors.accent },
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "expo-router";
|
||||
import React from "react";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { useApp } from "../app-context";
|
||||
import { notificationApi, notificationKeys } from "../notification-api";
|
||||
import { colors, radii, spacing } from "../theme";
|
||||
|
||||
export function AppHeader(_props: { guestLabel?: string | undefined }) {
|
||||
const { authStatus } = useApp();
|
||||
const authenticated = authStatus === "authenticated";
|
||||
const counter = useQuery({
|
||||
queryKey: notificationKeys.counter,
|
||||
queryFn: notificationApi.counter,
|
||||
enabled: authenticated,
|
||||
});
|
||||
const unread = counter.data?.unread_count ?? 0;
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
<Link href="/notifications" asChild>
|
||||
<Pressable accessibilityLabel="Уведомления" accessibilityRole="link" style={({ pressed }) => [styles.centerLink, pressed && styles.pressed]}>
|
||||
<View>
|
||||
<Feather name="bell" size={20} color={colors.foreground} />
|
||||
{authenticated && unread > 0 && (
|
||||
<View accessibilityLabel={`${unread} непрочитанных уведомлений`} style={styles.notificationBadge}>
|
||||
<Text style={styles.notificationBadgeText}>{unread > 99 ? "99+" : unread}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</Link>
|
||||
|
||||
<Link href="/profile" asChild>
|
||||
<Pressable accessibilityRole="link" accessibilityLabel="Личный кабинет" style={({ pressed }) => [styles.avatar, pressed && styles.pressed]}>
|
||||
<Feather name="user" size={20} color={colors.mutedForeground} />
|
||||
<View style={styles.dot} />
|
||||
</Pressable>
|
||||
</Link>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
backgroundColor: colors.background,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
centerLink: { width: 40, height: 40, alignItems: "center", justifyContent: "center" },
|
||||
notificationBadge: {
|
||||
position: "absolute",
|
||||
top: -9,
|
||||
right: -12,
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
borderRadius: radii.full,
|
||||
paddingHorizontal: 4,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: colors.destructive,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.background,
|
||||
},
|
||||
notificationBadgeText: { color: colors.primaryForeground, fontSize: 9, fontWeight: "700" },
|
||||
avatar: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: radii.full,
|
||||
backgroundColor: colors.muted,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
dot: {
|
||||
position: "absolute",
|
||||
top: 2,
|
||||
right: 2,
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: radii.full,
|
||||
backgroundColor: colors.primary,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.background,
|
||||
},
|
||||
pressed: { opacity: 0.7 },
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { Animated, StyleSheet, Text, View } from "react-native";
|
||||
import { colors, radii, spacing } from "../theme";
|
||||
|
||||
export function AuthLoadingView() {
|
||||
const pulse = useRef(new Animated.Value(0)).current;
|
||||
const dots = useRef([
|
||||
new Animated.Value(0),
|
||||
new Animated.Value(0),
|
||||
new Animated.Value(0),
|
||||
]).current;
|
||||
|
||||
useEffect(() => {
|
||||
const pulseAnimation = Animated.loop(
|
||||
Animated.timing(pulse, {
|
||||
toValue: 1,
|
||||
duration: 1800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
);
|
||||
const dotAnimations = dots.map((dot, index) => Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.delay(index * 200),
|
||||
Animated.timing(dot, { toValue: -8, duration: 280, useNativeDriver: true }),
|
||||
Animated.timing(dot, { toValue: 0, duration: 280, useNativeDriver: true }),
|
||||
Animated.delay((2 - index) * 200 + 240),
|
||||
]),
|
||||
));
|
||||
|
||||
pulseAnimation.start();
|
||||
dotAnimations.forEach((animation) => animation.start());
|
||||
return () => {
|
||||
pulseAnimation.stop();
|
||||
dotAnimations.forEach((animation) => animation.stop());
|
||||
};
|
||||
}, [dots, pulse]);
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<View style={styles.center}>
|
||||
<View style={styles.logoArea}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.pulseRing,
|
||||
{
|
||||
opacity: pulse.interpolate({ inputRange: [0, 1], outputRange: [0.35, 0] }),
|
||||
transform: [{ scale: pulse.interpolate({ inputRange: [0, 1], outputRange: [1, 1.45] }) }],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<View style={styles.innerRing} />
|
||||
<View style={styles.logo}>
|
||||
<Text style={styles.logoText}>HAN</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.dots}>
|
||||
{dots.map((dot, index) => (
|
||||
<Animated.View key={index} style={[styles.dot, { transform: [{ translateY: dot }] }]} />
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Text accessibilityRole="header" style={styles.title}>Выполняем вход</Text>
|
||||
<Text style={styles.subtitle}>Проверяем данные...</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.footer}>
|
||||
HAN — ваш персональный консультант по вопросам миграции в России
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
minHeight: 560,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 48,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
center: { alignItems: "center" },
|
||||
logoArea: {
|
||||
width: 116,
|
||||
height: 116,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
pulseRing: {
|
||||
position: "absolute",
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.primary,
|
||||
borderRadius: radii.full,
|
||||
},
|
||||
innerRing: {
|
||||
position: "absolute",
|
||||
width: 96,
|
||||
height: 96,
|
||||
borderWidth: 2,
|
||||
borderColor: "rgba(3, 2, 19, 0.12)",
|
||||
borderRadius: radii.full,
|
||||
},
|
||||
logo: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: radii.full,
|
||||
backgroundColor: colors.primary,
|
||||
shadowColor: colors.primary,
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 14,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
},
|
||||
logoText: { color: colors.primaryForeground, fontSize: 20, fontWeight: "700", letterSpacing: 1.5 },
|
||||
dots: { flexDirection: "row", gap: 6, height: 24, alignItems: "center", marginBottom: 28 },
|
||||
dot: { width: 8, height: 8, borderRadius: radii.full, backgroundColor: colors.primary },
|
||||
title: { color: colors.foreground, fontSize: 20, fontWeight: "600", marginBottom: spacing.sm },
|
||||
subtitle: { color: colors.mutedForeground, fontSize: 14 },
|
||||
footer: {
|
||||
position: "absolute",
|
||||
right: 32,
|
||||
bottom: 48,
|
||||
left: 32,
|
||||
color: colors.mutedForeground,
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
textAlign: "center",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import React, { useState } from "react";
|
||||
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
|
||||
import { messageFitsLimit } from "../message-text";
|
||||
import { colors, radii, spacing } from "../theme";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
onSubmit: () => void;
|
||||
disabled?: boolean;
|
||||
sending?: boolean;
|
||||
onAttach?: () => void;
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
inputLabel?: string;
|
||||
maxLength?: number;
|
||||
};
|
||||
|
||||
export function ChatInputBar({
|
||||
value,
|
||||
onChangeText,
|
||||
onSubmit,
|
||||
disabled,
|
||||
sending,
|
||||
onAttach,
|
||||
placeholder = "Напишите ваш вопрос...",
|
||||
hint = "Напишите сообщение или прикрепите документ",
|
||||
inputLabel = "Сообщение",
|
||||
maxLength,
|
||||
}: Props) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const withinLimit = maxLength === undefined || messageFitsLimit(value, maxLength);
|
||||
const canSend = Boolean(value.trim()) && withinLimit && !disabled && !sending;
|
||||
const nearLimit = maxLength !== undefined && value.length >= maxLength * 0.9;
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<View style={[styles.inputBox, focused && styles.inputBoxFocused]}>
|
||||
<TextInput
|
||||
accessibilityLabel={inputLabel}
|
||||
editable={!disabled && !sending}
|
||||
multiline
|
||||
onBlur={() => { if (!value.trim()) setFocused(false); }}
|
||||
onChangeText={onChangeText}
|
||||
onFocus={() => setFocused(true)}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
returnKeyType="send"
|
||||
style={[styles.input, focused && styles.inputExpanded]}
|
||||
value={value}
|
||||
/>
|
||||
|
||||
<View style={styles.actions}>
|
||||
{onAttach && (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Прикрепить файл"
|
||||
disabled={disabled || sending}
|
||||
onPress={onAttach}
|
||||
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
|
||||
>
|
||||
<Feather name="paperclip" size={20} color={colors.mutedForeground} />
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Отправить"
|
||||
disabled={!canSend}
|
||||
onPress={onSubmit}
|
||||
style={({ pressed }) => [
|
||||
styles.sendButton,
|
||||
!canSend && styles.sendButtonDisabled,
|
||||
pressed && canSend && styles.pressed,
|
||||
]}
|
||||
>
|
||||
<Feather name="send" size={16} color={colors.primaryForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
{maxLength !== undefined ? (
|
||||
<Text
|
||||
accessibilityLiveRegion={withinLimit ? "none" : "polite"}
|
||||
style={[
|
||||
styles.counter,
|
||||
nearLimit && styles.counterWarning,
|
||||
!withinLimit && styles.counterError,
|
||||
]}
|
||||
>
|
||||
{value.length}/{maxLength}
|
||||
</Text>
|
||||
) : null}
|
||||
{hint ? <Text style={styles.hint}>{hint}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: { paddingHorizontal: spacing.lg, paddingTop: spacing.sm, paddingBottom: spacing.lg, borderTopWidth: 1, borderTopColor: colors.border, backgroundColor: colors.background },
|
||||
inputBox: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-end",
|
||||
gap: spacing.sm,
|
||||
backgroundColor: colors.card,
|
||||
borderWidth: 2,
|
||||
borderColor: "rgba(3, 2, 19, 0.2)",
|
||||
borderRadius: radii.xl,
|
||||
padding: 10,
|
||||
},
|
||||
inputBoxFocused: { borderColor: colors.primary },
|
||||
input: {
|
||||
flex: 1,
|
||||
minHeight: 80,
|
||||
maxHeight: 256,
|
||||
fontSize: 16,
|
||||
color: colors.foreground,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.sm,
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
inputExpanded: { minHeight: 144 },
|
||||
actions: { alignItems: "center", justifyContent: "flex-end", gap: spacing.sm },
|
||||
iconButton: { width: 36, height: 36, borderRadius: radii.full, alignItems: "center", justifyContent: "center" },
|
||||
sendButton: { width: 36, height: 36, borderRadius: radii.full, backgroundColor: colors.primary, alignItems: "center", justifyContent: "center" },
|
||||
sendButtonDisabled: { opacity: 0.4 },
|
||||
counter: { fontSize: 12, color: colors.mutedForeground, textAlign: "right", marginTop: spacing.xs },
|
||||
counterWarning: { color: colors.warning },
|
||||
counterError: { color: colors.destructive },
|
||||
hint: { fontSize: 12, color: colors.mutedForeground, textAlign: "center", marginTop: spacing.sm },
|
||||
pressed: { opacity: 0.7 },
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { useRouter } from "expo-router";
|
||||
import React from "react";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { colors, radii, spacing } from "../theme";
|
||||
|
||||
export function ChatScreenHeader({ title = "HAN Помощник", subtitle = "Онлайн" }: { title?: string; subtitle?: string }) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Назад"
|
||||
onPress={() => router.replace("/")}
|
||||
style={({ pressed }) => [styles.backButton, pressed && styles.pressed]}
|
||||
>
|
||||
<Feather name="arrow-left" size={20} color={colors.foreground} />
|
||||
</Pressable>
|
||||
<View style={styles.info}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Text style={styles.subtitle}>{subtitle}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
backButton: { width: 36, height: 36, borderRadius: radii.full, alignItems: "center", justifyContent: "center" },
|
||||
info: { flex: 1 },
|
||||
title: { fontSize: 16, fontWeight: "500", color: colors.foreground },
|
||||
subtitle: { fontSize: 12, color: colors.mutedForeground },
|
||||
pressed: { backgroundColor: colors.muted },
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Linking, Pressable, Text, View } from "react-native";
|
||||
import type { PublicConfig } from "../types";
|
||||
import { Button, styles } from "../ui";
|
||||
import { colors, radii, spacing } from "../theme";
|
||||
|
||||
type ConsentKey = "personal_data" | "user_agreement" | "marketing";
|
||||
|
||||
type ConsentLink = {
|
||||
label: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type ConsentBlock = {
|
||||
key: ConsentKey;
|
||||
text: string;
|
||||
required: boolean;
|
||||
links: ConsentLink[];
|
||||
};
|
||||
|
||||
type ConsentConfigItem = PublicConfig["consents"][string];
|
||||
|
||||
type Props = {
|
||||
consents: PublicConfig["consents"] | undefined;
|
||||
onAccept: (accepted: Record<ConsentKey, boolean>) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
const LABELS: Record<ConsentKey, {
|
||||
text: string;
|
||||
links: Array<{ label: string; readUrl: (item: ConsentConfigItem) => string | null | undefined }>;
|
||||
}> = {
|
||||
personal_data: {
|
||||
text: "Я ознакомлен с Политикой обработки персональных данных ООО «ХАН» и даю своё Согласие на обработку моих персональных данных",
|
||||
links: [
|
||||
{ label: "Согласие на обработку ПД", readUrl: (item) => item.document_url },
|
||||
{ label: "Политика обработки ПД", readUrl: (item) => item.privacy_policy_document_url },
|
||||
],
|
||||
},
|
||||
user_agreement: {
|
||||
text: "Я прочитал и соглашаюсь с Пользовательским соглашением",
|
||||
links: [{ label: "Пользовательское соглашение", readUrl: (item) => item.document_url }],
|
||||
},
|
||||
marketing: {
|
||||
text: "Я даю своё согласие на получение рекламных и маркетинговых коммуникаций",
|
||||
links: [{ label: "Условия получения коммуникаций", readUrl: (item) => item.document_url }],
|
||||
},
|
||||
};
|
||||
|
||||
export function ConsentModal({ consents, onAccept, onCancel }: Props) {
|
||||
const blocks = useMemo(() => buildBlocks(consents), [consents]);
|
||||
const [checked, setChecked] = useState<Record<ConsentKey, boolean>>({
|
||||
personal_data: false,
|
||||
user_agreement: false,
|
||||
marketing: false,
|
||||
});
|
||||
|
||||
const requiredDone = blocks.length > 0
|
||||
&& blocks.filter((block) => block.required).every((block) => checked[block.key]);
|
||||
|
||||
const toggle = (key: ConsentKey) => {
|
||||
setChecked((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<View accessibilityViewIsModal style={styles.modalBackdrop}>
|
||||
<View style={[styles.modal, { maxWidth: 400, gap: spacing.lg }]}>
|
||||
<View style={{ gap: spacing.sm }}>
|
||||
<View style={consentStyles.iconWrap}>
|
||||
<Text style={consentStyles.iconGlyph}>✓</Text>
|
||||
</View>
|
||||
<Text accessibilityRole="header" style={[styles.title, { fontSize: 22 }]}>
|
||||
Перед началом работы
|
||||
</Text>
|
||||
<Text style={styles.muted}>
|
||||
Для использования приложения ознакомьтесь со следующими документами и предоставьте необходимые согласия
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={{ gap: spacing.md }}>
|
||||
{blocks.map((block) => {
|
||||
const isChecked = checked[block.key];
|
||||
return (
|
||||
<Pressable
|
||||
key={block.key}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityState={{ checked: isChecked }}
|
||||
onPress={() => toggle(block.key)}
|
||||
style={[
|
||||
consentStyles.card,
|
||||
isChecked ? consentStyles.cardChecked : null,
|
||||
]}
|
||||
>
|
||||
<View style={consentStyles.cardRow}>
|
||||
<View
|
||||
style={[
|
||||
consentStyles.checkbox,
|
||||
isChecked ? consentStyles.checkboxChecked : null,
|
||||
]}
|
||||
>
|
||||
{isChecked ? <Text style={consentStyles.checkMark}>✓</Text> : null}
|
||||
</View>
|
||||
<View style={{ flex: 1, gap: spacing.sm }}>
|
||||
<Text style={styles.text}>
|
||||
{block.text}
|
||||
{block.required ? <Text style={{ color: colors.destructive }}> *</Text> : null}
|
||||
</Text>
|
||||
{block.links.length > 0 ? (
|
||||
<View style={consentStyles.links}>
|
||||
{block.links.map((link) => (
|
||||
<Pressable
|
||||
key={link.url}
|
||||
accessibilityRole="link"
|
||||
onPress={(event) => {
|
||||
event?.stopPropagation?.();
|
||||
void Linking.openURL(link.url);
|
||||
}}
|
||||
>
|
||||
<Text style={consentStyles.link}>↗ {link.label}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
<Text style={styles.muted}>
|
||||
<Text style={{ color: colors.destructive }}>*</Text>
|
||||
{" — обязательные согласия"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={{ gap: spacing.sm }}>
|
||||
<Button
|
||||
title="Продолжить"
|
||||
disabled={!requiredDone}
|
||||
onPress={() => onAccept(checked)}
|
||||
/>
|
||||
<Button title="Отмена" secondary onPress={onCancel} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function buildBlocks(consents: PublicConfig["consents"] | undefined): ConsentBlock[] {
|
||||
return (Object.keys(LABELS) as ConsentKey[]).flatMap((key) => {
|
||||
const item = consents?.[key];
|
||||
if (!item) return [];
|
||||
const meta = LABELS[key];
|
||||
const links = meta.links.flatMap((link) => {
|
||||
const url = link.readUrl(item);
|
||||
return url ? [{ label: link.label, url }] : [];
|
||||
});
|
||||
return [{
|
||||
key,
|
||||
text: meta.text,
|
||||
required: item.required,
|
||||
links,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
const consentStyles = {
|
||||
iconWrap: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 16,
|
||||
backgroundColor: "rgba(3, 2, 19, 0.08)",
|
||||
alignItems: "center" as const,
|
||||
justifyContent: "center" as const,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
iconGlyph: {
|
||||
color: colors.primary,
|
||||
fontSize: 22,
|
||||
fontWeight: "700" as const,
|
||||
},
|
||||
card: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: radii.xl,
|
||||
backgroundColor: colors.card,
|
||||
padding: spacing.md,
|
||||
},
|
||||
cardChecked: {
|
||||
borderColor: "rgba(3, 2, 19, 0.3)",
|
||||
backgroundColor: "rgba(3, 2, 19, 0.04)",
|
||||
},
|
||||
cardRow: {
|
||||
flexDirection: "row" as const,
|
||||
alignItems: "flex-start" as const,
|
||||
gap: spacing.md,
|
||||
},
|
||||
checkbox: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: radii.sm,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.background,
|
||||
alignItems: "center" as const,
|
||||
justifyContent: "center" as const,
|
||||
marginTop: 2,
|
||||
},
|
||||
checkboxChecked: {
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
checkMark: {
|
||||
color: colors.primaryForeground,
|
||||
fontSize: 12,
|
||||
fontWeight: "700" as const,
|
||||
lineHeight: 14,
|
||||
},
|
||||
links: {
|
||||
flexDirection: "row" as const,
|
||||
flexWrap: "wrap" as const,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
link: {
|
||||
color: colors.primary,
|
||||
fontSize: 12,
|
||||
textDecorationLine: "underline" as const,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { useRouter } from "expo-router";
|
||||
import React from "react";
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
import { colors, radii, spacing } from "../theme";
|
||||
import { Button, styles } from "../ui";
|
||||
|
||||
export function GuestAuthGate({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
icon: React.ComponentProps<typeof Feather>["name"];
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<View style={local.gate}>
|
||||
<View style={local.icon}>
|
||||
<Feather name={icon} size={34} color={colors.primaryForeground} />
|
||||
</View>
|
||||
<Text accessibilityRole="header" style={[styles.title, local.centerText]}>{title}</Text>
|
||||
<Text style={[styles.text, local.centerText]}>{description}</Text>
|
||||
<Button
|
||||
title="Авторизоваться"
|
||||
onPress={() => router.replace({ pathname: "/", params: { authorize: "1" } })}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const local = StyleSheet.create({
|
||||
gate: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: spacing.md,
|
||||
padding: spacing.xl,
|
||||
},
|
||||
icon: {
|
||||
width: 68,
|
||||
height: 68,
|
||||
borderRadius: radii.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
centerText: { textAlign: "center" },
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
import { colors, radii, spacing } from "../theme";
|
||||
|
||||
export function HanLogo({ subtitle }: { subtitle?: string }) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.logoBox}>
|
||||
<Text style={styles.logoText}>HAN</Text>
|
||||
</View>
|
||||
<View style={styles.textBlock}>
|
||||
<Text accessibilityRole="header" style={styles.title}>Привет! Я HAN</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{subtitle ?? "Помощник по документам и жизни в России"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flexDirection: "row", alignItems: "center", gap: spacing.md, paddingHorizontal: spacing.lg, paddingVertical: spacing.md },
|
||||
logoBox: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: radii.xl,
|
||||
backgroundColor: colors.primary,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
logoText: { color: colors.primaryForeground, fontSize: 14, fontWeight: "700", letterSpacing: 0.5 },
|
||||
textBlock: { flex: 1 },
|
||||
title: { fontSize: 16, fontWeight: "500", color: colors.foreground, marginBottom: 2 },
|
||||
subtitle: { fontSize: 12, color: colors.mutedForeground, lineHeight: 16 },
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Image, Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { downloadAndOpen } from "../native-files";
|
||||
import type { Attachment, Message } from "../types";
|
||||
import { colors, radii, spacing } from "../theme";
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
accepted: "Принято",
|
||||
delivered: "Доставлено",
|
||||
failed: "Ошибка",
|
||||
rejected: "Отклонено",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
message: Message;
|
||||
getAttachmentUrl?: ((attachmentId: string) => Promise<string>) | undefined;
|
||||
onAttachmentError?: ((error: unknown) => void) | undefined;
|
||||
};
|
||||
|
||||
export function MessageBubble({ message, getAttachmentUrl, onAttachmentError }: Props) {
|
||||
const isClient = message.sender_type === "client";
|
||||
const time = new Date(message.created_at).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
|
||||
|
||||
return (
|
||||
<View style={[styles.row, isClient ? styles.rowClient : styles.rowCompany]}>
|
||||
<View style={[styles.bubble, isClient ? styles.bubbleClient : styles.bubbleCompany]}>
|
||||
{message.content_kind === "text" ? (
|
||||
<Text style={[styles.text, isClient && styles.textClient]}>{message.text}</Text>
|
||||
) : message.attachments.length ? (
|
||||
<View style={styles.attachments}>
|
||||
{message.attachments.map((attachment) => (
|
||||
<AttachmentPreview
|
||||
key={attachment.attachment_id}
|
||||
attachment={attachment}
|
||||
getUrl={getAttachmentUrl}
|
||||
isClient={isClient}
|
||||
onError={onAttachmentError}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.fileFallback}>
|
||||
<Feather name="file" size={28} color={isClient ? colors.primaryForeground : colors.primary} />
|
||||
</View>
|
||||
)}
|
||||
<Text style={[styles.time, isClient ? styles.timeClient : styles.timeMuted]}>
|
||||
{time}
|
||||
{isClient ? ` · ${statusLabel[message.delivery_status] ?? message.delivery_status}` : ""}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentPreview({ attachment, getUrl, isClient, onError }: {
|
||||
attachment: Attachment;
|
||||
getUrl?: ((attachmentId: string) => Promise<string>) | undefined;
|
||||
isClient: boolean;
|
||||
onError?: ((error: unknown) => void) | undefined;
|
||||
}) {
|
||||
const [previewUrl, setPreviewUrl] = useState<string>();
|
||||
const [previewFailed, setPreviewFailed] = useState(false);
|
||||
const isImage = attachment.mime_type.startsWith("image/");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isImage || !getUrl) return;
|
||||
let active = true;
|
||||
void getUrl(attachment.attachment_id)
|
||||
.then((url) => { if (active) setPreviewUrl(url); })
|
||||
.catch(() => { if (active) setPreviewFailed(true); });
|
||||
return () => { active = false; };
|
||||
}, [attachment.attachment_id, getUrl, isImage]);
|
||||
|
||||
const open = async () => {
|
||||
if (!getUrl) return;
|
||||
try {
|
||||
const url = previewUrl ?? await getUrl(attachment.attachment_id);
|
||||
await downloadAndOpen(url, attachment.file_name);
|
||||
} catch (error) {
|
||||
onError?.(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Скачать файл ${attachment.file_name}`}
|
||||
onPress={() => void open()}
|
||||
style={({ pressed }) => [styles.attachmentButton, pressed && styles.pressed]}
|
||||
>
|
||||
{isImage && !previewFailed ? (
|
||||
previewUrl ? (
|
||||
<Image
|
||||
accessibilityLabel={attachment.file_name}
|
||||
resizeMode="cover"
|
||||
source={{ uri: previewUrl }}
|
||||
style={styles.previewImage}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.previewLoading}>
|
||||
<ActivityIndicator color={isClient ? colors.primaryForeground : colors.primary} />
|
||||
</View>
|
||||
)
|
||||
) : (
|
||||
<View style={[styles.fileCard, isClient && styles.fileCardClient]}>
|
||||
<Feather name="file-text" size={30} color={isClient ? colors.primaryForeground : colors.primary} />
|
||||
<Text numberOfLines={2} style={[styles.fileName, isClient && styles.textClient]}>
|
||||
{attachment.file_name}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: { flexDirection: "row", marginBottom: spacing.lg },
|
||||
rowClient: { justifyContent: "flex-end" },
|
||||
rowCompany: { justifyContent: "flex-start" },
|
||||
bubble: { maxWidth: "75%", borderRadius: radii.xl, paddingHorizontal: spacing.lg, paddingVertical: 10 },
|
||||
bubbleClient: { backgroundColor: colors.primary },
|
||||
bubbleCompany: { backgroundColor: colors.muted },
|
||||
text: { fontSize: 14, color: colors.foreground, lineHeight: 20 },
|
||||
textClient: { color: colors.primaryForeground },
|
||||
time: { fontSize: 12, marginTop: 4 },
|
||||
timeClient: { color: "rgba(255,255,255,0.7)" },
|
||||
timeMuted: { color: colors.mutedForeground },
|
||||
attachments: { gap: spacing.sm },
|
||||
attachmentButton: { borderRadius: radii.lg, overflow: "hidden" },
|
||||
previewImage: { width: 190, height: 128, borderRadius: radii.lg, backgroundColor: colors.inputBackground },
|
||||
previewLoading: { width: 190, height: 128, alignItems: "center", justifyContent: "center" },
|
||||
fileCard: {
|
||||
width: 190,
|
||||
minHeight: 72,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.md,
|
||||
padding: spacing.md,
|
||||
borderRadius: radii.lg,
|
||||
backgroundColor: colors.card,
|
||||
},
|
||||
fileCardClient: { backgroundColor: "rgba(255,255,255,0.14)" },
|
||||
fileName: { flex: 1, fontSize: 13, lineHeight: 18, color: colors.foreground },
|
||||
fileFallback: { width: 72, height: 72, alignItems: "center", justifyContent: "center" },
|
||||
pressed: { opacity: 0.75 },
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import React from "react";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { formatNotificationPrice, notificationIcon, notificationPalette } from "../notification-presenter";
|
||||
import { colors, radii, spacing } from "../theme";
|
||||
import type { NotificationItem, NotificationType } from "../types";
|
||||
|
||||
export function NotificationCard({
|
||||
item,
|
||||
type,
|
||||
onCta,
|
||||
onHide,
|
||||
onNext,
|
||||
onPrevious,
|
||||
position,
|
||||
total,
|
||||
width,
|
||||
compact = false,
|
||||
disabled = false,
|
||||
}: {
|
||||
item: NotificationItem;
|
||||
type: NotificationType | undefined;
|
||||
onCta: () => void;
|
||||
onHide?: () => void;
|
||||
onNext?: () => void;
|
||||
onPrevious?: () => void;
|
||||
position?: number;
|
||||
total?: number;
|
||||
width?: number;
|
||||
compact?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const palette = notificationPalette(type?.color_token);
|
||||
const price = formatNotificationPrice(item.price);
|
||||
const oldPrice = formatNotificationPrice(item.old_price);
|
||||
const deadline = item.details?.deadline;
|
||||
|
||||
return (
|
||||
<View style={[
|
||||
local.card,
|
||||
compact && local.compact,
|
||||
width !== undefined && { width },
|
||||
{ backgroundColor: palette.background, borderColor: palette.border },
|
||||
]}>
|
||||
<View style={local.headerRow}>
|
||||
<View style={[local.labelBadge, { backgroundColor: palette.accentBackground }]}>
|
||||
<Text style={[local.label, { color: palette.accent }]}>{type?.label ?? "Уведомление"}</Text>
|
||||
{type?.countable && item.is_read === false && <View accessibilityLabel="Непрочитано" style={[local.unread, { backgroundColor: palette.accent }]} />}
|
||||
</View>
|
||||
<View style={local.controls}>
|
||||
{onPrevious && total && total > 1 ? (
|
||||
<Pressable accessibilityLabel="Предыдущее уведомление" accessibilityRole="button" hitSlop={8} onPress={onPrevious} style={local.controlButton}>
|
||||
<Feather name="chevron-left" size={16} color={colors.mutedForeground} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
{position !== undefined && total && total > 1 ? (
|
||||
<Text accessibilityLabel={`${position} из ${total}`} style={local.position}>{position}/{total}</Text>
|
||||
) : null}
|
||||
{onNext && total && total > 1 ? (
|
||||
<Pressable accessibilityLabel="Следующее уведомление" accessibilityRole="button" hitSlop={8} onPress={onNext} style={local.controlButton}>
|
||||
<Feather name="chevron-right" size={16} color={colors.mutedForeground} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
{onHide && (
|
||||
<Pressable accessibilityLabel="Скрыть уведомление" accessibilityRole="button" hitSlop={8} onPress={onHide} style={local.hideButton}>
|
||||
<Feather name="x" size={16} color={colors.mutedForeground} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={local.body}>
|
||||
<View style={[local.iconWrap, { backgroundColor: palette.accentBackground }]}>
|
||||
<Feather name={notificationIcon(type?.icon_code)} size={17} color={palette.accent} />
|
||||
</View>
|
||||
<View style={local.content}>
|
||||
<Text style={[local.title, { color: palette.foreground }]}>{item.header}</Text>
|
||||
{item.text ? <Text numberOfLines={compact ? 2 : 3} style={local.text}>{item.text}</Text> : null}
|
||||
{deadline ? (
|
||||
<View style={local.deadline}>
|
||||
<Feather name="clock" size={13} color={colors.mutedForeground} />
|
||||
<Text style={local.meta}>до {new Date(deadline).toLocaleDateString("ru-RU")}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{price ? (
|
||||
<View style={local.priceRow}>
|
||||
<Text style={[local.price, { color: palette.accent }]}>{price}</Text>
|
||||
{oldPrice ? <Text style={local.oldPrice}>{oldPrice}</Text> : null}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={disabled}
|
||||
onPress={onCta}
|
||||
style={({ pressed }) => [local.cta, pressed && local.pressed, disabled && local.disabled]}
|
||||
>
|
||||
<Text style={[local.ctaText, { color: palette.accent }]}>{type?.cta_text ?? "Подробнее →"}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const local = StyleSheet.create({
|
||||
card: {
|
||||
width: 326,
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.xl,
|
||||
overflow: "hidden",
|
||||
},
|
||||
compact: { width: "100%", minHeight: 0 },
|
||||
headerRow: { minHeight: 42, paddingHorizontal: 14, paddingTop: 10, paddingBottom: 6, flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
|
||||
labelBadge: { minHeight: 24, borderRadius: radii.full, paddingHorizontal: 10, flexDirection: "row", alignItems: "center", gap: 6, flexShrink: 1 },
|
||||
label: { fontSize: 11, fontWeight: "700", textTransform: "uppercase", letterSpacing: 0.5 },
|
||||
unread: { width: 8, height: 8, borderRadius: radii.full },
|
||||
controls: { flexDirection: "row", alignItems: "center" },
|
||||
controlButton: { width: 28, height: 28, alignItems: "center", justifyContent: "center" },
|
||||
hideButton: { width: 28, height: 28, marginLeft: 2, alignItems: "center", justifyContent: "center" },
|
||||
position: { minWidth: 32, textAlign: "center", fontSize: 11, color: colors.mutedForeground },
|
||||
body: { flexDirection: "row", alignItems: "flex-start", gap: spacing.md, paddingHorizontal: 14, paddingBottom: spacing.md },
|
||||
iconWrap: { width: 36, height: 36, borderRadius: radii.full, alignItems: "center", justifyContent: "center", flexShrink: 0 },
|
||||
content: { flex: 1, minWidth: 0 },
|
||||
title: { fontSize: 14, lineHeight: 19, fontWeight: "600", marginBottom: 2 },
|
||||
text: { fontSize: 12, lineHeight: 17, color: colors.mutedForeground },
|
||||
deadline: { flexDirection: "row", alignItems: "center", gap: spacing.xs },
|
||||
meta: { fontSize: 11, color: colors.mutedForeground },
|
||||
priceRow: { flexDirection: "row", alignItems: "baseline", gap: spacing.sm },
|
||||
price: { fontSize: 14, fontWeight: "700" },
|
||||
oldPrice: { fontSize: 11, color: colors.mutedForeground, textDecorationLine: "line-through" },
|
||||
cta: { minHeight: 39, justifyContent: "center", borderTopWidth: 1, borderTopColor: "rgba(0, 0, 0, 0.06)", paddingHorizontal: 14 },
|
||||
ctaText: { fontSize: 12, fontWeight: "700" },
|
||||
pressed: { opacity: 0.78 },
|
||||
disabled: { opacity: 0.5 },
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { FlatList, StyleSheet, useWindowDimensions, View } from "react-native";
|
||||
import { notificationApi, notificationKeys } from "../notification-api";
|
||||
import { useNotificationAction } from "../notification-actions";
|
||||
import { typeMap } from "../notification-presenter";
|
||||
import { layout, spacing } from "../theme";
|
||||
import type { NotificationItem } from "../types";
|
||||
import { ErrorNotice, Loading } from "../ui";
|
||||
import { NotificationCard } from "./NotificationCard";
|
||||
|
||||
const CARD_GAP = spacing.sm;
|
||||
|
||||
export function NotificationCarousel({
|
||||
authenticated,
|
||||
autoplay = false,
|
||||
autoplayIntervalMs = 5000,
|
||||
requireAuth,
|
||||
}: {
|
||||
authenticated: boolean;
|
||||
autoplay?: boolean;
|
||||
autoplayIntervalMs?: number;
|
||||
requireAuth: (afterAuth?: () => Promise<void>) => void;
|
||||
}) {
|
||||
const client = useQueryClient();
|
||||
const list = useRef<FlatList<NotificationItem>>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [actionError, setActionError] = useState<unknown>();
|
||||
const [hiddenGuestIds, setHiddenGuestIds] = useState<Set<string>>(() => new Set());
|
||||
const dimensions = useWindowDimensions();
|
||||
const cardWidth = Math.max(0, Math.min(dimensions.width, layout.maxWidth) - spacing.lg * 2);
|
||||
const pageWidth = cardWidth + CARD_GAP;
|
||||
const catalog = useQuery({
|
||||
queryKey: notificationKeys.catalog,
|
||||
queryFn: notificationApi.catalog,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
const notifications = useQuery({
|
||||
queryKey: notificationKeys.home(authenticated),
|
||||
queryFn: authenticated ? () => notificationApi.list("home") : notificationApi.guestHome,
|
||||
});
|
||||
const byCode = useMemo(() => typeMap(catalog.data ?? []), [catalog.data]);
|
||||
const action = useNotificationAction({ authenticated, requireAuth, onError: setActionError });
|
||||
const hide = useMutation({
|
||||
mutationFn: notificationApi.hide,
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
client.invalidateQueries({ queryKey: notificationKeys.home(true) }),
|
||||
client.invalidateQueries({ queryKey: notificationKeys.center }),
|
||||
]);
|
||||
},
|
||||
onError: setActionError,
|
||||
});
|
||||
const data = (notifications.data ?? []).filter((item) => authenticated || !hiddenGuestIds.has(item.id));
|
||||
|
||||
const goTo = (index: number) => {
|
||||
if (data.length < 2) return;
|
||||
const next = (index + data.length) % data.length;
|
||||
setActiveIndex(next);
|
||||
list.current?.scrollToIndex({ index: next, animated: true });
|
||||
};
|
||||
|
||||
const syncActiveIndex = (offset: number) => {
|
||||
if (pageWidth <= 0) return;
|
||||
const next = Math.min(data.length - 1, Math.max(0, Math.round(offset / pageWidth)));
|
||||
setActiveIndex((current) => current === next ? current : next);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoplay || data.length < 2) return;
|
||||
const timer = setInterval(() => {
|
||||
setActiveIndex((current) => {
|
||||
const next = (current + 1) % data.length;
|
||||
list.current?.scrollToIndex({ index: next, animated: true });
|
||||
return next;
|
||||
});
|
||||
}, Math.max(1000, autoplayIntervalMs));
|
||||
return () => clearInterval(timer);
|
||||
}, [autoplay, autoplayIntervalMs, data.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeIndex < data.length) return;
|
||||
const next = Math.max(data.length - 1, 0);
|
||||
setActiveIndex(next);
|
||||
list.current?.scrollToIndex({ index: next, animated: false });
|
||||
}, [activeIndex, data.length]);
|
||||
|
||||
if (notifications.isLoading || catalog.isLoading) {
|
||||
return <View style={local.state}><Loading /></View>;
|
||||
}
|
||||
if (notifications.error || catalog.error) {
|
||||
return (
|
||||
<View style={local.state}>
|
||||
<ErrorNotice
|
||||
error={notifications.error ?? catalog.error}
|
||||
retry={() => { void notifications.refetch(); void catalog.refetch(); }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (!data.length) return null;
|
||||
|
||||
return (
|
||||
<View style={local.section}>
|
||||
<FlatList
|
||||
ref={list}
|
||||
horizontal
|
||||
data={data}
|
||||
decelerationRate="fast"
|
||||
disableIntervalMomentum
|
||||
snapToInterval={pageWidth}
|
||||
snapToAlignment="start"
|
||||
getItemLayout={(_, index) => ({ length: pageWidth, offset: pageWidth * index, index })}
|
||||
ItemSeparatorComponent={() => <View style={local.separator} />}
|
||||
keyExtractor={(item) => item.id}
|
||||
style={local.carousel}
|
||||
onScroll={(event) => syncActiveIndex(event.nativeEvent.contentOffset.x)}
|
||||
scrollEventThrottle={16}
|
||||
renderItem={({ item }) => (
|
||||
<NotificationCard
|
||||
disabled={hide.isPending}
|
||||
item={item}
|
||||
type={byCode.get(item.notification_type)}
|
||||
onCta={() => void action(item, byCode.get(item.notification_type))}
|
||||
onNext={() => goTo(activeIndex + 1)}
|
||||
onPrevious={() => goTo(activeIndex - 1)}
|
||||
position={activeIndex + 1}
|
||||
total={data.length}
|
||||
width={cardWidth}
|
||||
onHide={() => {
|
||||
if (authenticated) {
|
||||
hide.mutate(item.id);
|
||||
} else {
|
||||
setHiddenGuestIds((current) => new Set(current).add(item.id));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
/>
|
||||
{Boolean(actionError) && <View style={local.error}><ErrorNotice error={actionError} /></View>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const local = StyleSheet.create({
|
||||
section: { paddingVertical: spacing.md },
|
||||
carousel: { marginHorizontal: spacing.lg },
|
||||
separator: { width: CARD_GAP },
|
||||
state: { paddingHorizontal: spacing.lg, paddingVertical: spacing.md },
|
||||
error: { paddingHorizontal: spacing.lg, paddingTop: spacing.sm },
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import React from "react";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { colors, radii, spacing } from "../theme";
|
||||
|
||||
const icons = ["map-pin", "credit-card", "briefcase", "alert-circle"] as const;
|
||||
|
||||
type Question = { id?: string; text: string };
|
||||
|
||||
export function PopularQuestionsList({ questions, onSelect }: { questions: Question[]; onSelect: (text: string) => void }) {
|
||||
if (!questions.length) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.heading}>Популярные вопросы</Text>
|
||||
<View style={styles.list}>
|
||||
{questions.map((question, index) => (
|
||||
<Pressable
|
||||
key={question.id ?? index}
|
||||
accessibilityRole="button"
|
||||
onPress={() => onSelect(question.text)}
|
||||
style={({ pressed }) => [styles.item, pressed && styles.pressed]}
|
||||
>
|
||||
<View style={styles.iconWrap}>
|
||||
<Feather name={icons[index % icons.length]} size={17} color={colors.primary} />
|
||||
</View>
|
||||
<Text style={styles.itemText}>{question.text}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { paddingHorizontal: spacing.lg, paddingVertical: spacing.md, borderTopWidth: 1, borderTopColor: colors.border, backgroundColor: colors.background },
|
||||
heading: { fontSize: 14, fontWeight: "500", color: colors.mutedForeground, marginBottom: 10, paddingHorizontal: 4 },
|
||||
list: { gap: spacing.sm },
|
||||
item: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
backgroundColor: colors.card,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: radii.lg,
|
||||
padding: 10,
|
||||
},
|
||||
iconWrap: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: radii.full,
|
||||
backgroundColor: "rgba(3, 2, 19, 0.1)",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
itemText: { flex: 1, fontSize: 14, fontWeight: "600", color: colors.foreground },
|
||||
pressed: { backgroundColor: colors.accent },
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { Animated, Linking, Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { dialogApi } from "../services";
|
||||
import { colors, radii, spacing } from "../theme";
|
||||
|
||||
function UnreadMessageBadge() {
|
||||
const pulse = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
const animation = Animated.loop(
|
||||
Animated.timing(pulse, {
|
||||
toValue: 1,
|
||||
duration: 1100,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
);
|
||||
animation.start();
|
||||
return () => animation.stop();
|
||||
}, [pulse]);
|
||||
|
||||
return (
|
||||
<View accessibilityLabel="Есть непрочитанные сообщения" style={styles.badgePosition}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.pulse,
|
||||
{
|
||||
opacity: pulse.interpolate({ inputRange: [0, 1], outputRange: [0.75, 0] }),
|
||||
transform: [{ scale: pulse.interpolate({ inputRange: [0, 1], outputRange: [1, 2] }) }],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>!</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuickActions({ phone, authenticated = false }: { phone?: string | undefined; authenticated?: boolean }) {
|
||||
const router = useRouter();
|
||||
const dialogs = useQuery({
|
||||
queryKey: ["dialogs", "unread-indicator"],
|
||||
queryFn: () => dialogApi.list(),
|
||||
enabled: authenticated,
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
const hasUnreadMessages = (dialogs.data?.items ?? []).some(
|
||||
(dialog) => (dialog.unread_count ?? 0) > 0 || dialog.status === "waiting_for_client",
|
||||
);
|
||||
|
||||
const call = () => {
|
||||
if (phone) void Linking.openURL(`tel:${phone}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Pressable
|
||||
accessibilityLabel="Чат"
|
||||
accessibilityRole="button"
|
||||
onPress={() => router.push("/dialogs")}
|
||||
style={({ pressed }) => [styles.button, styles.chatButton, pressed && styles.pressed]}
|
||||
>
|
||||
<View>
|
||||
<Feather name="message-circle" size={17} color={colors.primaryForeground} />
|
||||
{hasUnreadMessages ? <UnreadMessageBadge /> : null}
|
||||
</View>
|
||||
<Text style={[styles.text, styles.chatText]}>Чат</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
accessibilityLabel="Оператор"
|
||||
accessibilityRole="button"
|
||||
disabled={!phone}
|
||||
onPress={call}
|
||||
style={({ pressed }) => [styles.button, styles.operatorButton, pressed && styles.pressed, !phone && styles.disabled]}
|
||||
>
|
||||
<Feather name="headphones" size={17} color={colors.secondaryForeground} />
|
||||
<Text style={styles.text}>Оператор</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
gap: spacing.sm,
|
||||
marginHorizontal: spacing.lg,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: spacing.sm,
|
||||
paddingVertical: 10,
|
||||
borderRadius: radii.lg,
|
||||
},
|
||||
chatButton: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
operatorButton: {
|
||||
backgroundColor: colors.secondary,
|
||||
},
|
||||
text: { fontSize: 14, fontWeight: "600", color: colors.secondaryForeground },
|
||||
chatText: { color: colors.primaryForeground },
|
||||
badgePosition: {
|
||||
position: "absolute",
|
||||
top: -8,
|
||||
right: -9,
|
||||
width: 14,
|
||||
height: 14,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
pulse: {
|
||||
position: "absolute",
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: radii.full,
|
||||
backgroundColor: colors.destructive,
|
||||
},
|
||||
badge: {
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: radii.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: colors.destructive,
|
||||
},
|
||||
badgeText: { color: colors.primaryForeground, fontSize: 10, lineHeight: 12, fontWeight: "800" },
|
||||
pressed: { opacity: 0.8 },
|
||||
disabled: { opacity: 0.5 },
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from "react";
|
||||
import { KeyboardAvoidingView, Platform, StyleSheet, View } from "react-native";
|
||||
import { colors, layout } from "../theme";
|
||||
|
||||
export function ScreenShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
keyboardVerticalOffset={0}
|
||||
style={styles.outer}
|
||||
>
|
||||
<View style={styles.inner}>{children}</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outer: { flex: 1, backgroundColor: colors.background, alignItems: "center" },
|
||||
inner: { flex: 1, width: "100%", maxWidth: layout.maxWidth },
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
const required = (value: string | undefined, fallback: string) =>
|
||||
(value ?? fallback).replace(/\/$/, "");
|
||||
|
||||
export const env = Object.freeze({
|
||||
apiBaseUrl: required(process.env.EXPO_PUBLIC_API_BASE_URL, "http://localhost:8000"),
|
||||
authBaseUrl: required(
|
||||
process.env.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,9 @@
|
||||
export const DEFAULT_MESSAGE_MAX_LENGTH = 4000;
|
||||
|
||||
export function normalizeMessageText(value: string) {
|
||||
return value.normalize("NFKC").trim();
|
||||
}
|
||||
|
||||
export function messageFitsLimit(value: string, maxLength: number) {
|
||||
return normalizeMessageText(value).length <= maxLength;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import * as Crypto from "expo-crypto";
|
||||
import * as DocumentPicker from "expo-document-picker";
|
||||
import { File, Paths } from "expo-file-system";
|
||||
import * as FileSystem from "expo-file-system/legacy";
|
||||
import * as Linking from "expo-linking";
|
||||
import * as Sharing from "expo-sharing";
|
||||
|
||||
export type NativeFile = {
|
||||
uri: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
type PickedAsset = {
|
||||
uri: string;
|
||||
name: string;
|
||||
mimeType?: string | null;
|
||||
size?: number | null;
|
||||
};
|
||||
|
||||
export function toNativeFile(asset: PickedAsset): NativeFile {
|
||||
return {
|
||||
uri: asset.uri,
|
||||
name: asset.name,
|
||||
mimeType: asset.mimeType ?? "application/octet-stream",
|
||||
size: asset.size ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function pickFiles({
|
||||
multiple = false,
|
||||
mimeTypes,
|
||||
}: {
|
||||
multiple?: boolean;
|
||||
mimeTypes?: string[] | undefined;
|
||||
} = {}): Promise<NativeFile[]> {
|
||||
const result = await DocumentPicker.getDocumentAsync({
|
||||
copyToCacheDirectory: true,
|
||||
multiple,
|
||||
type: mimeTypes?.length ? mimeTypes : "*/*",
|
||||
});
|
||||
if (result.canceled) return [];
|
||||
return result.assets.map((asset) => {
|
||||
const file = toNativeFile(asset);
|
||||
if (file.size > 0) return file;
|
||||
const actualSize = new File(file.uri).size;
|
||||
return {
|
||||
...file,
|
||||
size: typeof actualSize === "number" && Number.isFinite(actualSize)
|
||||
? actualSize
|
||||
: file.size,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function sha256(file: NativeFile) {
|
||||
const bytes = await new File(file.uri).bytes();
|
||||
const digest = await Crypto.digest(Crypto.CryptoDigestAlgorithm.SHA256, bytes);
|
||||
const hex = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
return `sha256:${hex}`;
|
||||
}
|
||||
|
||||
export async function uploadFile(
|
||||
url: string,
|
||||
file: NativeFile,
|
||||
headers: Record<string, string> = {},
|
||||
) {
|
||||
const task = FileSystem.createUploadTask(url, file.uri, {
|
||||
httpMethod: "PUT",
|
||||
headers: {
|
||||
"Content-Type": file.mimeType,
|
||||
...headers,
|
||||
},
|
||||
uploadType: FileSystem.FileSystemUploadType.BINARY_CONTENT,
|
||||
});
|
||||
const result = await task.uploadAsync();
|
||||
if (!result || result.status < 200 || result.status >= 300) {
|
||||
throw new Error("Не удалось загрузить файл в хранилище");
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadAndOpen(url: string, suggestedName = "document") {
|
||||
try {
|
||||
const safeName = suggestedName.replace(/[^\p{L}\p{N}._-]+/gu, "_") || "document";
|
||||
const target = new File(Paths.cache, `${Date.now()}-${safeName}`).uri;
|
||||
const result = await FileSystem.downloadAsync(url, target);
|
||||
if (await Sharing.isAvailableAsync()) {
|
||||
await Sharing.shareAsync(result.uri);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Системный браузер остаётся безопасным запасным вариантом.
|
||||
}
|
||||
await Linking.openURL(url);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import { Alert } from "react-native";
|
||||
import { dialogApi } from "./services";
|
||||
import { notificationApi } from "./notification-api";
|
||||
import { actionDialogId, actionUrl, openNewTab } from "./notification-presenter";
|
||||
import {
|
||||
clearPendingTextIntent,
|
||||
createPendingTextIntent,
|
||||
savePendingTextIntent,
|
||||
type PendingTextIntent,
|
||||
} from "./pending-intent";
|
||||
import type { NotificationItem, NotificationType } from "./types";
|
||||
|
||||
export function useNotificationAction({
|
||||
authenticated,
|
||||
requireAuth,
|
||||
onError,
|
||||
}: {
|
||||
authenticated: boolean;
|
||||
requireAuth?: (afterAuth?: () => Promise<void>) => void;
|
||||
onError: (error: unknown) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const client = useQueryClient();
|
||||
|
||||
const sendGuestOffer = useCallback(async (intent: PendingTextIntent) => {
|
||||
const dialog = await dialogApi.create(intent.dialogKey);
|
||||
await dialogApi.sendText(dialog.dialog_id, intent.text, intent.messageKey);
|
||||
await clearPendingTextIntent();
|
||||
router.push(`/dialogs/${dialog.dialog_id}`);
|
||||
}, [router]);
|
||||
|
||||
return useCallback(async (item: NotificationItem, type?: NotificationType) => {
|
||||
if (!type) return;
|
||||
onError(undefined);
|
||||
try {
|
||||
if (!authenticated) {
|
||||
if (type.cta_action === "install_app_prompt") {
|
||||
await promptInstallOrOpenInstruction(item.instruction_url);
|
||||
return;
|
||||
}
|
||||
if (type.cta_action === "send_chat_message" && item.chat_message_text) {
|
||||
const intent = createPendingTextIntent(item.chat_message_text);
|
||||
await savePendingTextIntent(intent);
|
||||
requireAuth?.(() => sendGuestOffer(intent));
|
||||
return;
|
||||
}
|
||||
requireAuth?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const state = await notificationApi.cta(item.id);
|
||||
client.setQueryData(notificationApiStateKey(item.id), (old: NotificationItem | undefined) =>
|
||||
old ? { ...old, ...state } : old);
|
||||
await Promise.all([
|
||||
client.invalidateQueries({ queryKey: ["notifications"] }),
|
||||
client.invalidateQueries({ queryKey: ["notifications", "counter"] }),
|
||||
]);
|
||||
|
||||
if (type.cta_action === "open_detail") {
|
||||
router.push(`/notification/${item.id}`);
|
||||
return;
|
||||
}
|
||||
const url = actionUrl(state);
|
||||
if (url) openNewTab(url);
|
||||
const dialogId = actionDialogId(state);
|
||||
if (dialogId) router.push(`/dialogs/${dialogId}`);
|
||||
else if (type.cta_action === "send_chat_message") router.push("/dialogs");
|
||||
} catch (error) {
|
||||
onError(error);
|
||||
}
|
||||
}, [authenticated, client, onError, requireAuth, router, sendGuestOffer]);
|
||||
}
|
||||
|
||||
function notificationApiStateKey(id: string) {
|
||||
return ["notifications", "detail", id] as const;
|
||||
}
|
||||
|
||||
async function promptInstallOrOpenInstruction(instructionUrl?: string | null) {
|
||||
Alert.alert(
|
||||
"HAN Chat",
|
||||
instructionUrl
|
||||
? "Приложение уже установлено. При необходимости откройте инструкцию."
|
||||
: "Приложение уже установлено на этом устройстве.",
|
||||
instructionUrl
|
||||
? [
|
||||
{ text: "Закрыть", style: "cancel" },
|
||||
{ text: "Открыть инструкцию", onPress: () => openNewTab(instructionUrl) },
|
||||
]
|
||||
: [{ text: "Понятно" }],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { apiRequest, json } from "./api";
|
||||
import { sha256, uploadFile, type NativeFile } from "./native-files";
|
||||
import type {
|
||||
NotificationActionState,
|
||||
NotificationCounter,
|
||||
NotificationItem,
|
||||
NotificationList,
|
||||
NotificationType,
|
||||
UploadDraft,
|
||||
} from "./types";
|
||||
|
||||
type CatalogResponse = { items: NotificationType[] };
|
||||
type UploadListResponse = { items: UploadDraft[] };
|
||||
|
||||
export const notificationKeys = {
|
||||
catalog: ["notification-types"] as const,
|
||||
home: (authenticated: boolean) => ["notifications", authenticated ? "P" : "G", "home"] as const,
|
||||
center: ["notifications", "P", "center"] as const,
|
||||
counter: ["notifications", "counter"] as const,
|
||||
detail: (id: string) => ["notifications", "detail", id] as const,
|
||||
};
|
||||
|
||||
export const notificationApi = {
|
||||
catalog: async () => (await apiRequest<CatalogResponse>("/api/v1/public/notification-types")).items,
|
||||
guestHome: async () => (await apiRequest<NotificationList>("/api/v1/public/notifications")).items,
|
||||
list: async (place: "home" | "center") =>
|
||||
(await apiRequest<NotificationList>(
|
||||
`/api/v1/notifications?place=${place}`,
|
||||
{ protected: true },
|
||||
)).items,
|
||||
counter: () =>
|
||||
apiRequest<NotificationCounter>("/api/v1/notifications/counter", { protected: true }),
|
||||
detail: (id: string) =>
|
||||
apiRequest<NotificationItem>(`/api/v1/notifications/${encodeURIComponent(id)}`, { protected: true }),
|
||||
read: (id: string) =>
|
||||
apiRequest<NotificationActionState>(`/api/v1/notifications/${encodeURIComponent(id)}/read`, {
|
||||
method: "POST", protected: true, body: "{}",
|
||||
}),
|
||||
hide: (id: string) =>
|
||||
apiRequest<NotificationActionState>(`/api/v1/notifications/${encodeURIComponent(id)}/hide`, {
|
||||
method: "POST", protected: true, body: "{}",
|
||||
}),
|
||||
cta: (id: string) =>
|
||||
apiRequest<NotificationActionState>(`/api/v1/notifications/${encodeURIComponent(id)}/cta`, {
|
||||
method: "POST", protected: true, body: "{}",
|
||||
}),
|
||||
button: (id: string, code: string) =>
|
||||
apiRequest<NotificationActionState>(
|
||||
`/api/v1/notifications/${encodeURIComponent(id)}/buttons/${encodeURIComponent(code)}`,
|
||||
{ method: "POST", protected: true, body: "{}" },
|
||||
),
|
||||
documentUrl: (notificationId: string, documentId: string) =>
|
||||
apiRequest<{ download_url: string; expires_at: string }>(
|
||||
`/api/v1/notifications/${encodeURIComponent(notificationId)}/documents/${encodeURIComponent(documentId)}/download-url`,
|
||||
{ protected: true },
|
||||
),
|
||||
};
|
||||
|
||||
export const uploadDraftApi = {
|
||||
list: async (notificationId: string) =>
|
||||
(await apiRequest<UploadListResponse>(
|
||||
`/api/v1/uploads?context_type=notification&context_id=${encodeURIComponent(notificationId)}`,
|
||||
{ protected: true },
|
||||
)).items,
|
||||
remove: (draftId: string) =>
|
||||
apiRequest<void>(`/api/v1/uploads/${encodeURIComponent(draftId)}`, {
|
||||
method: "DELETE", protected: true,
|
||||
}),
|
||||
upload: async (notificationId: string, file: NativeFile) => {
|
||||
const checksum = await sha256(file);
|
||||
const draft = await apiRequest<{
|
||||
draft_id: string;
|
||||
upload_url: string;
|
||||
upload_headers: Record<string, string>;
|
||||
expires_at: string;
|
||||
}>("/api/v1/uploads/init", {
|
||||
method: "POST",
|
||||
protected: true,
|
||||
body: json({
|
||||
context_type: "notification",
|
||||
context_id: notificationId,
|
||||
file_name: file.name,
|
||||
mime_type: file.mimeType,
|
||||
size_bytes: file.size,
|
||||
}),
|
||||
});
|
||||
await uploadFile(draft.upload_url, file, draft.upload_headers);
|
||||
await apiRequest<UploadDraft>(`/api/v1/uploads/${encodeURIComponent(draft.draft_id)}/complete`, {
|
||||
method: "POST",
|
||||
protected: true,
|
||||
body: json({ checksum }),
|
||||
});
|
||||
return draft.draft_id;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import * as Linking from "expo-linking";
|
||||
import type { NotificationActionState, NotificationType } from "./types";
|
||||
|
||||
export type NotificationPalette = {
|
||||
background: string;
|
||||
foreground: string;
|
||||
accent: string;
|
||||
accentBackground: string;
|
||||
border: string;
|
||||
};
|
||||
|
||||
const neutral: NotificationPalette = {
|
||||
background: "#f3f3f5",
|
||||
foreground: "#252525",
|
||||
accent: "#030213",
|
||||
accentBackground: "#e4e4e8",
|
||||
border: "#d7d7dc",
|
||||
};
|
||||
|
||||
const palettes: Record<string, NotificationPalette> = {
|
||||
critical: {
|
||||
background: "#fdecef",
|
||||
foreground: "#252525",
|
||||
accent: "#d4183d",
|
||||
accentBackground: "#f8dce2",
|
||||
border: "#efbcc7",
|
||||
},
|
||||
warning: {
|
||||
background: "#fff3e0",
|
||||
foreground: "#252525",
|
||||
accent: "#e65100",
|
||||
accentBackground: "#ffe0b2",
|
||||
border: "#ffcc80",
|
||||
},
|
||||
success: {
|
||||
background: "#e8f5e9",
|
||||
foreground: "#252525",
|
||||
accent: "#2e7d32",
|
||||
accentBackground: "#c8e6c9",
|
||||
border: "#a5d6a7",
|
||||
},
|
||||
info: {
|
||||
background: "#eaf2ff",
|
||||
foreground: "#252525",
|
||||
accent: "#2563eb",
|
||||
accentBackground: "#d8e7ff",
|
||||
border: "#b7d1fb",
|
||||
},
|
||||
promo: {
|
||||
background: "#f4edff",
|
||||
foreground: "#252525",
|
||||
accent: "#7c3aed",
|
||||
accentBackground: "#e7d8ff",
|
||||
border: "#d4b9fb",
|
||||
},
|
||||
neutral,
|
||||
};
|
||||
|
||||
const icons: Record<string, keyof typeof Feather.glyphMap> = {
|
||||
alert: "alert-circle",
|
||||
urgent: "alert-triangle",
|
||||
payment: "credit-card",
|
||||
documents: "file-text",
|
||||
document: "file-text",
|
||||
status: "activity",
|
||||
reminder: "clock",
|
||||
news: "bell",
|
||||
promo: "gift",
|
||||
ads: "star",
|
||||
authorize: "log-in",
|
||||
install: "download",
|
||||
info: "info",
|
||||
};
|
||||
|
||||
export function notificationPalette(token?: string | null): NotificationPalette {
|
||||
return token ? (palettes[token] ?? neutral) : neutral;
|
||||
}
|
||||
|
||||
export function notificationIcon(code?: string | null): keyof typeof Feather.glyphMap {
|
||||
return code ? (icons[code] ?? "bell") : "bell";
|
||||
}
|
||||
|
||||
export function typeMap(catalog: NotificationType[]) {
|
||||
return new Map(catalog.map((type) => [type.code, type]));
|
||||
}
|
||||
|
||||
export function formatNotificationPrice(value?: number | string | null) {
|
||||
if (value === null || value === undefined) return null;
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return null;
|
||||
return new Intl.NumberFormat("ru-RU", {
|
||||
style: "currency",
|
||||
currency: "RUB",
|
||||
maximumFractionDigits: number % 1 === 0 ? 0 : 2,
|
||||
}).format(number);
|
||||
}
|
||||
|
||||
export function actionUrl(state: NotificationActionState) {
|
||||
return state.result?.action === "open_url" ? state.result.url : undefined;
|
||||
}
|
||||
|
||||
export function actionDialogId(state: NotificationActionState) {
|
||||
return state.result?.action === "chat_message_sent"
|
||||
? state.result.message.dialog_id
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function openNewTab(url: string) {
|
||||
void Linking.openURL(url);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Constants from "expo-constants";
|
||||
import * as Crypto from "expo-crypto";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
const DEVICE_ID_KEY = "han.device-id";
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
type Store = {
|
||||
get(key: string): Promise<string | null>;
|
||||
set(key: string, value: string): Promise<void>;
|
||||
};
|
||||
|
||||
export type OidcDeviceMetadata = Partial<Record<
|
||||
| "han_device_id"
|
||||
| "han_fingerprint"
|
||||
| "han_platform"
|
||||
| "han_os_name"
|
||||
| "han_os_version"
|
||||
| "han_app_version",
|
||||
string
|
||||
>>;
|
||||
|
||||
function safe(value: unknown, maxLength: number): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const normalized = value.trim();
|
||||
if (!normalized || normalized.length > maxLength || CONTROL_CHARACTERS.test(normalized)) {
|
||||
return undefined;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function buildOidcDeviceMetadata(store: Store): Promise<OidcDeviceMetadata> {
|
||||
const platform = Platform.OS === "ios" ? "ios" : "android";
|
||||
let deviceId = safe(await store.get(DEVICE_ID_KEY), 256);
|
||||
if (!deviceId) {
|
||||
deviceId = Crypto.randomUUID();
|
||||
await store.set(DEVICE_ID_KEY, deviceId);
|
||||
}
|
||||
|
||||
const constants = Platform.constants as unknown as Record<string, unknown>;
|
||||
const fingerprintSource = [platform, constants.Brand, constants.Model, constants.osVersion].join("|");
|
||||
const fingerprint = await Crypto.digestStringAsync(
|
||||
Crypto.CryptoDigestAlgorithm.SHA256,
|
||||
`${deviceId}|${fingerprintSource}`,
|
||||
);
|
||||
|
||||
const osName = safe(constants.systemName, 64)
|
||||
?? (platform === "ios" ? "iOS" : platform === "android" ? "Android" : undefined);
|
||||
const osVersion = safe(String(constants.osVersion ?? Platform.Version ?? ""), 64);
|
||||
const appVersion = safe(Constants.expoConfig?.version, 64);
|
||||
const safeOsName = safe(osName, 64);
|
||||
const safeOsVersion = safe(osVersion, 64);
|
||||
|
||||
return {
|
||||
han_device_id: deviceId,
|
||||
han_fingerprint: fingerprint,
|
||||
han_platform: platform,
|
||||
...(safeOsName ? { han_os_name: safeOsName } : {}),
|
||||
...(safeOsVersion ? { han_os_version: safeOsVersion } : {}),
|
||||
...(appVersion ? { han_app_version: appVersion } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import * as Crypto from "expo-crypto";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import type { NativeFile } from "./native-files";
|
||||
|
||||
export type PendingTextIntent = {
|
||||
text: string;
|
||||
dialogKey: string;
|
||||
messageKey: string;
|
||||
dialogId?: string;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "han.pending-message";
|
||||
const FILE_STORAGE_KEY = "han.pending-file";
|
||||
let pendingTextIntent: PendingTextIntent | null = null;
|
||||
let pendingFileIntent: PendingFileIntent | null = null;
|
||||
|
||||
export type PendingFileIntent = {
|
||||
file: NativeFile;
|
||||
dialogKey: string;
|
||||
dialogId?: string;
|
||||
messageKey: string;
|
||||
};
|
||||
|
||||
export function createPendingTextIntent(text: string): PendingTextIntent {
|
||||
return {
|
||||
text,
|
||||
dialogKey: Crypto.randomUUID(),
|
||||
messageKey: Crypto.randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function savePendingTextIntent(intent: PendingTextIntent) {
|
||||
pendingTextIntent = intent;
|
||||
await SecureStore.setItemAsync(STORAGE_KEY, JSON.stringify(intent));
|
||||
}
|
||||
|
||||
export function loadPendingTextIntent(): PendingTextIntent | null {
|
||||
return pendingTextIntent;
|
||||
}
|
||||
|
||||
export async function bindPendingTextIntent(intent: PendingTextIntent, dialogId: string) {
|
||||
const bound = { ...intent, dialogId };
|
||||
await savePendingTextIntent(bound);
|
||||
return bound;
|
||||
}
|
||||
|
||||
export async function clearPendingTextIntent() {
|
||||
pendingTextIntent = null;
|
||||
await SecureStore.deleteItemAsync(STORAGE_KEY);
|
||||
}
|
||||
|
||||
export async function savePendingFileIntent(intent: PendingFileIntent) {
|
||||
pendingFileIntent = intent;
|
||||
await SecureStore.setItemAsync(FILE_STORAGE_KEY, JSON.stringify(intent));
|
||||
}
|
||||
|
||||
export async function bindPendingFileIntent(dialogId: string) {
|
||||
if (pendingFileIntent) await savePendingFileIntent({ ...pendingFileIntent, dialogId });
|
||||
}
|
||||
|
||||
export function pendingDialogKey() {
|
||||
return loadPendingTextIntent()?.dialogKey ?? pendingFileIntent?.dialogKey;
|
||||
}
|
||||
|
||||
export function loadPendingFileIntent(dialogId: string) {
|
||||
return pendingFileIntent?.dialogId === dialogId ? pendingFileIntent : null;
|
||||
}
|
||||
|
||||
export async function clearPendingFileIntent() {
|
||||
pendingFileIntent = null;
|
||||
await SecureStore.deleteItemAsync(FILE_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export async function restorePendingIntents() {
|
||||
const [text, file] = await Promise.all([
|
||||
SecureStore.getItemAsync(STORAGE_KEY),
|
||||
SecureStore.getItemAsync(FILE_STORAGE_KEY),
|
||||
]);
|
||||
pendingTextIntent = parseTextIntent(text);
|
||||
pendingFileIntent = parseFileIntent(file);
|
||||
}
|
||||
|
||||
function parseTextIntent(raw: string | null): PendingTextIntent | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const value = JSON.parse(raw) as Partial<PendingTextIntent>;
|
||||
if (typeof value.text !== "string" || typeof value.dialogKey !== "string" || typeof value.messageKey !== "string") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
text: value.text,
|
||||
dialogKey: value.dialogKey,
|
||||
messageKey: value.messageKey,
|
||||
...(typeof value.dialogId === "string" ? { dialogId: value.dialogId } : {}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseFileIntent(raw: string | null): PendingFileIntent | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const value = JSON.parse(raw) as PendingFileIntent;
|
||||
if (
|
||||
typeof value.file?.uri !== "string"
|
||||
|| typeof value.file.name !== "string"
|
||||
|| typeof value.file.mimeType !== "string"
|
||||
|| typeof value.file.size !== "number"
|
||||
|| typeof value.dialogKey !== "string"
|
||||
|| typeof value.messageKey !== "string"
|
||||
) return null;
|
||||
return value;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { env } from "./config";
|
||||
import { getAccessToken, refreshTokens } from "./auth";
|
||||
import { dialogApi } from "./services";
|
||||
import { AppState } from "react-native";
|
||||
import type { Message, NotificationRealtimeEvent } 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 };
|
||||
|
||||
export const NOTIFICATION_OUTAGE_MS = 30_000;
|
||||
export const NOTIFICATION_POLL_INTERVAL_MS = 60_000;
|
||||
|
||||
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);
|
||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
let encoded = "";
|
||||
for (let index = 0; index < bytes.length; index += 3) {
|
||||
const first = bytes[index] ?? 0;
|
||||
const second = bytes[index + 1];
|
||||
const third = bytes[index + 2];
|
||||
encoded += alphabet[first >> 2]!;
|
||||
encoded += alphabet[((first & 3) << 4) | ((second ?? 0) >> 4)]!;
|
||||
if (second !== undefined) encoded += alphabet[((second & 15) << 2) | ((third ?? 0) >> 6)]!;
|
||||
if (third !== undefined) encoded += alphabet[third & 63]!;
|
||||
}
|
||||
return `han.jwt.${encoded}`;
|
||||
}
|
||||
|
||||
export class RealtimeClient {
|
||||
private socket?: WebSocket;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private pollingTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
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 = AppState.currentState === "active" ? 5_000 : 15_000;
|
||||
this.pollingTimer = setTimeout(poll, delay);
|
||||
};
|
||||
void poll();
|
||||
}
|
||||
|
||||
private stopPolling() {
|
||||
if (this.pollingTimer) clearTimeout(this.pollingTimer);
|
||||
this.pollingTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export class NotificationRealtimeClient {
|
||||
private socket?: WebSocket;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private pollingTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private outageTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private disconnectedAt = 0;
|
||||
private attempt = 0;
|
||||
private stopped = true;
|
||||
private readonly eventIds = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private readonly onEvent: (event: NotificationRealtimeEvent) => void,
|
||||
private readonly reconcile: () => Promise<void>,
|
||||
private readonly onState: (state: RealtimeState) => void,
|
||||
) {}
|
||||
|
||||
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);
|
||||
if (this.outageTimer) clearTimeout(this.outageTimer);
|
||||
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;
|
||||
this.socket?.send(JSON.stringify({ type: "subscribe", dialog_ids: [], notifications: true }));
|
||||
void this.reconcile().then(() => {
|
||||
this.disconnectedAt = 0;
|
||||
this.stopPolling();
|
||||
this.onState("websocket");
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
this.socket.onmessage = ({ data }) => {
|
||||
try {
|
||||
const event = JSON.parse(String(data)) as NotificationRealtimeEvent | { type: string };
|
||||
if (event.type === "ping") {
|
||||
this.socket?.send(JSON.stringify({ type: "pong" }));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.type === "notification.created"
|
||||
|| event.type === "notification.updated"
|
||||
|| event.type === "notification.closed"
|
||||
) {
|
||||
const notificationEvent = event as NotificationRealtimeEvent;
|
||||
if (this.eventIds.has(notificationEvent.event_id)) return;
|
||||
this.eventIds.add(notificationEvent.event_id);
|
||||
if (this.eventIds.size > 100) {
|
||||
const oldest = this.eventIds.values().next().value as string | undefined;
|
||||
if (oldest) this.eventIds.delete(oldest);
|
||||
}
|
||||
this.onEvent(notificationEvent);
|
||||
}
|
||||
} catch { /* malformed and unknown messages are ignored */ }
|
||||
};
|
||||
this.socket.onclose = (event) => {
|
||||
if (this.stopped) return;
|
||||
if (!this.disconnectedAt) {
|
||||
this.disconnectedAt = Date.now();
|
||||
this.outageTimer = setTimeout(() => this.startPolling(), NOTIFICATION_OUTAGE_MS);
|
||||
}
|
||||
if (event.code === 4401 || event.code === 1008) {
|
||||
void refreshTokens().finally(() => this.scheduleReconnect());
|
||||
} else {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
};
|
||||
this.socket.onerror = () => this.socket?.close();
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.stopped) return;
|
||||
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 startPolling() {
|
||||
if (this.stopped || this.pollingTimer || !this.disconnectedAt) return;
|
||||
this.onState("polling");
|
||||
const poll = async () => {
|
||||
if (this.stopped) return;
|
||||
await this.reconcile().catch(() => undefined);
|
||||
this.pollingTimer = setTimeout(poll, NOTIFICATION_POLL_INTERVAL_MS);
|
||||
};
|
||||
void poll();
|
||||
}
|
||||
|
||||
private stopPolling() {
|
||||
if (this.pollingTimer) clearTimeout(this.pollingTimer);
|
||||
if (this.outageTimer) clearTimeout(this.outageTimer);
|
||||
this.pollingTimer = undefined;
|
||||
this.outageTimer = undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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) => {
|
||||
const left = Date.parse(a.created_at);
|
||||
const right = Date.parse(b.created_at);
|
||||
if (Number.isFinite(left) && Number.isFinite(right)) return left - right;
|
||||
return a.created_at.localeCompare(b.created_at);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { apiRequest, idempotencyHeaders, json, sessionMemory } from "./api";
|
||||
import * as Crypto from "expo-crypto";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { buildOidcDeviceMetadata } from "./oidc-device";
|
||||
import { sha256, uploadFile, type NativeFile } from "./native-files";
|
||||
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: async (consents: Consents) =>
|
||||
apiRequest<{ user_id: string; profile_ready: boolean }>("/api/v1/auth/bootstrap", {
|
||||
method: "POST", protected: true,
|
||||
body: json({ consents, device: await deviceMetadata() }),
|
||||
}),
|
||||
startSession: async (reason: "first_launch" | "cold_start" | "idle_timeout") => {
|
||||
const device = await deviceMetadata();
|
||||
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 }) },
|
||||
);
|
||||
sessionMemory.set(result.ux_session_id);
|
||||
return result;
|
||||
},
|
||||
saveConsents: (consents: Consents) =>
|
||||
apiRequest<void>("/api/v1/consents", {
|
||||
method: "POST", protected: true, body: json({ consents }),
|
||||
}),
|
||||
};
|
||||
|
||||
async function deviceMetadata() {
|
||||
const metadata = await buildOidcDeviceMetadata({
|
||||
get: SecureStore.getItemAsync,
|
||||
set: SecureStore.setItemAsync,
|
||||
});
|
||||
return {
|
||||
platform: metadata.han_platform ?? "android",
|
||||
app_version: metadata.han_app_version ?? "1.0.0",
|
||||
device_id: metadata.han_device_id,
|
||||
};
|
||||
}
|
||||
|
||||
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: NativeFile, 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.mimeType, size_bytes: file.size }),
|
||||
});
|
||||
await uploadFile(init.upload_url, file, init.upload_headers ?? {});
|
||||
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 };
|
||||
}
|
||||
|
||||
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,38 @@
|
||||
export const colors = {
|
||||
background: "#ffffff",
|
||||
foreground: "#252525",
|
||||
primary: "#030213",
|
||||
primaryForeground: "#ffffff",
|
||||
secondary: "#f3f3f5",
|
||||
secondaryForeground: "#030213",
|
||||
muted: "#ececf0",
|
||||
mutedForeground: "#717182",
|
||||
accent: "#e9ebef",
|
||||
destructive: "#d4183d",
|
||||
border: "rgba(0, 0, 0, 0.1)",
|
||||
inputBackground: "#f3f3f5",
|
||||
card: "#ffffff",
|
||||
success: "#16a34a",
|
||||
warning: "#ca8a04",
|
||||
info: "#2563eb",
|
||||
};
|
||||
|
||||
export const radii = {
|
||||
sm: 6,
|
||||
md: 8,
|
||||
lg: 10,
|
||||
xl: 16,
|
||||
full: 9999,
|
||||
};
|
||||
|
||||
export const spacing = {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 12,
|
||||
lg: 16,
|
||||
xl: 20,
|
||||
};
|
||||
|
||||
export const layout = {
|
||||
maxWidth: 390,
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
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;
|
||||
unread_count?: number;
|
||||
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 };
|
||||
messages: { max_text_length: number };
|
||||
notification?: {
|
||||
carousel_autoplay_enabled?: boolean;
|
||||
carousel_autoplay_interval_ms?: number;
|
||||
};
|
||||
consents: Record<string, {
|
||||
required: boolean;
|
||||
document_url: string | null;
|
||||
privacy_policy_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;
|
||||
};
|
||||
|
||||
export type NotificationContour = "G" | "P";
|
||||
export type NotificationCtaAction =
|
||||
| "open_detail"
|
||||
| "open_payment_url"
|
||||
| "send_chat_message"
|
||||
| "start_auth"
|
||||
| "install_app_prompt";
|
||||
|
||||
export type NotificationButton = {
|
||||
code: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type NotificationType = {
|
||||
code: string;
|
||||
label: string;
|
||||
color_token: string;
|
||||
icon_code: string | null;
|
||||
cta_text: string;
|
||||
cta_action: NotificationCtaAction;
|
||||
countable: boolean;
|
||||
contour: NotificationContour;
|
||||
button_primary: NotificationButton | null;
|
||||
button_secondary: NotificationButton | null;
|
||||
};
|
||||
|
||||
export type NotificationTodoItem = {
|
||||
number: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type NotificationDocument = {
|
||||
document_id: string;
|
||||
title: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
};
|
||||
|
||||
export type UploadDraft = {
|
||||
draft_id: string;
|
||||
context_type: "notification";
|
||||
context_id: string;
|
||||
title: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
scan_status: "pending" | "clean" | "infected" | "failed";
|
||||
state: "draft" | "submitted" | "discarded";
|
||||
};
|
||||
|
||||
export type NotificationDetails = {
|
||||
deadline?: string | null;
|
||||
details_header?: string | null;
|
||||
details_text?: string | null;
|
||||
todo_header?: string | null;
|
||||
todo_plan?: NotificationTodoItem[] | null;
|
||||
send_documents?: boolean;
|
||||
pending_documents?: UploadDraft[];
|
||||
documents?: NotificationDocument[] | null;
|
||||
};
|
||||
|
||||
export type NotificationItem = {
|
||||
id: string;
|
||||
notification_type: string;
|
||||
notification_datetime: string;
|
||||
header: string;
|
||||
text?: string | null;
|
||||
date_expired?: string | null;
|
||||
price?: number | string | null;
|
||||
old_price?: number | string | null;
|
||||
instruction_url?: string | null;
|
||||
instruction_open_mode?: "new_tab" | null;
|
||||
chat_message_text?: string | null;
|
||||
details?: NotificationDetails | null;
|
||||
priority?: number;
|
||||
lifecycle_status?: "active" | "closed";
|
||||
visibility?: "visible" | "hidden";
|
||||
is_read?: boolean;
|
||||
close_reason?: string | null;
|
||||
countable?: boolean;
|
||||
cta_action?: NotificationCtaAction;
|
||||
};
|
||||
|
||||
export type NotificationList = { items: NotificationItem[] };
|
||||
export type NotificationCounter = { unread_count: number };
|
||||
export type NotificationActionResult =
|
||||
| { action: "open_detail"; notification_id: string }
|
||||
| { action: "open_url"; url: string }
|
||||
| { action: "chat_message_sent"; message: Message };
|
||||
|
||||
export type NotificationActionState = {
|
||||
notification_id: string;
|
||||
lifecycle_status: "active" | "closed";
|
||||
visibility: "visible" | "hidden";
|
||||
is_read: boolean;
|
||||
close_reason: string | null;
|
||||
date_expired: string | null;
|
||||
unread_count: number;
|
||||
result: NotificationActionResult | null;
|
||||
};
|
||||
|
||||
export type NotificationRealtimeEvent =
|
||||
| {
|
||||
type: "notification.created";
|
||||
event_id: string;
|
||||
occurred_at: string;
|
||||
notification: NotificationItem;
|
||||
unread_count: number;
|
||||
}
|
||||
| {
|
||||
type: "notification.updated";
|
||||
event_id: string;
|
||||
occurred_at: string;
|
||||
notification_id: string;
|
||||
unread_count: number;
|
||||
is_read?: boolean;
|
||||
visibility?: "visible" | "hidden";
|
||||
date_expired?: string | null;
|
||||
close_reason?: string | null;
|
||||
}
|
||||
| {
|
||||
type: "notification.closed";
|
||||
event_id: string;
|
||||
occurred_at: string;
|
||||
notification_id: string;
|
||||
close_reason: string;
|
||||
unread_count: number;
|
||||
is_read?: boolean;
|
||||
visibility?: "visible" | "hidden";
|
||||
date_expired?: string | null;
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from "react";
|
||||
import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
|
||||
import { ApiError } from "./api";
|
||||
import { colors, radii, spacing } from "./theme";
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
page: { flexGrow: 1, backgroundColor: colors.background },
|
||||
scrollContent: { padding: spacing.lg, gap: spacing.lg },
|
||||
title: { fontSize: 20, fontWeight: "500", color: colors.foreground },
|
||||
heading: { fontSize: 16, fontWeight: "500", color: colors.foreground },
|
||||
text: { fontSize: 14, lineHeight: 20, color: colors.foreground },
|
||||
muted: { fontSize: 12, lineHeight: 18, color: colors.mutedForeground },
|
||||
card: { padding: spacing.lg, gap: spacing.md, borderWidth: 1, borderColor: colors.border, borderRadius: radii.lg, backgroundColor: colors.card },
|
||||
input: { minHeight: 48, borderWidth: 1, borderColor: colors.border, borderRadius: radii.md, padding: spacing.md, fontSize: 16, backgroundColor: colors.inputBackground, color: colors.foreground },
|
||||
textarea: { minHeight: 104, textAlignVertical: "top" },
|
||||
row: { flexDirection: "row", flexWrap: "wrap", gap: spacing.sm, alignItems: "center" },
|
||||
button: { minHeight: 44, justifyContent: "center", paddingHorizontal: 18, borderRadius: radii.lg, backgroundColor: colors.primary },
|
||||
buttonSecondary: { backgroundColor: colors.secondary },
|
||||
buttonDanger: { backgroundColor: colors.destructive },
|
||||
buttonDisabled: { opacity: 0.5 },
|
||||
buttonText: { color: colors.primaryForeground, fontWeight: "500", fontSize: 14, textAlign: "center" },
|
||||
buttonTextSecondary: { color: colors.secondaryForeground },
|
||||
link: { color: colors.primary, fontSize: 14, textDecorationLine: "underline", paddingVertical: spacing.sm },
|
||||
badge: { borderRadius: radii.full, backgroundColor: colors.muted, color: colors.foreground, paddingHorizontal: 10, paddingVertical: 5, fontSize: 12, alignSelf: "flex-start" },
|
||||
error: { borderLeftWidth: 4, borderColor: colors.destructive, backgroundColor: "#fef2f2", padding: spacing.md, color: "#7f1d1d", fontSize: 14 },
|
||||
success: { borderLeftWidth: 4, borderColor: colors.success, backgroundColor: "#f0fdf4", padding: spacing.md, color: "#14532d", fontSize: 14 },
|
||||
modalBackdrop: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, zIndex: 10, backgroundColor: "rgba(3, 2, 19, 0.45)", alignItems: "center", justifyContent: "center", padding: spacing.lg },
|
||||
modal: { width: "100%", maxWidth: 360, borderRadius: radii.xl, backgroundColor: colors.card, padding: spacing.lg, gap: spacing.md, borderWidth: 1, borderColor: colors.border },
|
||||
});
|
||||
|
||||
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={({ pressed }) => [
|
||||
styles.button, secondary && styles.buttonSecondary, danger && styles.buttonDanger,
|
||||
disabled && styles.buttonDisabled, pressed && { opacity: 0.8 },
|
||||
]}
|
||||
>
|
||||
<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]} placeholderTextColor={colors.mutedForeground} />
|
||||
{props.error && <Text accessibilityRole="alert" style={styles.error}>{props.error}</Text>}
|
||||
</View>;
|
||||
}
|
||||
|
||||
export function Loading() {
|
||||
return <View accessibilityRole="progressbar" style={styles.row}><ActivityIndicator color={colors.primary} /><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: spacing.sm }}>
|
||||
<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>;
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const secureValues = vi.hoisted(() => new Map<string, string>());
|
||||
|
||||
vi.mock("expo-constants", () => ({
|
||||
default: { expoConfig: { version: "1.0.0" } },
|
||||
}));
|
||||
vi.mock("expo-crypto", () => ({
|
||||
CryptoDigestAlgorithm: { SHA256: "SHA-256" },
|
||||
randomUUID: vi.fn(() => "123e4567-e89b-42d3-a456-426614174000"),
|
||||
digestStringAsync: vi.fn(async () => "stable-fingerprint"),
|
||||
}));
|
||||
vi.mock("expo-auth-session", () => ({
|
||||
makeRedirectUri: vi.fn(() => "https://example.test/auth/callback"),
|
||||
}));
|
||||
vi.mock("expo-secure-store", () => ({
|
||||
getItemAsync: vi.fn(async (key: string) => secureValues.get(key) ?? null),
|
||||
setItemAsync: vi.fn(async (key: string, value: string) => { secureValues.set(key, value); }),
|
||||
deleteItemAsync: vi.fn(async (key: string) => { secureValues.delete(key); }),
|
||||
}));
|
||||
vi.mock("expo-web-browser", () => ({
|
||||
maybeCompleteAuthSession: vi.fn(),
|
||||
}));
|
||||
vi.mock("expo-document-picker", () => ({
|
||||
getDocumentAsync: vi.fn(),
|
||||
}));
|
||||
vi.mock("expo-file-system", () => ({
|
||||
File: vi.fn(),
|
||||
Paths: { cache: "file:///cache" },
|
||||
}));
|
||||
vi.mock("expo-file-system/legacy", () => ({
|
||||
FileSystemUploadType: { BINARY_CONTENT: 0 },
|
||||
createUploadTask: vi.fn(),
|
||||
downloadAsync: vi.fn(),
|
||||
}));
|
||||
vi.mock("expo-linking", () => ({
|
||||
openURL: vi.fn(),
|
||||
}));
|
||||
vi.mock("expo-sharing", () => ({
|
||||
isAvailableAsync: vi.fn(),
|
||||
shareAsync: vi.fn(),
|
||||
}));
|
||||
vi.mock("react-native", () => ({
|
||||
Platform: { OS: "android", Version: "test", constants: {} },
|
||||
AppState: { currentState: "active" },
|
||||
}));
|
||||
|
||||
import { buildOidcDeviceMetadata } from "../../src/oidc-device";
|
||||
import { ApiError, isMessageBlockedError } from "../../src/api";
|
||||
import { messageFitsLimit, normalizeMessageText } from "../../src/message-text";
|
||||
import { reconcileMessages } from "../../src/reconcile";
|
||||
import { sessionMemory } from "../../src/session";
|
||||
import { SingleFlight } from "../../src/single-flight";
|
||||
import { websocketJwtProtocol } from "../../src/realtime";
|
||||
import { toNativeFile } from "../../src/native-files";
|
||||
import {
|
||||
clearPendingTextIntent,
|
||||
bindPendingFileIntent,
|
||||
bindPendingTextIntent,
|
||||
clearPendingFileIntent,
|
||||
createPendingTextIntent,
|
||||
loadPendingFileIntent,
|
||||
loadPendingTextIntent,
|
||||
restorePendingIntents,
|
||||
savePendingFileIntent,
|
||||
savePendingTextIntent,
|
||||
} from "../../src/pending-intent";
|
||||
import type { Message } from "../../src/types";
|
||||
|
||||
const message = (id: string, createdAt: string, status: Message["delivery_status"] = "accepted"): Message => ({
|
||||
message_id: id,
|
||||
dialog_id: "dialog",
|
||||
sender_type: "client",
|
||||
content_kind: "text",
|
||||
text: "Тест",
|
||||
attachments: [],
|
||||
safety_status: "allowed",
|
||||
delivery_status: status,
|
||||
created_at: createdAt,
|
||||
});
|
||||
|
||||
describe("reconcileMessages", () => {
|
||||
it("устраняет дубли, обновляет статус и сортирует сообщения", () => {
|
||||
const result = reconcileMessages(
|
||||
[message("2", "2026-01-02T00:00:00Z"), message("1", "2026-01-01T00:00:00Z")],
|
||||
[message("2", "2026-01-02T00:00:00Z", "delivered"), message("3", "2026-01-03T00:00:00Z")],
|
||||
);
|
||||
expect(result.map((item) => item.message_id)).toEqual(["1", "2", "3"]);
|
||||
expect(result[1]?.delivery_status).toBe("delivered");
|
||||
});
|
||||
|
||||
it("сортирует realtime-сообщения по абсолютному времени при разных часовых поясах", () => {
|
||||
const result = reconcileMessages(
|
||||
[message("client", "2026-07-21T15:48:00+03:00")],
|
||||
[message("company", "2026-07-21T12:49:00Z")],
|
||||
);
|
||||
|
||||
expect(result.map((item) => item.message_id)).toEqual(["client", "company"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UX session memory", () => {
|
||||
it("хранит идентификатор только в памяти и очищает его", () => {
|
||||
sessionMemory.set("ux-test");
|
||||
expect(sessionMemory.id).toBe("ux-test");
|
||||
sessionMemory.clear();
|
||||
expect(sessionMemory.id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pending message intent", () => {
|
||||
it("сохраняет ключи повтора и очищается после завершения", async () => {
|
||||
const intent = createPendingTextIntent("Сообщение после входа");
|
||||
await savePendingTextIntent(intent);
|
||||
|
||||
expect(loadPendingTextIntent()).toEqual(intent);
|
||||
|
||||
await clearPendingTextIntent();
|
||||
expect(loadPendingTextIntent()).toBeNull();
|
||||
});
|
||||
|
||||
it("игнорирует повреждённое значение", async () => {
|
||||
await clearPendingTextIntent();
|
||||
secureValues.set("han.pending-message", "{\"text\":42}");
|
||||
await restorePendingIntents();
|
||||
expect(loadPendingTextIntent()).toBeNull();
|
||||
await clearPendingTextIntent();
|
||||
});
|
||||
|
||||
it("привязывает отложенный текст и файл к созданному диалогу", async () => {
|
||||
const textIntent = createPendingTextIntent("После входа");
|
||||
await bindPendingTextIntent(textIntent, "dialog-1");
|
||||
const file = { uri: "file:///cache/test.txt", name: "test.txt", mimeType: "text/plain", size: 4 };
|
||||
await savePendingFileIntent({ file, dialogKey: "file-dialog-key", messageKey: "file-key" });
|
||||
await bindPendingFileIntent("dialog-1");
|
||||
|
||||
expect(loadPendingTextIntent()?.dialogId).toBe("dialog-1");
|
||||
expect(loadPendingFileIntent("dialog-1")?.file).toBe(file);
|
||||
|
||||
await clearPendingTextIntent();
|
||||
await clearPendingFileIntent();
|
||||
});
|
||||
});
|
||||
|
||||
describe("native file descriptor", () => {
|
||||
it("нормализует поля DocumentPicker и подставляет безопасные значения", () => {
|
||||
expect(toNativeFile({
|
||||
uri: "file:///cache/document",
|
||||
name: "document",
|
||||
mimeType: null,
|
||||
size: null,
|
||||
})).toEqual({
|
||||
uri: "file:///cache/document",
|
||||
name: "document",
|
||||
mimeType: "application/octet-stream",
|
||||
size: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("message UX rules", () => {
|
||||
it("нормализует текст и проверяет динамический лимит", () => {
|
||||
expect(normalizeMessageText(" A ")).toBe("A");
|
||||
expect(messageFitsLimit("1234", 4)).toBe(true);
|
||||
expect(messageFitsLimit("12345", 4)).toBe(false);
|
||||
});
|
||||
|
||||
it("отличает блокировку message-safety от технической ошибки", () => {
|
||||
expect(isMessageBlockedError(new ApiError(422, "message_blocked", "blocked"))).toBe(true);
|
||||
expect(isMessageBlockedError(new ApiError(503, "dependency_unavailable", "failed"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SingleFlight", () => {
|
||||
it("объединяет параллельные refresh операции", async () => {
|
||||
const flight = new SingleFlight<number>();
|
||||
let calls = 0;
|
||||
const operation = async () => {
|
||||
calls++;
|
||||
await Promise.resolve();
|
||||
return 42;
|
||||
};
|
||||
const [first, second, third] = await Promise.all([
|
||||
flight.run(operation), flight.run(operation), flight.run(operation),
|
||||
]);
|
||||
expect([first, second, third]).toEqual([42, 42, 42]);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebSocket authentication protocol", () => {
|
||||
it("кодирует JWT как canonical han.jwt.<base64url(jwt)>", () => {
|
||||
const protocol = websocketJwtProtocol("header.payload.signature");
|
||||
expect(protocol).toBe("han.jwt.aGVhZGVyLnBheWxvYWQuc2lnbmF0dXJl");
|
||||
expect(protocol).not.toContain("=");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OIDC device metadata", () => {
|
||||
it("создаёт стабильный Android UUID и передаёт доступные han_* поля", async () => {
|
||||
const values = new Map<string, string>();
|
||||
const store = {
|
||||
get: async (key: string) => values.get(key) ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
};
|
||||
|
||||
const first = await buildOidcDeviceMetadata(store);
|
||||
const second = await buildOidcDeviceMetadata(store);
|
||||
|
||||
expect(first.han_device_id).toBe("123e4567-e89b-42d3-a456-426614174000");
|
||||
expect(second.han_device_id).toBe(first.han_device_id);
|
||||
expect(first).toMatchObject({
|
||||
han_fingerprint: "stable-fingerprint",
|
||||
han_platform: "android",
|
||||
han_app_version: "1.0.0",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../src/auth", () => ({
|
||||
getAccessToken: () => "test.jwt",
|
||||
refreshTokens: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("../../src/config", () => ({
|
||||
env: { apiBaseUrl: "https://example.test" },
|
||||
}));
|
||||
vi.mock("../../src/services", () => ({
|
||||
dialogApi: { messages: vi.fn(async () => ({ items: [], next_cursor: null })) },
|
||||
}));
|
||||
vi.mock("react-native", () => ({
|
||||
AppState: { currentState: "active" },
|
||||
}));
|
||||
vi.mock("expo-linking", () => ({
|
||||
openURL: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
import {
|
||||
NOTIFICATION_OUTAGE_MS,
|
||||
NOTIFICATION_POLL_INTERVAL_MS,
|
||||
NotificationRealtimeClient,
|
||||
} from "../../src/realtime";
|
||||
import {
|
||||
actionDialogId,
|
||||
actionUrl,
|
||||
formatNotificationPrice,
|
||||
notificationIcon,
|
||||
notificationPalette,
|
||||
typeMap,
|
||||
} from "../../src/notification-presenter";
|
||||
import type { NotificationType } from "../../src/types";
|
||||
|
||||
class MockWebSocket {
|
||||
static readonly OPEN = 1;
|
||||
static instances: MockWebSocket[] = [];
|
||||
readonly sent: string[] = [];
|
||||
readyState = MockWebSocket.OPEN;
|
||||
onopen?: () => void;
|
||||
onmessage?: (event: { data: string }) => void;
|
||||
onclose?: (event: { code: number }) => void;
|
||||
onerror?: () => void;
|
||||
|
||||
constructor(readonly url: string, readonly protocols: string[]) {
|
||||
MockWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
send(value: string) {
|
||||
this.sent.push(value);
|
||||
}
|
||||
|
||||
close() {}
|
||||
}
|
||||
|
||||
describe("notification catalog presentation", () => {
|
||||
it("использует neutral и bell для неизвестных значений", () => {
|
||||
expect(notificationPalette("future-token")).toEqual(notificationPalette("neutral"));
|
||||
expect(notificationIcon("future-icon")).toBe("bell");
|
||||
expect(notificationIcon(null)).toBe("bell");
|
||||
});
|
||||
|
||||
it("рендерит новый вид только по данным каталога", () => {
|
||||
const type: NotificationType = {
|
||||
code: "future_type",
|
||||
label: "Новый вид",
|
||||
color_token: "info",
|
||||
icon_code: "news",
|
||||
cta_text: "Открыть",
|
||||
cta_action: "open_detail",
|
||||
countable: true,
|
||||
contour: "P",
|
||||
button_primary: { code: "gotit", label: "Понятно" },
|
||||
button_secondary: null,
|
||||
};
|
||||
expect(typeMap([type]).get("future_type")).toEqual(type);
|
||||
expect(notificationPalette(type.color_token).accent).toBe("#2563eb");
|
||||
});
|
||||
|
||||
it("форматирует цену в рублях и безопасно игнорирует мусор", () => {
|
||||
expect(formatNotificationPrice("1500")).toContain("1 500");
|
||||
expect(formatNotificationPrice("not-a-number")).toBeNull();
|
||||
});
|
||||
|
||||
it("читает результат CTA из backend state response", () => {
|
||||
const base = {
|
||||
notification_id: "notification-1",
|
||||
lifecycle_status: "active" as const,
|
||||
visibility: "visible" as const,
|
||||
is_read: true,
|
||||
close_reason: null,
|
||||
date_expired: null,
|
||||
unread_count: 0,
|
||||
};
|
||||
expect(actionUrl({ ...base, result: { action: "open_url", url: "https://pay.test" } }))
|
||||
.toBe("https://pay.test");
|
||||
expect(actionDialogId({
|
||||
...base,
|
||||
result: {
|
||||
action: "chat_message_sent",
|
||||
message: {
|
||||
message_id: "message-1",
|
||||
dialog_id: "dialog-1",
|
||||
sender_type: "client",
|
||||
content_kind: "text",
|
||||
text: "Тест",
|
||||
attachments: [],
|
||||
safety_status: "allowed",
|
||||
delivery_status: "delivered",
|
||||
created_at: "2026-07-27T12:00:00Z",
|
||||
},
|
||||
},
|
||||
})).toBe("dialog-1");
|
||||
expect(actionUrl({ ...base, result: null })).toBeUndefined();
|
||||
expect(actionDialogId({ ...base, result: null })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("notification realtime degradation", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
MockWebSocket.instances = [];
|
||||
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("подписывается с notifications:true", () => {
|
||||
const client = new NotificationRealtimeClient(vi.fn(), vi.fn(async () => undefined), vi.fn());
|
||||
client.start();
|
||||
const socket = MockWebSocket.instances[0]!;
|
||||
socket.onopen?.();
|
||||
expect(JSON.parse(socket.sent[0]!)).toEqual({
|
||||
type: "subscribe",
|
||||
dialog_ids: [],
|
||||
notifications: true,
|
||||
});
|
||||
client.stop();
|
||||
});
|
||||
|
||||
it("после 30 секунд включает polling с интервалом 60 секунд", async () => {
|
||||
const reconcile = vi.fn(async () => undefined);
|
||||
const state = vi.fn();
|
||||
const client = new NotificationRealtimeClient(vi.fn(), reconcile, state);
|
||||
client.start();
|
||||
MockWebSocket.instances[0]!.onclose?.({ code: 1006 });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(NOTIFICATION_OUTAGE_MS);
|
||||
expect(state).toHaveBeenCalledWith("polling");
|
||||
expect(reconcile).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(NOTIFICATION_POLL_INTERVAL_MS);
|
||||
expect(reconcile).toHaveBeenCalledTimes(2);
|
||||
client.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noImplicitOverride": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"types": [
|
||||
"vitest/globals"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"app",
|
||||
"src",
|
||||
"tests",
|
||||
"app.config.ts",
|
||||
"expo-env.d.ts",
|
||||
".expo/types/**/*.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
define: { __DEV__: false },
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
include: ["tests/unit/**/*.test.{ts,tsx}"],
|
||||
clearMocks: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user