The owner's picks (all the recommended options) become DESIGN.md's Copy chapter and a sweep of both languages (742 values, no key drift): - One name per feature: the rail's label, which may shorten it (Alerts -> Notifications, Fiókok -> Felhasználók, Download Center -> Downloads, Channel manager -> Channels); one "sync from YouTube" verb. - Four verbs, four meanings (Delete / Remove / Dismiss / Discard, and their Hungarian pairs), checked against what each action does. - Sentence case, the app's places capitalised; "…" on a command that asks first (a popover or form title got its own key without it); one-sentence messages without a stop or a tick, "Couldn't …"; curly quotes. - Dates only from lib/format.ts, in the UI language: formatDateTime names the month and stops at the minute (it printed "9/27/2026, 8:30:14 AM"), formatDateTimeSec for the log, the audit and the recorder; ui/RelativeTime pairs "3d ago" with the exact minute on hover. local/date-format refuses toLocale*String and Intl date formatters elsewhere. - Units from Intl, short (formatUnit): runtimes, totals, the Scheduler countdown, the stats range, the player's tap skip. "2h 13m" was English in both languages. - src/i18n/copyRule.test.ts holds the machine-readable part (quotes, "...", ticks, bare unit letters).
242 lines
9.8 KiB
TypeScript
242 lines
9.8 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { qk } from "../lib/queryKeys";
|
|
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 { useLiveQuery } from "../lib/useLiveQuery";
|
|
import { useScrollFade } from "../lib/useScrollFade";
|
|
import { IconButton } from "./ui/IconButton";
|
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
|
import { notifyActionError } from "../lib/actionErrors";
|
|
import { partnerPub, POLL_MS, refreshPartnerPub, useKeyState } from "../lib/messaging";
|
|
import * as e2ee from "../lib/e2ee";
|
|
import KeyGate from "./KeyGate";
|
|
import { Textarea } from "./ui/Input";
|
|
import { RelativeTime } from "./ui/RelativeTime";
|
|
|
|
// The message list + composer for one conversation, shared by the full Messages page and the
|
|
// floating dock window. Handles client-side decryption and encrypt-on-send; the surrounding
|
|
// chrome (back button / minimize / close) is the caller's. Self-contained key gating: prompts
|
|
// setup/unlock (server-truth) for a user conversation until messaging is ready.
|
|
export default function ChatThread({
|
|
meId,
|
|
partnerId,
|
|
isSystem,
|
|
}: {
|
|
meId: number;
|
|
partnerId: number;
|
|
isSystem: boolean;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const [draft, setDraft] = useState("");
|
|
const [plain, setPlain] = useState<Record<number, string>>({});
|
|
const bottomRef = useRef<HTMLDivElement>(null);
|
|
const fade = useScrollFade();
|
|
const ready = useKeyState() === "ready";
|
|
const needsKey = !isSystem && !ready;
|
|
|
|
const q = useLiveQuery<{ partner: MessageUser; items: Message[] }>(
|
|
qk.thread(partnerId),
|
|
() => api.thread(partnerId),
|
|
{ intervalMs: POLL_MS, enabled: !needsKey },
|
|
);
|
|
const items = q.data?.items ?? [];
|
|
// The two effects below take the FETCH as their trigger and read the payload through this ref, so
|
|
// their dep lists name everything they touch without making the identity of `q.data` a trigger.
|
|
const threadRef = useRef(q.data);
|
|
threadRef.current = q.data;
|
|
|
|
// 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> = {};
|
|
// Fetch the partner key lazily inside the try (only when there's a ciphertext message) and
|
|
// once — so an empty thread never fetches, and a fetch failure (e.g. a partner who reset and
|
|
// hasn't re-set-up, so has no key) degrades to "can't decrypt" instead of rejecting the pass.
|
|
let pub: string | null = null;
|
|
let refreshed = false;
|
|
for (const m of threadRef.current?.items ?? []) {
|
|
if (!m.ciphertext || !m.iv) continue;
|
|
try {
|
|
if (!pub) pub = await partnerPub(partnerId);
|
|
out[m.id] = await e2ee.decryptFrom(partnerId, pub, m.ciphertext, m.iv);
|
|
} catch {
|
|
// A decrypt failure can mean the partner rotated their keypair (a passphrase reset), so
|
|
// this device's cached public key is stale. Refresh it ONCE per pass and retry — that
|
|
// also re-derives future sends under the current key. A genuinely bad message still ends
|
|
// up "can't decrypt".
|
|
if (!refreshed) {
|
|
refreshed = true;
|
|
try {
|
|
pub = await refreshPartnerPub(partnerId);
|
|
out[m.id] = await e2ee.decryptFrom(partnerId, pub, m.ciphertext, m.iv);
|
|
continue;
|
|
} catch {
|
|
/* fall through to the error label below */
|
|
}
|
|
}
|
|
out[m.id] = "⚠ " + t("messages.cantDecrypt");
|
|
}
|
|
}
|
|
if (!cancelled) setPlain(out);
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
// `dataUpdatedAt` is the trigger and the payload is read through the ref above — see Messages,
|
|
// where keying this family on `q.data` itself produced a render loop.
|
|
}, [q.dataUpdatedAt, ready, isSystem, partnerId, t]);
|
|
|
|
// Opening the thread (or a new incoming message arriving) marks messages read server-side, so
|
|
// refresh the badges — but ONLY when the incoming-message count actually changed, not on every
|
|
// 20s poll (which would needlessly refetch /conversations + /unread_count each tick).
|
|
const lastIncoming = useRef(-1);
|
|
const lastPartner = useRef(partnerId);
|
|
useEffect(() => {
|
|
if (!q.dataUpdatedAt) return;
|
|
// The Messages page reuses one ChatThread instance across conversation switches (not keyed by
|
|
// partnerId), so reset the counter when the partner changes — otherwise switching to a thread
|
|
// with the same incoming-count would skip the open-marks-read refresh.
|
|
if (lastPartner.current !== partnerId) {
|
|
lastPartner.current = partnerId;
|
|
lastIncoming.current = -1;
|
|
}
|
|
const incoming = (threadRef.current?.items ?? []).filter((m) => m.recipient_id === meId).length;
|
|
if (incoming !== lastIncoming.current) {
|
|
lastIncoming.current = incoming;
|
|
qc.invalidateQueries({ queryKey: qk.conversations() });
|
|
qc.invalidateQueries({ queryKey: qk.messageUnread() });
|
|
}
|
|
}, [q.dataUpdatedAt, partnerId, meId, 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]);
|
|
|
|
const send = useMutation({
|
|
mutationFn: async (text: string) => {
|
|
const pub = await partnerPub(partnerId);
|
|
const { ciphertext, iv } = await e2ee.encryptFor(partnerId, pub, text);
|
|
return api.sendMessage(partnerId, ciphertext, iv);
|
|
},
|
|
onSuccess: () => {
|
|
setDraft("");
|
|
qc.invalidateQueries({ queryKey: qk.thread(partnerId) });
|
|
qc.invalidateQueries({ queryKey: qk.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.
|
|
//
|
|
// Through the shared helper, which knows both of those rules: it no-ops on anything already
|
|
// reported, and it prefers the server's reason only when that reason is a STRUCTURED detail.
|
|
// The hand-written copy that used to live here did `e.detail || …`, so a Hungarian reader got
|
|
// the route's English sentence — "That member hasn't set up messaging yet." — which is
|
|
// exactly what round 5 changed `notifyActionError` to stop doing. A second copy of a contract
|
|
// is how the fix misses a caller (round 6).
|
|
notifyActionError(e, t("messages.sendFailed"));
|
|
},
|
|
});
|
|
|
|
const submit = () => {
|
|
const text = draft.trim();
|
|
if (text && !send.isPending) send.mutate(text);
|
|
};
|
|
|
|
if (needsKey) {
|
|
return (
|
|
<div className="flex-1 grid place-items-center p-3 overflow-y-auto">
|
|
<div className="w-full max-w-sm">
|
|
<KeyGate meId={meId} compact />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
ref={fade.ref}
|
|
style={fade.style}
|
|
className="flex-1 overflow-y-auto no-scrollbar p-3 flex flex-col gap-2"
|
|
>
|
|
{q.isError && !q.data ? (
|
|
<div className="m-auto">
|
|
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
|
{t("common.loadError")}
|
|
</StateMessage>
|
|
</div>
|
|
) : q.isPending ? (
|
|
<div className="m-auto">
|
|
<LoadingState />
|
|
</div>
|
|
) : items.length === 0 ? (
|
|
<div className="m-auto text-sm text-muted">{t("messages.threadEmpty")}</div>
|
|
) : (
|
|
items.map((m) => {
|
|
const mine = m.sender_id === meId;
|
|
const text = m.kind === "system" ? (m.body ?? "") : (plain[m.id] ?? "…");
|
|
return (
|
|
<div key={m.id} className={`flex ${mine ? "justify-end" : "justify-start"}`}>
|
|
<div
|
|
className={`max-w-[80%] rounded-2xl px-3 py-2 text-sm whitespace-pre-wrap wrap-break-word ${
|
|
mine ? "bg-accent text-accent-fg rounded-br-sm" : "bg-card rounded-bl-sm"
|
|
}`}
|
|
>
|
|
{text}
|
|
<RelativeTime
|
|
at={m.created_at}
|
|
className={`block text-3xs mt-1 ${mine ? "text-accent-fg/70" : "text-muted"}`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
<div ref={bottomRef} />
|
|
</div>
|
|
|
|
{isSystem ? (
|
|
<div className="p-2 border-t border-border text-center text-xs text-muted">
|
|
{t("messages.systemReadOnly")}
|
|
</div>
|
|
) : (
|
|
<div className="flex items-end gap-2 p-2 border-t border-border">
|
|
<Textarea
|
|
className="flex-1 resize-none max-h-28"
|
|
value={draft}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
submit();
|
|
}
|
|
}}
|
|
rows={1}
|
|
placeholder={t("messages.writePlaceholder")}
|
|
/>
|
|
<IconButton
|
|
label={t("messages.send")}
|
|
size="md"
|
|
variant="primary"
|
|
onClick={submit}
|
|
disabled={!draft.trim() || send.isPending}
|
|
>
|
|
<Send className="w-4 h-4" />
|
|
</IconButton>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|