Закрыты задачи бэклога по неочевидному поведению UI при ошибках отправки сообщений и блокировках со стороны Message-safety + добалено ограничение на размер сообщения
This commit is contained in:
@@ -4,8 +4,7 @@ import { Text, View } from "react-native";
|
||||
import { useApp } from "../../src/app-context";
|
||||
import { AuthLoadingView } from "../../src/components/AuthLoadingView";
|
||||
import { ScreenShell } from "../../src/components/ScreenShell";
|
||||
import { clearPendingTextIntent, loadPendingTextIntent } from "../../src/pending-intent";
|
||||
import { dialogApi } from "../../src/services";
|
||||
import { loadPendingTextIntent } from "../../src/pending-intent";
|
||||
import { spacing } from "../../src/theme";
|
||||
import type { Consents } from "../../src/types";
|
||||
import { Button, ErrorNotice, styles } from "../../src/ui";
|
||||
@@ -24,18 +23,14 @@ export default function AuthCallbackScreen() {
|
||||
setError(undefined);
|
||||
const consents = JSON.parse(raw) as Consents;
|
||||
await app.finishCallback(params.code, params.state, consents);
|
||||
window.sessionStorage.removeItem("han.pending-consents");
|
||||
|
||||
const intent = loadPendingTextIntent();
|
||||
if (intent) {
|
||||
const dialog = await dialogApi.create(intent.dialogKey);
|
||||
await dialogApi.sendText(dialog.dialog_id, intent.text, intent.messageKey);
|
||||
clearPendingTextIntent();
|
||||
window.sessionStorage.removeItem("han.pending-consents");
|
||||
router.replace(`/dialogs/${dialog.dialog_id}`);
|
||||
router.replace("/dialogs?pending=1");
|
||||
return;
|
||||
}
|
||||
|
||||
window.sessionStorage.removeItem("han.pending-consents");
|
||||
router.replace("/");
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,14 @@ 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 { isMessageBlockedError } from "../../src/api";
|
||||
import {
|
||||
clearPendingFileIntent,
|
||||
clearPendingTextIntent,
|
||||
loadPendingFileIntent,
|
||||
loadPendingTextIntent,
|
||||
} from "../../src/pending-intent";
|
||||
import { DEFAULT_MESSAGE_MAX_LENGTH, normalizeMessageText } from "../../src/message-text";
|
||||
import { RealtimeClient, reconcileMessages } from "../../src/realtime";
|
||||
import { dialogApi, profileApi, publicApi, uploadAttachment } from "../../src/services";
|
||||
import type { Message } from "../../src/types";
|
||||
@@ -29,6 +37,8 @@ export default function ChatScreen() {
|
||||
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" });
|
||||
@@ -66,14 +76,32 @@ export default function ChatScreen() {
|
||||
if (items.length) listRef.current?.scrollToEnd({ animated: true });
|
||||
}, [messages.data?.items.length]);
|
||||
|
||||
const sendText = async () => {
|
||||
const normalized = text.trim();
|
||||
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, crypto.randomUUID());
|
||||
const message = await dialogApi.sendText(dialogId, normalized, messageKey);
|
||||
merge([message]); setText("");
|
||||
} catch (reason) { setError(reason); }
|
||||
return true;
|
||||
} catch (reason) {
|
||||
return await handleSendError(reason);
|
||||
}
|
||||
finally { setSending(false); }
|
||||
};
|
||||
|
||||
@@ -89,7 +117,7 @@ export default function ChatScreen() {
|
||||
input.click();
|
||||
};
|
||||
|
||||
const sendFile = async (file: File) => {
|
||||
const sendFile = async (file: File, 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"];
|
||||
@@ -100,12 +128,45 @@ export default function ChatScreen() {
|
||||
setSending(true); setError(undefined);
|
||||
try {
|
||||
const uploaded = await uploadAttachment(dialogId, file);
|
||||
const message = await dialogApi.sendFile(dialogId, uploaded.attachmentId, uploaded.checksum, crypto.randomUUID());
|
||||
const message = await dialogApi.sendFile(dialogId, uploaded.attachmentId, uploaded.checksum, messageKey);
|
||||
merge([message]);
|
||||
} catch (reason) { setError(reason); }
|
||||
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) {
|
||||
clearPendingTextIntent();
|
||||
clearPendingFileIntent();
|
||||
setRetryPending(undefined);
|
||||
return;
|
||||
}
|
||||
setRetryPending(() => () => {
|
||||
setRetryPending(undefined);
|
||||
void submit();
|
||||
});
|
||||
};
|
||||
void submit();
|
||||
}, [dialogId, app.authStatus]);
|
||||
|
||||
const getAttachmentUrl = useCallback(async (attachmentId: string) => {
|
||||
const result = await profileApi.attachmentUrl(dialogId, attachmentId);
|
||||
return result.download_url;
|
||||
@@ -170,10 +231,11 @@ export default function ChatScreen() {
|
||||
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} />
|
||||
<ErrorNotice error={error} retry={retryPending} />
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -5,6 +5,12 @@ import { ScrollView, Text, View } from "react-native";
|
||||
import { useApp } from "../../src/app-context";
|
||||
import { AppHeader } from "../../src/components/AppHeader";
|
||||
import { ScreenShell } from "../../src/components/ScreenShell";
|
||||
import {
|
||||
bindPendingFileIntent,
|
||||
bindPendingTextIntent,
|
||||
loadPendingTextIntent,
|
||||
pendingDialogKey,
|
||||
} from "../../src/pending-intent";
|
||||
import { dialogApi } from "../../src/services";
|
||||
import { ErrorNotice, Loading, styles } from "../../src/ui";
|
||||
import { spacing } from "../../src/theme";
|
||||
@@ -12,7 +18,7 @@ import { spacing } from "../../src/theme";
|
||||
export default function DialogsScreen() {
|
||||
const app = useApp();
|
||||
const router = useRouter();
|
||||
const requestKey = useRef(crypto.randomUUID());
|
||||
const requestKey = useRef(pendingDialogKey() ?? crypto.randomUUID());
|
||||
const chat = useQuery({
|
||||
queryKey: ["current-dialog", requestKey.current],
|
||||
queryFn: () => dialogApi.create(requestKey.current),
|
||||
@@ -20,7 +26,12 @@ export default function DialogsScreen() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (chat.data) router.replace(`/dialogs/${chat.data.dialog_id}`);
|
||||
if (chat.data) {
|
||||
const intent = loadPendingTextIntent();
|
||||
if (intent && !intent.dialogId) bindPendingTextIntent(intent, chat.data.dialog_id);
|
||||
bindPendingFileIntent(chat.data.dialog_id);
|
||||
router.replace(`/dialogs/${chat.data.dialog_id}`);
|
||||
}
|
||||
}, [chat.data, router]);
|
||||
|
||||
if (app.authStatus !== "authenticated") {
|
||||
|
||||
@@ -15,9 +15,11 @@ import {
|
||||
clearPendingTextIntent,
|
||||
createPendingTextIntent,
|
||||
savePendingTextIntent,
|
||||
savePendingFileIntent,
|
||||
type PendingTextIntent,
|
||||
} from "../src/pending-intent";
|
||||
import { dialogApi, publicApi, uploadAttachment } from "../src/services";
|
||||
import { DEFAULT_MESSAGE_MAX_LENGTH, normalizeMessageText } from "../src/message-text";
|
||||
import { publicApi } from "../src/services";
|
||||
import type { Consents } from "../src/types";
|
||||
import { ErrorNotice, Loading, styles } from "../src/ui";
|
||||
|
||||
@@ -29,7 +31,7 @@ export default function HomeScreen() {
|
||||
const [pending, setPending] = useState<PendingTextIntent | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
const [sendError, setSendError] = useState<unknown>();
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sending] = useState(false);
|
||||
const [afterNotificationAuth, setAfterNotificationAuth] = useState<(() => Promise<void>) | undefined>();
|
||||
const { authorize: authorizeParam } = useLocalSearchParams<{ authorize?: string }>();
|
||||
const handledAuthorizeParam = useRef(false);
|
||||
@@ -42,28 +44,20 @@ export default function HomeScreen() {
|
||||
}
|
||||
}, [authStatus, authorizeParam]);
|
||||
|
||||
const sendAuthenticated = async (intent: PendingTextIntent) => {
|
||||
setSending(true);
|
||||
const openChatWithText = (intent: PendingTextIntent) => {
|
||||
setSendError(undefined);
|
||||
try {
|
||||
const dialog = await dialogApi.create(intent.dialogKey);
|
||||
await dialogApi.sendText(dialog.dialog_id, intent.text, intent.messageKey);
|
||||
setMessage("");
|
||||
setPending(null);
|
||||
clearPendingTextIntent();
|
||||
router.push(`/dialogs/${dialog.dialog_id}`);
|
||||
} catch (error) {
|
||||
setSendError(error);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
savePendingTextIntent(intent);
|
||||
setMessage("");
|
||||
setPending(null);
|
||||
router.push("/dialogs?pending=1");
|
||||
};
|
||||
|
||||
const send = async (text: string) => {
|
||||
const normalized = text.trim();
|
||||
const normalized = normalizeMessageText(text);
|
||||
if (!normalized) return;
|
||||
if (normalized.length > 4000) {
|
||||
setSendError(new Error("Сообщение слишком длинное. Максимум — 4000 символов."));
|
||||
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);
|
||||
@@ -73,7 +67,7 @@ export default function HomeScreen() {
|
||||
setConsentOpen(true);
|
||||
return;
|
||||
}
|
||||
await sendAuthenticated(intent);
|
||||
openChatWithText(intent);
|
||||
};
|
||||
|
||||
const chooseFile = () => {
|
||||
@@ -103,23 +97,13 @@ export default function HomeScreen() {
|
||||
setSendError(new Error("Недопустимый тип файла или превышен допустимый размер."));
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
setSendError(undefined);
|
||||
try {
|
||||
const dialog = await dialogApi.create(crypto.randomUUID());
|
||||
const uploaded = await uploadAttachment(dialog.dialog_id, file);
|
||||
await dialogApi.sendFile(
|
||||
dialog.dialog_id,
|
||||
uploaded.attachmentId,
|
||||
uploaded.checksum,
|
||||
crypto.randomUUID(),
|
||||
);
|
||||
router.push(`/dialogs/${dialog.dialog_id}`);
|
||||
} catch (error) {
|
||||
setSendError(error);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
savePendingFileIntent({
|
||||
file,
|
||||
dialogKey: crypto.randomUUID(),
|
||||
messageKey: crypto.randomUUID(),
|
||||
});
|
||||
router.push("/dialogs?pending=1");
|
||||
};
|
||||
|
||||
const accept = async (accepted: {
|
||||
@@ -136,7 +120,7 @@ export default function HomeScreen() {
|
||||
setConsentOpen(false);
|
||||
try {
|
||||
const authorized = await authorize(consents);
|
||||
if (authorized && pending) await sendAuthenticated(pending);
|
||||
if (authorized && pending) openChatWithText(pending);
|
||||
else if (authorized && afterNotificationAuth) await afterNotificationAuth();
|
||||
} catch (error) {
|
||||
setSendError(error);
|
||||
@@ -181,6 +165,7 @@ export default function HomeScreen() {
|
||||
onSubmit={() => void send(message)}
|
||||
sending={sending}
|
||||
value={message}
|
||||
maxLength={config.data?.messages?.max_text_length ?? DEFAULT_MESSAGE_MAX_LENGTH}
|
||||
/>
|
||||
<QuickActions
|
||||
authenticated={authStatus === "authenticated"}
|
||||
|
||||
@@ -20,6 +20,9 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export const isMessageBlockedError = (error: unknown) =>
|
||||
error instanceof ApiError && error.code === "message_blocked";
|
||||
|
||||
export type Diagnostic = {
|
||||
at: number;
|
||||
method: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import React, { useState } from "react";
|
||||
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
|
||||
import { messageFitsLimit } from "../message-text";
|
||||
import { colors, radii, spacing } from "../theme";
|
||||
|
||||
type Props = {
|
||||
@@ -13,6 +14,7 @@ type Props = {
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
inputLabel?: string;
|
||||
maxLength?: number;
|
||||
};
|
||||
|
||||
export function ChatInputBar({
|
||||
@@ -25,9 +27,12 @@ export function ChatInputBar({
|
||||
placeholder = "Напишите ваш вопрос...",
|
||||
hint = "Напишите сообщение или прикрепите документ",
|
||||
inputLabel = "Сообщение",
|
||||
maxLength,
|
||||
}: Props) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const canSend = Boolean(value.trim()) && !disabled && !sending;
|
||||
const withinLimit = maxLength === undefined || messageFitsLimit(value, maxLength);
|
||||
const canSend = Boolean(value.trim()) && withinLimit && !disabled && !sending;
|
||||
const nearLimit = maxLength !== undefined && value.length >= maxLength * 0.9;
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
@@ -78,6 +83,18 @@ export function ChatInputBar({
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
{maxLength !== undefined ? (
|
||||
<Text
|
||||
accessibilityLiveRegion={withinLimit ? "none" : "polite"}
|
||||
style={[
|
||||
styles.counter,
|
||||
nearLimit && styles.counterWarning,
|
||||
!withinLimit && styles.counterError,
|
||||
]}
|
||||
>
|
||||
{value.length}/{maxLength}
|
||||
</Text>
|
||||
) : null}
|
||||
{hint ? <Text style={styles.hint}>{hint}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
@@ -111,6 +128,9 @@ const styles = StyleSheet.create({
|
||||
iconButton: { width: 36, height: 36, borderRadius: radii.full, alignItems: "center", justifyContent: "center" },
|
||||
sendButton: { width: 36, height: 36, borderRadius: radii.full, backgroundColor: colors.primary, alignItems: "center", justifyContent: "center" },
|
||||
sendButtonDisabled: { opacity: 0.4 },
|
||||
counter: { fontSize: 12, color: colors.mutedForeground, textAlign: "right", marginTop: spacing.xs },
|
||||
counterWarning: { color: colors.warning },
|
||||
counterError: { color: colors.destructive },
|
||||
hint: { fontSize: 12, color: colors.mutedForeground, textAlign: "center", marginTop: spacing.sm },
|
||||
pressed: { opacity: 0.7 },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export const DEFAULT_MESSAGE_MAX_LENGTH = 4000;
|
||||
|
||||
export function normalizeMessageText(value: string) {
|
||||
return value.normalize("NFKC").trim();
|
||||
}
|
||||
|
||||
export function messageFitsLimit(value: string, maxLength: number) {
|
||||
return normalizeMessageText(value).length <= maxLength;
|
||||
}
|
||||
@@ -2,9 +2,19 @@ export type PendingTextIntent = {
|
||||
text: string;
|
||||
dialogKey: string;
|
||||
messageKey: string;
|
||||
dialogId?: string;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "han.pending-message";
|
||||
let pendingTextIntent: PendingTextIntent | null = null;
|
||||
let pendingFileIntent: PendingFileIntent | null = null;
|
||||
|
||||
export type PendingFileIntent = {
|
||||
file: File;
|
||||
dialogKey: string;
|
||||
dialogId?: string;
|
||||
messageKey: string;
|
||||
};
|
||||
|
||||
export function createPendingTextIntent(text: string): PendingTextIntent {
|
||||
return {
|
||||
@@ -15,12 +25,14 @@ export function createPendingTextIntent(text: string): PendingTextIntent {
|
||||
}
|
||||
|
||||
export function savePendingTextIntent(intent: PendingTextIntent) {
|
||||
pendingTextIntent = intent;
|
||||
if (typeof window !== "undefined") {
|
||||
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(intent));
|
||||
}
|
||||
}
|
||||
|
||||
export function loadPendingTextIntent(): PendingTextIntent | null {
|
||||
if (pendingTextIntent) return pendingTextIntent;
|
||||
if (typeof window === "undefined") return null;
|
||||
const raw = window.sessionStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
@@ -33,18 +45,47 @@ export function loadPendingTextIntent(): PendingTextIntent | null {
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
pendingTextIntent = {
|
||||
text: value.text,
|
||||
dialogKey: value.dialogKey,
|
||||
messageKey: value.messageKey,
|
||||
...(typeof value.dialogId === "string" ? { dialogId: value.dialogId } : {}),
|
||||
};
|
||||
return pendingTextIntent;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function bindPendingTextIntent(intent: PendingTextIntent, dialogId: string) {
|
||||
const bound = { ...intent, dialogId };
|
||||
savePendingTextIntent(bound);
|
||||
return bound;
|
||||
}
|
||||
|
||||
export function clearPendingTextIntent() {
|
||||
pendingTextIntent = null;
|
||||
if (typeof window !== "undefined") {
|
||||
window.sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function savePendingFileIntent(intent: PendingFileIntent) {
|
||||
pendingFileIntent = intent;
|
||||
}
|
||||
|
||||
export function bindPendingFileIntent(dialogId: string) {
|
||||
if (pendingFileIntent) pendingFileIntent = { ...pendingFileIntent, dialogId };
|
||||
}
|
||||
|
||||
export function pendingDialogKey() {
|
||||
return loadPendingTextIntent()?.dialogKey ?? pendingFileIntent?.dialogKey;
|
||||
}
|
||||
|
||||
export function loadPendingFileIntent(dialogId: string) {
|
||||
return pendingFileIntent?.dialogId === dialogId ? pendingFileIntent : null;
|
||||
}
|
||||
|
||||
export function clearPendingFileIntent() {
|
||||
pendingFileIntent = null;
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ export type DocumentItem = {
|
||||
export type PublicConfig = {
|
||||
auth: { phone_enabled: boolean; password_enabled: boolean };
|
||||
operator: { call_phone: string };
|
||||
messages: { max_text_length: number };
|
||||
notification?: {
|
||||
carousel_autoplay_enabled?: boolean;
|
||||
carousel_autoplay_interval_ms?: number;
|
||||
|
||||
@@ -23,6 +23,7 @@ test.beforeEach(async ({ page }) => {
|
||||
json: {
|
||||
auth: { phone_enabled: true, password_enabled: false },
|
||||
operator: { call_phone: "+74950000000" },
|
||||
messages: { max_text_length: 4000 },
|
||||
ux: { idle_timeout_minutes: 15 },
|
||||
attachments: { max_size_mb: 5, allowed_extensions: ["png", "pdf"], allowed_mime_types: ["image/png", "application/pdf"] },
|
||||
consents: {
|
||||
@@ -86,11 +87,11 @@ test("Enter отправляет введённое сообщение", async (
|
||||
await expect(page.getByRole("heading", { name: "Перед началом работы" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("сообщение ограничено 4000 символами", async ({ page }) => {
|
||||
test("счётчик показывает и ограничивает 4000 символов", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByLabel("Сообщение").fill("а".repeat(4001));
|
||||
await page.getByRole("button", { name: "Отправить" }).click();
|
||||
await expect(page.getByRole("alert")).toContainText("Максимум — 4000 символов");
|
||||
await expect(page.getByText("4001/4000")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Отправить" })).toBeDisabled();
|
||||
await expect(page.getByRole("heading", { name: "Перед началом работы" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -24,14 +24,21 @@ vi.mock("react-native", () => ({
|
||||
}));
|
||||
|
||||
import { buildOidcDeviceMetadata } from "../../src/oidc-device";
|
||||
import { ApiError, isMessageBlockedError } from "../../src/api";
|
||||
import { messageFitsLimit, normalizeMessageText } from "../../src/message-text";
|
||||
import { reconcileMessages } from "../../src/reconcile";
|
||||
import { sessionMemory } from "../../src/session";
|
||||
import { SingleFlight } from "../../src/single-flight";
|
||||
import { websocketJwtProtocol } from "../../src/realtime";
|
||||
import {
|
||||
clearPendingTextIntent,
|
||||
bindPendingFileIntent,
|
||||
bindPendingTextIntent,
|
||||
clearPendingFileIntent,
|
||||
createPendingTextIntent,
|
||||
loadPendingFileIntent,
|
||||
loadPendingTextIntent,
|
||||
savePendingFileIntent,
|
||||
savePendingTextIntent,
|
||||
} from "../../src/pending-intent";
|
||||
import type { Message } from "../../src/types";
|
||||
@@ -94,6 +101,33 @@ describe("pending message intent", () => {
|
||||
expect(loadPendingTextIntent()).toBeNull();
|
||||
clearPendingTextIntent();
|
||||
});
|
||||
|
||||
it("привязывает отложенный текст и файл к созданному диалогу", () => {
|
||||
const textIntent = createPendingTextIntent("После входа");
|
||||
bindPendingTextIntent(textIntent, "dialog-1");
|
||||
const file = new File(["test"], "test.txt", { type: "text/plain" });
|
||||
savePendingFileIntent({ file, dialogKey: "file-dialog-key", messageKey: "file-key" });
|
||||
bindPendingFileIntent("dialog-1");
|
||||
|
||||
expect(loadPendingTextIntent()?.dialogId).toBe("dialog-1");
|
||||
expect(loadPendingFileIntent("dialog-1")?.file).toBe(file);
|
||||
|
||||
clearPendingTextIntent();
|
||||
clearPendingFileIntent();
|
||||
});
|
||||
});
|
||||
|
||||
describe("message UX rules", () => {
|
||||
it("нормализует текст и проверяет динамический лимит", () => {
|
||||
expect(normalizeMessageText(" A ")).toBe("A");
|
||||
expect(messageFitsLimit("1234", 4)).toBe(true);
|
||||
expect(messageFitsLimit("12345", 4)).toBe(false);
|
||||
});
|
||||
|
||||
it("отличает блокировку message-safety от технической ошибки", () => {
|
||||
expect(isMessageBlockedError(new ApiError(422, "message_blocked", "blocked"))).toBe(true);
|
||||
expect(isMessageBlockedError(new ApiError(503, "dependency_unavailable", "failed"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SingleFlight", () => {
|
||||
|
||||
Reference in New Issue
Block a user