Добавлены уведомления

This commit is contained in:
mi
2026-07-27 17:36:53 +03:00
parent a072005164
commit 958fba5f3e
149 changed files with 6371 additions and 110 deletions
@@ -1,11 +1,21 @@
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import React from "react";
import React, { useEffect } 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() {
useEffect(() => {
if (typeof window === "undefined") return;
const capture = (event: Event) => {
event.preventDefault();
(window as typeof window & { __hanInstallPrompt?: Event }).__hanInstallPrompt = event;
};
window.addEventListener("beforeinstallprompt", capture);
return () => window.removeEventListener("beforeinstallprompt", capture);
}, []);
return <SafeAreaProvider>
<AppProvider>
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
@@ -1,12 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import React, { useState } from "react";
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";
@@ -29,8 +30,18 @@ export default function HomeScreen() {
const [message, setMessage] = useState("");
const [sendError, setSendError] = useState<unknown>();
const [sending, setSending] = 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 sendAuthenticated = async (intent: PendingTextIntent) => {
setSending(true);
setSendError(undefined);
@@ -126,8 +137,11 @@ export default function HomeScreen() {
try {
const authorized = await authorize(consents);
if (authorized && pending) await sendAuthenticated(pending);
else if (authorized && afterNotificationAuth) await afterNotificationAuth();
} catch (error) {
setSendError(error);
} finally {
setAfterNotificationAuth(undefined);
}
};
@@ -149,6 +163,15 @@ export default function HomeScreen() {
{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
@@ -160,7 +183,7 @@ export default function HomeScreen() {
value={message}
/>
<QuickActions phone={config.data?.operator.call_phone} />
{sendError && (
{Boolean(sendError) && (
<View style={{ paddingHorizontal: 16, paddingBottom: 8 }}>
<ErrorNotice error={sendError} />
</View>
@@ -174,6 +197,7 @@ export default function HomeScreen() {
onCancel={() => {
setConsentOpen(false);
setPending(null);
setAfterNotificationAuth(undefined);
clearPendingTextIntent();
}}
/>
@@ -0,0 +1,286 @@
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 { Platform, 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, openNewTab, typeMap } from "../../src/notification-presenter";
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 = () => {
if (Platform.OS !== "web") {
setError(new Error("Выбор файла в этой тестовой сборке доступен только в web."));
return;
}
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.accept = (config.data?.attachments.allowed_mime_types ?? []).join(",");
input.onchange = () => {
const files = Array.from(input.files ?? []);
if (files.length) void uploadFiles(files);
};
input.click();
};
const uploadFiles = async (files: File[]) => {
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.type)));
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);
openNewTab(result.download_url);
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={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,107 @@
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 { 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 { Button, 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 ? (
<View style={local.gate}>
<View style={local.bell}>
<Feather name="bell" size={34} color={colors.primaryForeground} />
</View>
<Text style={styles.title}>Уведомления доступны после входа</Text>
<Text style={[styles.text, local.centerText]}>
Авторизуйтесь, чтобы видеть важные напоминания, документы и статусы услуг.
</Text>
<Button
title="Авторизоваться"
onPress={() => router.replace({ pathname: "/", params: { authorize: "1" } })}
/>
</View>
) : (
<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" },
gate: { flex: 1, justifyContent: "center", alignItems: "center", gap: spacing.md, padding: spacing.xl },
bell: { width: 68, height: 68, borderRadius: radii.full, alignItems: "center", justifyContent: "center", backgroundColor: colors.primary },
centerText: { textAlign: "center" },
content: { padding: spacing.lg, gap: spacing.md, paddingBottom: 40 },
empty: { alignItems: "center", gap: spacing.sm, paddingVertical: 48 },
});
@@ -109,7 +109,7 @@ export default function ProfileScreen() {
)}
<Button title="Выйти из аккаунта" danger onPress={() => void app.signOut()} />
{downloadError && <ErrorNotice error={downloadError} />}
{Boolean(downloadError) && <ErrorNotice error={downloadError} />}
</View>
</ScrollView>
</ScreenShell>
@@ -1,11 +1,13 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { AppState, Platform } from "react-native";
import { beginAuthorization, clearTokens, completeAuthorization, configureAuthFailure, getAccessToken, logout, refreshTokens } from "./auth";
import { sessionMemory } from "./api";
import { authApi, publicApi } from "./services";
import type { Consents } from "./types";
import { notificationApi, notificationKeys } from "./notification-api";
import { NotificationRealtimeClient } from "./realtime";
import type { Consents, NotificationItem, NotificationRealtimeEvent } from "./types";
type AuthStatus = "guest" | "authorizing" | "bootstrapping" | "authenticated";
type AppContextValue = {
@@ -105,7 +107,14 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
signOut: async () => { await logout(); toGuest(); router.replace("/"); },
}), [authStatus, realtimeState, authorize, finishCallback, ensureSession, toGuest, router]);
return <QueryClientProvider client={queryClient}><Context.Provider value={value}>{children}</Context.Provider></QueryClientProvider>;
return (
<QueryClientProvider client={queryClient}>
<Context.Provider value={value}>
<NotificationRealtimeBridge authenticated={authStatus === "authenticated"} onState={setRealtimeState} />
{children}
</Context.Provider>
</QueryClientProvider>
);
}
export function useApp() {
@@ -118,3 +127,47 @@ export async function resetAuthForTests() {
await clearTokens();
queryClient.clear();
}
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);
realtime.start();
return () => realtime.stop();
}, [authenticated, client, onState]);
return null;
}
@@ -1,16 +1,35 @@
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({ guestLabel }: { guestLabel?: string }) {
export function AppHeader({ guestLabel }: { 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="/dialogs" asChild>
<Pressable accessibilityRole="link" style={({ pressed }) => [styles.historyLink, pressed && styles.pressed]}>
<Feather name="clock" size={20} color={colors.foreground} />
<Text style={styles.historyText}>История</Text>
<Link href="/notifications" asChild>
<Pressable 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>
<Text style={styles.centerText}>Центр</Text>
</Pressable>
</Link>
@@ -38,8 +57,23 @@ const styles = StyleSheet.create({
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
historyLink: { flexDirection: "row", alignItems: "center", gap: spacing.sm },
historyText: { fontSize: 14, color: colors.foreground },
centerLink: { flexDirection: "row", alignItems: "center", gap: spacing.sm },
centerText: { fontSize: 14, color: colors.foreground },
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" },
right: { flexDirection: "row", alignItems: "center", gap: spacing.sm },
guestBadge: { fontSize: 12, color: colors.mutedForeground },
avatar: {
@@ -13,8 +13,8 @@ const statusLabel: Record<string, string> = {
type Props = {
message: Message;
getAttachmentUrl?: (attachmentId: string) => Promise<string>;
onAttachmentError?: (error: unknown) => void;
getAttachmentUrl?: ((attachmentId: string) => Promise<string>) | undefined;
onAttachmentError?: ((error: unknown) => void) | undefined;
};
export function MessageBubble({ message, getAttachmentUrl, onAttachmentError }: Props) {
@@ -54,9 +54,9 @@ export function MessageBubble({ message, getAttachmentUrl, onAttachmentError }:
function AttachmentPreview({ attachment, getUrl, isClient, onError }: {
attachment: Attachment;
getUrl?: (attachmentId: string) => Promise<string>;
getUrl?: ((attachmentId: string) => Promise<string>) | undefined;
isClient: boolean;
onError?: (error: unknown) => void;
onError?: ((error: unknown) => void) | undefined;
}) {
const [previewUrl, setPreviewUrl] = useState<string>();
const [previewFailed, setPreviewFailed] = useState(false);
@@ -0,0 +1,98 @@
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 { radii, spacing } from "../theme";
import type { NotificationItem, NotificationType } from "../types";
export function NotificationCard({
item,
type,
onCta,
onHide,
compact = false,
disabled = false,
}: {
item: NotificationItem;
type: NotificationType | undefined;
onCta: () => void;
onHide?: () => void;
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,
{ backgroundColor: palette.background, borderColor: `${palette.accent}40` },
]}>
<View style={local.headerRow}>
<View style={local.labelRow}>
<Feather name={notificationIcon(type?.icon_code)} size={18} color={palette.accent} />
<Text style={[local.label, { color: palette.foreground }]}>{type?.label ?? "Уведомление"}</Text>
{type?.countable && item.is_read === false && <View accessibilityLabel="Непрочитано" style={[local.unread, { backgroundColor: palette.accent }]} />}
</View>
{onHide && (
<Pressable accessibilityLabel="Скрыть уведомление" accessibilityRole="button" hitSlop={10} onPress={onHide}>
<Feather name="x" size={18} color={palette.foreground} />
</Pressable>
)}
</View>
<Text style={[local.title, { color: palette.foreground }]}>{item.header}</Text>
{item.text ? <Text numberOfLines={compact ? 2 : 3} style={[local.text, { color: palette.foreground }]}>{item.text}</Text> : null}
{deadline ? (
<View style={local.deadline}>
<Feather name="clock" size={14} color={palette.foreground} />
<Text style={[local.meta, { color: palette.foreground }]}>до {new Date(deadline).toLocaleDateString("ru-RU")}</Text>
</View>
) : null}
{price ? (
<View style={local.priceRow}>
<Text style={[local.price, { color: palette.foreground }]}>{price}</Text>
{oldPrice ? <Text style={[local.oldPrice, { color: palette.foreground }]}>{oldPrice}</Text> : null}
</View>
) : null}
<Pressable
accessibilityRole="button"
disabled={disabled}
onPress={onCta}
style={({ pressed }) => [local.cta, { backgroundColor: palette.accent }, pressed && local.pressed, disabled && local.disabled]}
>
<Text style={local.ctaText}>{type?.cta_text ?? "Подробнее →"}</Text>
</Pressable>
</View>
);
}
const local = StyleSheet.create({
card: {
width: 326,
minHeight: 190,
borderWidth: 1,
borderRadius: radii.xl,
padding: spacing.lg,
gap: spacing.sm,
},
compact: { width: "100%", minHeight: 0 },
headerRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
labelRow: { flexDirection: "row", alignItems: "center", gap: spacing.sm, flexShrink: 1 },
label: { fontSize: 12, fontWeight: "600", textTransform: "uppercase", letterSpacing: 0.4 },
unread: { width: 8, height: 8, borderRadius: radii.full },
title: { fontSize: 18, lineHeight: 23, fontWeight: "600" },
text: { fontSize: 14, lineHeight: 20, opacity: 0.88 },
deadline: { flexDirection: "row", alignItems: "center", gap: spacing.xs },
meta: { fontSize: 12 },
priceRow: { flexDirection: "row", alignItems: "baseline", gap: spacing.sm },
price: { fontSize: 18, fontWeight: "700" },
oldPrice: { fontSize: 13, textDecorationLine: "line-through", opacity: 0.65 },
cta: { alignSelf: "flex-start", minHeight: 38, justifyContent: "center", borderRadius: radii.md, paddingHorizontal: spacing.md, marginTop: "auto" },
ctaText: { color: "#ffffff", fontSize: 14, fontWeight: "600" },
pressed: { opacity: 0.78 },
disabled: { opacity: 0.5 },
});
@@ -0,0 +1,121 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { FlatList, StyleSheet, Text, View } from "react-native";
import { notificationApi, notificationKeys } from "../notification-api";
import { useNotificationAction } from "../notification-actions";
import { typeMap } from "../notification-presenter";
import { colors, spacing } from "../theme";
import type { NotificationItem } from "../types";
import { ErrorNotice, Loading, styles } from "../ui";
import { NotificationCard } from "./NotificationCard";
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 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 ?? [];
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]);
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}>
<Text accessibilityRole="header" style={[styles.heading, local.heading]}>Важное для вас</Text>
<FlatList
ref={list}
horizontal
data={data}
keyExtractor={(item) => item.id}
contentContainerStyle={local.content}
ItemSeparatorComponent={() => <View style={{ width: spacing.md }} />}
onMomentumScrollEnd={(event) => {
const width = event.nativeEvent.layoutMeasurement.width;
if (width > 0) setActiveIndex(Math.round(event.nativeEvent.contentOffset.x / width));
}}
renderItem={({ item }) => (
<NotificationCard
disabled={hide.isPending}
item={item}
type={byCode.get(item.notification_type)}
onCta={() => void action(item, byCode.get(item.notification_type))}
{...(authenticated ? { onHide: () => hide.mutate(item.id) } : {})}
/>
)}
showsHorizontalScrollIndicator={false}
/>
{data.length > 1 && (
<View style={local.dots} accessibilityLabel={`${activeIndex + 1} из ${data.length}`}>
{data.map((item, index) => <View key={item.id} style={[local.dot, index === activeIndex && local.dotActive]} />)}
</View>
)}
{Boolean(actionError) && <View style={local.error}><ErrorNotice error={actionError} /></View>}
</View>
);
}
const local = StyleSheet.create({
section: { paddingVertical: spacing.md },
heading: { paddingHorizontal: spacing.lg, marginBottom: spacing.sm },
content: { paddingHorizontal: spacing.lg },
state: { paddingHorizontal: spacing.lg, paddingVertical: spacing.md },
dots: { flexDirection: "row", justifyContent: "center", gap: 6, marginTop: spacing.sm },
dot: { width: 6, height: 6, borderRadius: 3, backgroundColor: colors.muted },
dotActive: { width: 16, backgroundColor: colors.primary },
error: { paddingHorizontal: spacing.lg, paddingTop: spacing.sm },
});
@@ -3,7 +3,7 @@ import React from "react";
import { Linking, Pressable, StyleSheet, Text } from "react-native";
import { colors, radii, spacing } from "../theme";
export function QuickActions({ phone }: { phone?: string }) {
export function QuickActions({ phone }: { phone?: string | undefined }) {
const call = () => {
if (phone) void Linking.openURL(`tel:${phone}`);
};
@@ -0,0 +1,96 @@
import { useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useCallback } from "react";
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";
type InstallPromptEvent = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
};
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);
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);
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) {
const event = typeof window !== "undefined"
? (window as typeof window & { __hanInstallPrompt?: InstallPromptEvent }).__hanInstallPrompt
: undefined;
if (event) {
await event.prompt();
const choice = await event.userChoice;
if (choice.outcome === "accepted") return;
}
if (!instructionUrl) throw new Error("Инструкция по установке недоступна");
openNewTab(instructionUrl);
}
@@ -0,0 +1,108 @@
import { apiRequest, json } from "./api";
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: File) => {
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.type,
size_bytes: file.size,
}),
});
const upload = await fetch(draft.upload_url, {
method: "PUT",
headers: draft.upload_headers ?? { "Content-Type": file.type },
body: file,
});
if (!upload.ok) throw new Error("Не удалось загрузить файл в хранилище");
await apiRequest<UploadDraft>(`/api/v1/uploads/${encodeURIComponent(draft.draft_id)}/complete`, {
method: "POST",
protected: true,
body: json({ checksum }),
});
return draft.draft_id;
},
};
async function sha256(file: Blob) {
const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
const hex = Array.from(
new Uint8Array(digest),
(byte) => byte.toString(16).padStart(2, "0"),
).join("");
return `sha256:${hex}`;
}
@@ -0,0 +1,80 @@
import { Feather } from "@expo/vector-icons";
import type { NotificationActionState, NotificationType } from "./types";
export type NotificationPalette = {
background: string;
foreground: string;
accent: string;
};
const neutral: NotificationPalette = {
background: "#f3f3f5",
foreground: "#252525",
accent: "#030213",
};
const palettes: Record<string, NotificationPalette> = {
critical: { background: "#feecef", foreground: "#7f1d1d", accent: "#d4183d" },
warning: { background: "#fff7df", foreground: "#713f12", accent: "#ca8a04" },
success: { background: "#eaf8ee", foreground: "#14532d", accent: "#16a34a" },
info: { background: "#eaf2ff", foreground: "#1e3a8a", accent: "#2563eb" },
promo: { background: "#f4edff", foreground: "#4c1d95", accent: "#7c3aed" },
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) {
if (typeof window !== "undefined") {
window.open(url, "_blank", "noopener,noreferrer");
return;
}
void import("react-native").then(({ Linking }) => Linking.openURL(url));
}
@@ -1,7 +1,7 @@
import { env } from "./config";
import { getAccessToken, refreshTokens } from "./auth";
import { dialogApi } from "./services";
import type { Message } from "./types";
import type { Message, NotificationRealtimeEvent } from "./types";
export { reconcileMessages } from "./reconcile";
export type RealtimeState = "idle" | "connecting" | "websocket" | "polling";
@@ -10,6 +10,9 @@ export type RealtimeEvent =
| { 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]) => ({
@@ -26,8 +29,8 @@ export function websocketJwtProtocol(token: string) {
export class RealtimeClient {
private socket?: WebSocket;
private reconnectTimer?: ReturnType<typeof setTimeout>;
private pollingTimer?: ReturnType<typeof setTimeout>;
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
private pollingTimer: ReturnType<typeof setTimeout> | undefined;
private disconnectedAt = 0;
private attempt = 0;
private stopped = true;
@@ -143,3 +146,114 @@ export class RealtimeClient {
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;
}
}
@@ -56,6 +56,10 @@ export type DocumentItem = {
export type PublicConfig = {
auth: { phone_enabled: boolean; password_enabled: boolean };
operator: { call_phone: string };
notification?: {
carousel_autoplay_enabled?: boolean;
carousel_autoplay_interval_ms?: number;
};
consents: Record<string, {
required: boolean;
document_url: string | null;
@@ -75,3 +79,134 @@ export type PublicContent = {
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;
};
@@ -35,9 +35,9 @@ export function Button({ title, onPress, disabled, secondary, danger }: {
accessibilityRole="button"
disabled={disabled}
onPress={onPress}
style={({ focused }) => [
style={({ pressed }) => [
styles.button, secondary && styles.buttonSecondary, danger && styles.buttonDanger,
disabled && styles.buttonDisabled, focused && { borderWidth: 2, borderColor: colors.primary },
disabled && styles.buttonDisabled, pressed && { opacity: 0.8 },
]}
>
<Text style={[styles.buttonText, secondary && styles.buttonTextSecondary]}>{title}</Text>
@@ -8,6 +8,17 @@ vi.mock("expo-crypto", () => ({
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 () => null),
setItemAsync: vi.fn(async () => undefined),
deleteItemAsync: vi.fn(async () => undefined),
}));
vi.mock("expo-web-browser", () => ({
maybeCompleteAuthSession: vi.fn(),
}));
vi.mock("react-native", () => ({
Platform: { OS: "web", Version: "test", constants: {} },
}));
@@ -0,0 +1,152 @@
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 })) },
}));
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();
});
});
@@ -6,8 +6,7 @@
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] },
"paths": { "@/*": ["./src/*"] },
"types": ["vitest/globals"]
},
"include": ["app", "src", "tests", "app.config.ts", "expo-env.d.ts"]
@@ -3,6 +3,7 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
define: { __DEV__: false },
test: {
environment: "jsdom",
include: ["tests/unit/**/*.test.{ts,tsx}"],