import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useLocalSearchParams, useRouter } from "expo-router"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { FlatList, Platform, Text, View } from "react-native"; import { useApp } from "../../src/app-context"; import { ChatInputBar } from "../../src/components/ChatInputBar"; import { ChatScreenHeader } from "../../src/components/ChatScreenHeader"; 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 { 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 = { 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>(null); const [text, setText] = useState(""); const [error, setError] = useState(); const [sending, setSending] = useState(false); const [retryPending, setRetryPending] = useState<(() => void) | undefined>(); const pendingStarted = useRef(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") realtime.start(); return () => 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 = () => { if (Platform.OS !== "web") { setError(new Error("Выбор файла в этой тестовой сборке доступен в web.")); return; } const input = document.createElement("input"); input.type = "file"; input.accept = "image/*,application/pdf"; input.onchange = () => { const file = input.files?.[0]; if (file) void sendFile(file); }; input.click(); }; const sendFile = async (file: File, 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.type)) { 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) { clearPendingTextIntent(); clearPendingFileIntent(); setRetryPending(undefined); return; } setRetryPending(() => () => { setRetryPending(undefined); void submit(); }); }; void submit(); }, [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 ( Для просмотра чата требуется авторизация.