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

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

+7
View File
@@ -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-код запрещены.
+16
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
legacy-peer-deps=true
+28
View File
@@ -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.
+50
View File
@@ -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;
+17
View File
@@ -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>;
}
+63
View File
@@ -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>
);
}
+76
View File
@@ -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" },
});
+263
View File
@@ -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>
);
}
+84
View File
@@ -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>
);
}
+202
View File
@@ -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;
+284
View File
@@ -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 },
});
+145
View File
@@ -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 },
});
+27
View File
@@ -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": {}
}
}
+8967
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -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"
}
}
+97
View File
@@ -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 });
+190
View File
@@ -0,0 +1,190 @@
import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import * as SecureStore from "expo-secure-store";
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { AppState } from "react-native";
import { beginAuthorization, clearTokens, completeAuthorization, configureAuthFailure, getAccessToken, logout, refreshTokens } from "./auth";
import { sessionMemory } from "./api";
import { authApi, publicApi } from "./services";
import { notificationApi, notificationKeys } from "./notification-api";
import { NotificationRealtimeClient } from "./realtime";
import { restorePendingIntents } from "./pending-intent";
import type { Consents, NotificationItem, NotificationRealtimeEvent } from "./types";
const PENDING_CONSENTS_KEY = "han.pending-consents";
type AuthStatus = "guest" | "authorizing" | "bootstrapping" | "authenticated";
type AppContextValue = {
authStatus: AuthStatus;
realtimeState: string;
setRealtimeState: (value: string) => void;
authorize: (consents: Consents) => Promise<boolean>;
finishCallback: (code: string, state: string, consents: Consents) => Promise<void>;
signOut: () => Promise<void>;
ensureSession: () => Promise<void>;
};
const Context = createContext<AppContextValue | null>(null);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, staleTime: 15_000 } },
});
export function AppProvider({ children }: { children: React.ReactNode }) {
const [authStatus, setAuthStatus] = useState<AuthStatus>("guest");
const [realtimeState, setRealtimeState] = useState("idle");
const [idleTimeoutMs, setIdleTimeoutMs] = useState<number | null>(null);
const router = useRouter();
const toGuest = useCallback(() => {
sessionMemory.clear();
setRealtimeState("idle");
setAuthStatus("guest");
queryClient.clear();
}, []);
useEffect(() => {
configureAuthFailure(toGuest);
void publicApi.config().then((config) => {
const minutes = config.ux.idle_timeout_minutes;
if (minutes > 0) setIdleTimeoutMs(minutes * 60_000);
}).catch(() => undefined);
void restorePendingIntents().then(refreshTokens)
.then(async () => {
await authApi.startSession("cold_start");
setAuthStatus("authenticated");
})
.catch(() => setAuthStatus("guest"));
}, [toGuest]);
const finishCallback = useCallback(async (code: string, state: string, consents: Consents) => {
setAuthStatus("bootstrapping");
try {
if (!getAccessToken()) await completeAuthorization(code, state);
await authApi.bootstrap(consents);
await authApi.startSession("first_launch");
setAuthStatus("authenticated");
} catch (error) {
setAuthStatus("guest");
throw error;
}
}, []);
const authorize = useCallback(async (consents: Consents) => {
setAuthStatus("authorizing");
try {
await SecureStore.setItemAsync(PENDING_CONSENTS_KEY, JSON.stringify(consents));
const result = await beginAuthorization();
if (result.type !== "success" || typeof result.params.code !== "string" || typeof result.params.state !== "string") {
setAuthStatus("guest");
if (result.type !== "dismiss" && result.type !== "cancel") throw new Error("authorization_failed");
return false;
}
await finishCallback(result.params.code, result.params.state, consents);
await SecureStore.deleteItemAsync(PENDING_CONSENTS_KEY);
return true;
} catch (error) {
setAuthStatus("guest");
throw error;
}
}, [finishCallback]);
const ensureSession = useCallback(async () => {
if (authStatus !== "authenticated") return;
if (!sessionMemory.id) await authApi.startSession("cold_start");
else if (idleTimeoutMs !== null && Date.now() - sessionMemory.lastActivityAt > idleTimeoutMs) {
await authApi.startSession("idle_timeout");
}
sessionMemory.touch();
}, [authStatus, idleTimeoutMs]);
useEffect(() => {
const subscription = AppState.addEventListener("change", (state) => {
if (state === "active") void ensureSession();
});
return () => subscription.remove();
}, [ensureSession]);
const value = useMemo<AppContextValue>(() => ({
authStatus, realtimeState, setRealtimeState, authorize, finishCallback, ensureSession,
signOut: async () => { await logout(); toGuest(); router.replace("/"); },
}), [authStatus, realtimeState, authorize, finishCallback, ensureSession, toGuest, router]);
return (
<QueryClientProvider client={queryClient}>
<Context.Provider value={value}>
<NotificationRealtimeBridge authenticated={authStatus === "authenticated"} onState={setRealtimeState} />
{children}
</Context.Provider>
</QueryClientProvider>
);
}
export function useApp() {
const value = useContext(Context);
if (!value) throw new Error("AppProvider is missing");
return value;
}
export async function resetAuthForTests() {
await clearTokens();
queryClient.clear();
}
export async function loadPendingConsents() {
const raw = await SecureStore.getItemAsync(PENDING_CONSENTS_KEY);
return raw ? JSON.parse(raw) as Consents : null;
}
export const clearPendingConsents = () => SecureStore.deleteItemAsync(PENDING_CONSENTS_KEY);
function NotificationRealtimeBridge({
authenticated,
onState,
}: {
authenticated: boolean;
onState: (state: string) => void;
}) {
const client = useQueryClient();
useEffect(() => {
if (!authenticated) return;
const updateFromEvent = (event: NotificationRealtimeEvent) => {
client.setQueryData(notificationKeys.counter, { unread_count: event.unread_count });
if (event.type === "notification.updated") {
client.setQueryData(notificationKeys.detail(event.notification_id), (old: NotificationItem | undefined) =>
old ? { ...old, ...event } : old);
}
if (event.type === "notification.closed") {
client.setQueryData(notificationKeys.detail(event.notification_id), (old: NotificationItem | undefined) =>
old ? { ...old, lifecycle_status: "closed" as const } : old);
}
void client.invalidateQueries({ queryKey: ["notifications"] });
};
const reconcile = async () => {
const [home, center, counter] = await Promise.all([
notificationApi.list("home"),
notificationApi.list("center"),
notificationApi.counter(),
]);
client.setQueryData(notificationKeys.home(true), home);
client.setQueryData(notificationKeys.center, center);
client.setQueryData(notificationKeys.counter, counter);
};
const realtime = new NotificationRealtimeClient(updateFromEvent, reconcile, onState);
const syncRealtime = (state: string) => {
if (state === "active") realtime.start();
else realtime.stop();
};
syncRealtime(AppState.currentState);
const subscription = AppState.addEventListener("change", syncRealtime);
return () => {
subscription.remove();
realtime.stop();
};
}, [authenticated, client, onState]);
return null;
}
+204
View File
@@ -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 },
});
+16
View File
@@ -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";
+9
View File
@@ -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;
}
+96
View File
@@ -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: "Понятно" }],
);
}
+96
View File
@@ -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);
}
+62
View File
@@ -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 } : {}),
};
}
+117
View File
@@ -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;
}
}
+269
View File
@@ -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;
}
}
+12
View File
@@ -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);
});
}
+102
View File
@@ -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 },
),
};
+10
View File
@@ -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(); },
};
+11
View File
@@ -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;
}
}
+38
View File
@@ -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,
};
+218
View File
@@ -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;
};
+68
View File
@@ -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>;
}
+220
View File
@@ -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(" ")).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();
});
});
+26
View File
@@ -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"
]
}
+12
View File
@@ -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,
},
});