Files
han-app/VM4_Expo-mobile/app/index.tsx
T

203 lines
7.7 KiB
TypeScript

import { useQuery } from "@tanstack/react-query";
import * as Crypto from "expo-crypto";
import { useLocalSearchParams, useRouter } from "expo-router";
import React, { useEffect, useRef, useState } from "react";
import { ScrollView, Text, View } from "react-native";
import { useApp } from "../src/app-context";
import { AppHeader } from "../src/components/AppHeader";
import { ChatInputBar } from "../src/components/ChatInputBar";
import { ConsentModal } from "../src/components/ConsentModal";
import { HanLogo } from "../src/components/HanLogo";
import { NotificationCarousel } from "../src/components/NotificationCarousel";
import { PopularQuestionsList } from "../src/components/PopularQuestionsList";
import { QuickActions } from "../src/components/QuickActions";
import { ScreenShell } from "../src/components/ScreenShell";
import {
clearPendingTextIntent,
createPendingTextIntent,
savePendingTextIntent,
savePendingFileIntent,
type PendingTextIntent,
} from "../src/pending-intent";
import { DEFAULT_MESSAGE_MAX_LENGTH, normalizeMessageText } from "../src/message-text";
import { pickFiles, type NativeFile } from "../src/native-files";
import { publicApi } from "../src/services";
import type { Consents } from "../src/types";
import { ErrorNotice, Loading, styles } from "../src/ui";
export default function HomeScreen() {
const { authStatus, authorize } = useApp();
const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config });
const content = useQuery({ queryKey: ["public-content"], queryFn: publicApi.content });
const [consentOpen, setConsentOpen] = useState(false);
const [pending, setPending] = useState<PendingTextIntent | null>(null);
const [message, setMessage] = useState("");
const [sendError, setSendError] = useState<unknown>();
const [sending] = useState(false);
const [afterNotificationAuth, setAfterNotificationAuth] = useState<(() => Promise<void>) | undefined>();
const { authorize: authorizeParam } = useLocalSearchParams<{ authorize?: string }>();
const handledAuthorizeParam = useRef(false);
const router = useRouter();
useEffect(() => {
if (authorizeParam === "1" && !handledAuthorizeParam.current && authStatus !== "authenticated") {
handledAuthorizeParam.current = true;
setConsentOpen(true);
}
}, [authStatus, authorizeParam]);
const openChatWithText = async (intent: PendingTextIntent) => {
setSendError(undefined);
await savePendingTextIntent(intent);
setMessage("");
setPending(null);
router.push("/dialogs?pending=1");
};
const send = async (text: string) => {
const normalized = normalizeMessageText(text);
if (!normalized) return;
const maxLength = config.data?.messages?.max_text_length ?? DEFAULT_MESSAGE_MAX_LENGTH;
if (normalized.length > maxLength) {
setSendError(new Error(`Сообщение слишком длинное. Максимум — ${maxLength} символов.`));
return;
}
const intent = createPendingTextIntent(normalized);
try {
if (authStatus !== "authenticated") {
await savePendingTextIntent(intent);
setPending(intent);
setConsentOpen(true);
return;
}
await openChatWithText(intent);
} catch (error) {
setSendError(error);
}
};
const chooseFile = async () => {
if (authStatus !== "authenticated") {
setConsentOpen(true);
return;
}
try {
const [file] = await pickFiles({ mimeTypes: ["image/*", "application/pdf"] });
if (file) await sendFile(file);
} catch (error) {
setSendError(error);
}
};
const sendFile = async (file: NativeFile) => {
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)) {
setSendError(new Error("Недопустимый тип файла или превышен допустимый размер."));
return;
}
setSendError(undefined);
await savePendingFileIntent({
file,
dialogKey: Crypto.randomUUID(),
messageKey: Crypto.randomUUID(),
});
router.push("/dialogs?pending=1");
};
const accept = async (accepted: {
personal_data: boolean;
user_agreement: boolean;
marketing: boolean;
}) => {
const versions = config.data?.consents;
const consents: Consents = {
personal_data: { accepted: accepted.personal_data, version: versions?.personal_data?.version ?? "current" },
user_agreement: { accepted: accepted.user_agreement, version: versions?.user_agreement?.version ?? "current" },
marketing: { accepted: accepted.marketing, version: versions?.marketing?.version ?? "current" },
};
setConsentOpen(false);
try {
const authorized = await authorize(consents);
if (authorized && pending) await openChatWithText(pending);
else if (authorized && afterNotificationAuth) await afterNotificationAuth();
} catch (error) {
setSendError(error);
} finally {
setAfterNotificationAuth(undefined);
}
};
const welcome = content.data?.texts.welcome;
const questions = content.data?.popular_questions ?? [];
return (
<ScreenShell>
<AppHeader guestLabel={authStatus === "authenticated" ? undefined : "Гость"} />
<View style={{ flex: 1 }}>
<HanLogo />
<ScrollView
contentContainerStyle={{ paddingBottom: spacing }}
keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="handled"
style={{ flex: 1 }}
>
{(config.isLoading || content.isLoading) && <Loading />}
{(config.error || content.error) && (
<View style={{ paddingHorizontal: 16 }}>
<ErrorNotice error={config.error ?? content.error} retry={() => { void config.refetch(); void content.refetch(); }} />
</View>
)}
{welcome ? (
<Text style={[styles.muted, { paddingHorizontal: 16, marginBottom: 8 }]}>{welcome}</Text>
) : null}
<NotificationCarousel
authenticated={authStatus === "authenticated"}
autoplay={config.data?.notification?.carousel_autoplay_enabled ?? false}
autoplayIntervalMs={config.data?.notification?.carousel_autoplay_interval_ms ?? 5000}
requireAuth={(afterAuth) => {
setAfterNotificationAuth(afterAuth ? () => afterAuth : undefined);
setConsentOpen(true);
}}
/>
</ScrollView>
<PopularQuestionsList questions={questions} onSelect={(text) => { setMessage(text); void send(text); }} />
<ChatInputBar
disabled={sending}
onAttach={() => void chooseFile()}
onChangeText={setMessage}
onSubmit={() => void send(message)}
sending={sending}
value={message}
maxLength={config.data?.messages?.max_text_length ?? DEFAULT_MESSAGE_MAX_LENGTH}
/>
<QuickActions
authenticated={authStatus === "authenticated"}
phone={config.data?.operator.call_phone}
/>
{Boolean(sendError) && (
<View style={{ paddingHorizontal: 16, paddingBottom: 8 }}>
<ErrorNotice error={sendError} />
</View>
)}
</View>
{consentOpen && (
<ConsentModal
consents={config.data?.consents}
onAccept={(accepted) => void accept(accepted)}
onCancel={() => {
setConsentOpen(false);
setPending(null);
setAfterNotificationAuth(undefined);
void clearPendingTextIntent().catch(setSendError);
}}
/>
)}
</ScreenShell>
);
}
const spacing = 16;