Закрыты задачи бэклога по неочевидному поведению UI при ошибках отправки сообщений и блокировках со стороны Message-safety + добалено ограничение на размер сообщения

This commit is contained in:
mi
2026-07-29 16:45:19 +03:00
parent 41e19005fb
commit bda3ff39d7
36 changed files with 486 additions and 141 deletions
@@ -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"}