Первая версия мобильного приложения
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
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<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 [retryPending, setRetryPending] = useState<(() => void) | undefined>();
|
||||
const pendingStarted = useRef<string | undefined>(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 (
|
||||
<ScreenShell>
|
||||
<ChatScreenHeader title="Чат" subtitle="Требуется вход" />
|
||||
<GuestAuthGate
|
||||
icon="message-circle"
|
||||
title="Чат доступен после входа"
|
||||
description="Авторизуйтесь, чтобы переписываться с оператором и получать ответы."
|
||||
/>
|
||||
</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}
|
||||
keyboardDismissMode="on-drag"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
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={() => void chooseFile()}
|
||||
onChangeText={setText}
|
||||
onSubmit={() => void sendText()}
|
||||
placeholder="Напишите сообщение..."
|
||||
sending={sending}
|
||||
value={text}
|
||||
maxLength={config.data?.messages?.max_text_length ?? DEFAULT_MESSAGE_MAX_LENGTH}
|
||||
/>
|
||||
{error && (
|
||||
<View style={{ paddingHorizontal: spacing.lg, paddingBottom: spacing.sm }}>
|
||||
<ErrorNotice
|
||||
error={error}
|
||||
{...(retryPending ? { retry: retryPending } : {})}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user