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 = { 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") 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 ( ); } return ( {(dialog.isLoading || messages.isLoading) && ( )} {(dialog.error || messages.error) && ( { void dialog.refetch(); void messages.refetch(); }} /> )} item.message_id} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" contentContainerStyle={{ paddingHorizontal: spacing.lg, paddingVertical: spacing.lg, flexGrow: 1 }} ListEmptyComponent={!messages.isLoading ? Сообщений пока нет. : null} renderItem={({ item }) => ( )} /> {closed ? ( Предыдущая беседа завершена.