Files
han-app/codebase/backend/frontend-test-site/app/dialogs/[dialogId].tsx
T

185 lines
7.8 KiB
TypeScript

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 { 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 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 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 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 subtitle="Требуется вход" />
<View style={{ flex: 1, padding: spacing.lg, justifyContent: "center" }}>
<Text style={styles.error}>Для просмотра чата требуется авторизация.</Text>
<Button title="На главную" secondary onPress={() => router.replace("/")} />
</View>
</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}
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={chooseFile}
onChangeText={setText}
onSubmit={() => void sendText()}
placeholder="Напишите сообщение..."
sending={sending}
value={text}
/>
{error && (
<View style={{ paddingHorizontal: spacing.lg, paddingBottom: spacing.sm }}>
<ErrorNotice error={error} />
</View>
)}
</>
)}
</View>
</ScreenShell>
);
}