150 lines
7.7 KiB
TypeScript
150 lines
7.7 KiB
TypeScript
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
|
import React, { useEffect, useMemo, useState } from "react";
|
|
import { Platform, ScrollView, Text, View } from "react-native";
|
|
import { useApp } from "../../src/app-context";
|
|
import { RealtimeClient, reconcileMessages } from "../../src/realtime";
|
|
import { dialogApi, profileApi, publicApi, uploadAttachment } from "../../src/services";
|
|
import type { Message } from "../../src/types";
|
|
import { Button, ErrorNotice, Field, Header, Loading, styles } from "../../src/ui";
|
|
|
|
const statusLabel: Record<string, string> = {
|
|
open: "Открыт",
|
|
waiting_for_company: "Ожидает ответа компании",
|
|
waiting_for_client: "Ожидает вашего ответа",
|
|
closed: "Закрыт",
|
|
accepted: "Принято",
|
|
delivered: "Доставлено",
|
|
failed: "Ошибка доставки",
|
|
rejected: "Отклонено",
|
|
};
|
|
|
|
export default function ChatScreen() {
|
|
const { dialogId } = useLocalSearchParams<{ dialogId: string }>();
|
|
const app = useApp();
|
|
const client = useQueryClient();
|
|
const router = useRouter();
|
|
const [text, setText] = useState("");
|
|
const [error, setError] = useState<unknown>();
|
|
const [sending, setSending] = useState(false);
|
|
const [creating, setCreating] = useState(false);
|
|
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) =>
|
|
old ? { ...old, items: reconcileMessages(old.items, incoming) } : { items: incoming, next_cursor: null },
|
|
);
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (app.authStatus === "authenticated") realtime.start();
|
|
return () => realtime.stop();
|
|
}, [realtime, app.authStatus]);
|
|
|
|
const sendText = async () => {
|
|
const normalized = text.trim();
|
|
if (!normalized || !dialogId) return;
|
|
setSending(true); setError(undefined);
|
|
try {
|
|
const message = await dialogApi.sendText(dialogId, normalized, crypto.randomUUID());
|
|
merge([message]); setText("");
|
|
} catch (reason) { setError(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) => {
|
|
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, crypto.randomUUID());
|
|
merge([message]);
|
|
} catch (reason) { setError(reason); }
|
|
finally { setSending(false); }
|
|
};
|
|
|
|
const downloadAttachment = async (attachmentId: string) => {
|
|
try {
|
|
const result = await profileApi.attachmentUrl(dialogId, attachmentId);
|
|
if (typeof window !== "undefined") window.location.assign(result.download_url);
|
|
} catch (reason) { setError(reason); }
|
|
};
|
|
|
|
const createDialog = async () => {
|
|
setCreating(true); setError(undefined);
|
|
try {
|
|
const created = await dialogApi.create(crypto.randomUUID());
|
|
router.replace(`/dialogs/${created.dialog_id}`);
|
|
} catch (reason) { setError(reason); }
|
|
finally { setCreating(false); }
|
|
};
|
|
|
|
const closed = dialog.data?.status === "closed";
|
|
return <ScrollView contentContainerStyle={styles.page}>
|
|
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={app.authStatus === "authenticated" ? () => void app.signOut() : undefined} />
|
|
<Text accessibilityRole="header" style={styles.title}>Чат</Text>
|
|
{app.authStatus !== "authenticated" && <Text style={styles.error}>Для просмотра чата требуется авторизация.</Text>}
|
|
{(dialog.isLoading || messages.isLoading) && <Loading />}
|
|
{(dialog.error || messages.error) && <ErrorNotice error={dialog.error ?? messages.error} retry={() => { void dialog.refetch(); void messages.refetch(); }} />}
|
|
<Text style={styles.badge}>Статус: {statusLabel[dialog.data?.status ?? ""] ?? "—"}</Text>
|
|
<View accessibilityLiveRegion="polite" style={{ gap: 10 }}>
|
|
{(messages.data?.items ?? []).map((message) => <View key={message.message_id} style={message.sender_type === "client" ? styles.messageClient : styles.messageCompany}>
|
|
<Text style={styles.text}>{message.content_kind === "file" ? `Файл: ${message.attachments[0]?.file_name ?? "вложение"}` : message.text}</Text>
|
|
{message.attachments.map((attachment) =>
|
|
<Button key={attachment.attachment_id} title="Скачать вложение" secondary onPress={() => void downloadAttachment(attachment.attachment_id)} />,
|
|
)}
|
|
<Text style={styles.muted}>{message.sender_type === "client" ? "Вы" : "Компания"} · {new Date(message.created_at).toLocaleString("ru-RU")} · {statusLabel[message.delivery_status] ?? message.delivery_status}</Text>
|
|
</View>)}
|
|
{!messages.isLoading && !messages.data?.items.length && <Text style={styles.muted}>Сообщений пока нет.</Text>}
|
|
</View>
|
|
{closed ? <View style={styles.card}>
|
|
<Text style={styles.muted}>Диалог закрыт и доступен только для чтения.</Text>
|
|
<Button title={creating ? "Создание…" : "Начать новый диалог"} disabled={creating} onPress={() => void createDialog()} />
|
|
{error && <ErrorNotice error={error} retry={() => void createDialog()} />}
|
|
</View> : <View style={styles.card}>
|
|
<Field label="Новое сообщение" multiline value={text} onChangeText={setText} />
|
|
<View style={styles.row}>
|
|
<Button title={sending ? "Отправка…" : "Отправить"} disabled={sending || !text.trim()} onPress={() => void sendText()} />
|
|
<Button title="Прикрепить изображение или PDF" secondary disabled={sending} onPress={chooseFile} />
|
|
</View>
|
|
{error && <ErrorNotice error={error} />}
|
|
</View>}
|
|
</ScrollView>;
|
|
}
|