fix(messages-ui): surface silent send failures, stop scroll/render churn (+cleanup)

- ChatThread send had no onError: a 404 (recipient gone) or 429 (rate-limited)
  send failed silently (api.req shows no modal for those "caller-handled" statuses).
  Added onError → toast for 404/429 (400/409/500 already raise the global dialog);
  draft is kept for retry. New trilingual messages.sendFailed key.
- ChatThread scroll-to-bottom effect keyed on `plain` too, so every 20s poll's
  decrypt pass yanked a scrolled-up reader back to the bottom. Key on items.length.
- ChatThread + Messages decrypt effects keyed on `items` (a fresh `?? []` array
  every render) → a render storm while loading. Key on q.dataUpdatedAt.
- chatDock.ts: use LS.chatDock(userId) instead of the bare "siftlode.chatDock.*"
  literal (matches the storage LS registry).

tsc green, re-review clean, localdev boots, Messages renders (locked-state) w/o console errors.
This commit is contained in:
npeter83 2026-07-11 20:16:14 +02:00
parent 40ddaa2c92
commit 5441ad203d
6 changed files with 31 additions and 7 deletions

View file

@ -2,9 +2,10 @@ import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Send } from "lucide-react";
import { api, type Message, type MessageUser } from "../lib/api";
import { api, HttpError, type Message, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { relativeTime } from "../lib/format";
import { notify } from "../lib/notifications";
import { partnerPub, useKeyState } from "../lib/messaging";
import * as e2ee from "../lib/e2ee";
import KeyGate from "./KeyGate";
@ -39,12 +40,15 @@ export default function ChatThread({
);
const items = q.data?.items ?? [];
// Decrypt the thread when fresh server data arrives. Keyed on dataUpdatedAt (not `items`): the
// `?? []` fallback is a new array reference every render, which would otherwise re-run this
// effect (and setPlain a new object) on every render while data is loading — a render storm.
useEffect(() => {
if (isSystem || !ready) return;
let cancelled = false;
(async () => {
const out: Record<number, string> = {};
for (const m of items) {
for (const m of q.data?.items ?? []) {
if (m.ciphertext && m.iv) {
try {
const pub = await partnerPub(partnerId);
@ -59,7 +63,8 @@ export default function ChatThread({
return () => {
cancelled = true;
};
}, [items, ready, isSystem, partnerId, t]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q.dataUpdatedAt, ready, isSystem, partnerId, t]);
// Opening / refreshing the thread marks incoming messages read, so clear the badges.
useEffect(() => {
@ -69,9 +74,12 @@ export default function ChatThread({
}
}, [q.dataUpdatedAt, qc]);
// Scroll to the newest message when the count changes (open / new message) — NOT on `plain`,
// which gets a fresh object on every poll's decrypt pass and would yank a scrolled-up reader
// back to the bottom even when nothing new arrived.
useEffect(() => {
bottomRef.current?.scrollIntoView({ block: "end" });
}, [items.length, plain]);
}, [items.length]);
const send = useMutation({
mutationFn: async (text: string) => {
@ -84,6 +92,14 @@ export default function ChatThread({
qc.invalidateQueries({ queryKey: ["thread", partnerId] });
qc.invalidateQueries({ queryKey: ["conversations"] });
},
onError: (e) => {
// The draft is kept (only cleared on success) so the user can retry. 400/409/422/500 already
// raised the global error dialog; 404 (recipient gone) and 429 (rate-limited) are "caller-
// handled" in api.req (no modal), so surface those here or the send fails silently.
if (e instanceof HttpError && (e.status === 404 || e.status === 429)) {
notify({ level: "error", message: e.detail || t("messages.sendFailed") });
}
},
});
const submit = () => {

View file

@ -64,12 +64,15 @@ function ConversationList({
const items = q.data?.items ?? [];
const [previews, setPreviews] = useState<Record<number, string>>({});
// Decrypt conversation previews when fresh data arrives. Keyed on dataUpdatedAt (not `items`):
// the `?? []` fallback is a new array each render, which would otherwise spin this effect
// (and setPreviews) every render while the query is loading.
useEffect(() => {
if (!ready) return;
let cancelled = false;
(async () => {
const out: Record<number, string> = {};
for (const c of items) {
for (const c of q.data?.items ?? []) {
const m = c.last_message;
if (m.kind === "user" && m.ciphertext && m.iv) {
try {
@ -85,7 +88,8 @@ function ConversationList({
return () => {
cancelled = true;
};
}, [items, ready]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q.dataUpdatedAt, ready]);
function previewText(c: Conversation): string {
const m = c.last_message;