diff --git a/frontend/src/components/ChatThread.tsx b/frontend/src/components/ChatThread.tsx index 138eddb..58b6816 100644 --- a/frontend/src/components/ChatThread.tsx +++ b/frontend/src/components/ChatThread.tsx @@ -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 = {}; - 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 = () => { diff --git a/frontend/src/components/Messages.tsx b/frontend/src/components/Messages.tsx index 1ebdc06..769256e 100644 --- a/frontend/src/components/Messages.tsx +++ b/frontend/src/components/Messages.tsx @@ -64,12 +64,15 @@ function ConversationList({ const items = q.data?.items ?? []; const [previews, setPreviews] = useState>({}); + // 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 = {}; - 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; diff --git a/frontend/src/i18n/locales/de/messages.json b/frontend/src/i18n/locales/de/messages.json index e80de55..2ffaf4d 100644 --- a/frontend/src/i18n/locales/de/messages.json +++ b/frontend/src/i18n/locales/de/messages.json @@ -17,6 +17,7 @@ "encrypted": "Verschlüsselte Nachricht", "encryptedHint": "Ende-zu-Ende-verschlüsselt", "cantDecrypt": "Kann nicht entschlüsselt werden", + "sendFailed": "Nachricht konnte nicht gesendet werden. Bitte erneut versuchen.", "systemReadOnly": "Dies ist eine automatische Nachricht.", "setupTitle": "Sichere Nachrichten einrichten", "setupBody": "Nachrichten sind Ende-zu-Ende-verschlüsselt. Wähle ein Passwort zum Schutz deines Schlüssels — er verlässt nie dein Gerät, sodass niemand (nicht einmal ein Admin) deine Unterhaltungen lesen kann.", diff --git a/frontend/src/i18n/locales/en/messages.json b/frontend/src/i18n/locales/en/messages.json index 12857b3..249ec95 100644 --- a/frontend/src/i18n/locales/en/messages.json +++ b/frontend/src/i18n/locales/en/messages.json @@ -17,6 +17,7 @@ "encrypted": "Encrypted message", "encryptedHint": "End-to-end encrypted", "cantDecrypt": "Can't decrypt", + "sendFailed": "Couldn't send your message. Try again.", "systemReadOnly": "This is an automated message.", "setupTitle": "Set up secure messaging", "setupBody": "Messages are end-to-end encrypted. Choose a passphrase to protect your key — it never leaves your device, so no one (not even an admin) can read your conversations.", diff --git a/frontend/src/i18n/locales/hu/messages.json b/frontend/src/i18n/locales/hu/messages.json index a7260cd..3ed312e 100644 --- a/frontend/src/i18n/locales/hu/messages.json +++ b/frontend/src/i18n/locales/hu/messages.json @@ -17,6 +17,7 @@ "encrypted": "Titkosított üzenet", "encryptedHint": "Végpontig titkosítva", "cantDecrypt": "Nem fejthető vissza", + "sendFailed": "Nem sikerült elküldeni az üzenetet. Próbáld újra.", "systemReadOnly": "Ez egy automatikus üzenet.", "setupTitle": "Biztonságos üzenetküldés beállítása", "setupBody": "Az üzenetek végpontig titkosítottak. Válassz egy jelszót a kulcsod védelméhez — sosem hagyja el az eszközödet, így senki (még az admin sem) olvashatja a beszélgetéseidet.", diff --git a/frontend/src/lib/chatDock.ts b/frontend/src/lib/chatDock.ts index e1e8269..1b30c03 100644 --- a/frontend/src/lib/chatDock.ts +++ b/frontend/src/lib/chatDock.ts @@ -3,6 +3,7 @@ // when a message arrives) and stays open across page navigation. The dock state (which chats // are open + their minimised state) is persisted per user so it survives a reload. import { useSyncExternalStore } from "react"; +import { LS } from "./storage"; export interface DockChat { partnerId: number; @@ -20,7 +21,7 @@ let userId: number | null = null; const subs = new Set<() => void>(); function storageKey(): string | null { - return userId != null ? `siftlode.chatDock.${userId}` : null; + return userId != null ? LS.chatDock(userId) : null; } function persist(): void {