Накатил человеческий дизайн
This commit is contained in:
@@ -1,22 +1,23 @@
|
||||
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 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, Field, Header, Loading, styles } from "../../src/ui";
|
||||
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_company: "Ожидает ответа",
|
||||
waiting_for_client: "Ожидает вашего ответа",
|
||||
closed: "Закрыт",
|
||||
accepted: "Принято",
|
||||
delivered: "Доставлено",
|
||||
failed: "Ошибка доставки",
|
||||
rejected: "Отклонено",
|
||||
};
|
||||
|
||||
export default function ChatScreen() {
|
||||
@@ -24,6 +25,7 @@ export default function ChatScreen() {
|
||||
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);
|
||||
@@ -48,9 +50,10 @@ export default function ChatScreen() {
|
||||
), [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 },
|
||||
);
|
||||
client.setQueryData(["messages", dialogId], (old: typeof messages.data) => ({
|
||||
items: reconcileMessages(old?.items ?? [], incoming),
|
||||
next_cursor: old?.next_cursor ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -58,6 +61,11 @@ export default function ChatScreen() {
|
||||
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;
|
||||
@@ -98,41 +106,79 @@ export default function ChatScreen() {
|
||||
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 getAttachmentUrl = useCallback(async (attachmentId: string) => {
|
||||
const result = await profileApi.attachmentUrl(dialogId, attachmentId);
|
||||
return result.download_url;
|
||||
}, [dialogId]);
|
||||
|
||||
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)} />,
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<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="Продолжить общение" onPress={() => router.replace("/dialogs")} />
|
||||
</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>;
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user