Add a paint-only .btn-primary recipe (solid accent bg + accent-fg text + hover:opacity dim) in index.css @layer components, alongside the selection-paint recipes (.pill/.chip/.is-current). Migrate ~38 primary/action buttons across 24 files off the hand-rolled 'bg-accent text-accent-fg hover:opacity-90', keeping each site's geometry (padding/radius/font/gap/disabled/shadow). Deliberately NOT migrated: accent BADGES/labels (spans), the chat message bubble, selection toggles (tableHeader — R8 popover territory), and the round play FAB (hover:scale, not the opacity-dim primary idiom). Three previously hover-less primary buttons (SettingsPanel/SetupWizard/DraftSaveBar) gain the standard hover dim for consistency. Gate green (tsc/eslint-0/prettier/knip/vitest 79); self-E2E confirmed a migrated button computes to accent/accent-fg and both recipe rules load.
230 lines
9.1 KiB
TypeScript
230 lines
9.1 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, HttpError, type Message, type MessageUser } from "../lib/api";
|
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
|
import { useScrollFade } from "../lib/useScrollFade";
|
|
import { LoadingState, StateMessage } from "./QueryState";
|
|
import { relativeTime } from "../lib/format";
|
|
import { notify } from "../lib/notifications";
|
|
import { partnerPub, POLL_MS, refreshPartnerPub, useKeyState } from "../lib/messaging";
|
|
import * as e2ee from "../lib/e2ee";
|
|
import KeyGate from "./KeyGate";
|
|
|
|
// 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 ?? [];
|
|
|
|
// 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 q.data?.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;
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [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 = (q.data?.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() });
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [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.
|
|
if (e instanceof HttpError && (e.status === 404 || e.status === 429)) {
|
|
notify({ level: "error", message: e.detail || 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}
|
|
<div className={`text-3xs mt-1 ${mine ? "text-accent-fg/70" : "text-muted"}`}>
|
|
{relativeTime(m.created_at)}
|
|
</div>
|
|
</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
|
|
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")}
|
|
className="flex-1 resize-none max-h-28 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
|
/>
|
|
<button
|
|
onClick={submit}
|
|
disabled={!draft.trim() || send.isPending}
|
|
className="shrink-0 p-2.5 rounded-xl btn-primary transition disabled:opacity-40"
|
|
aria-label={t("messages.send")}
|
|
title={t("messages.send")}
|
|
>
|
|
<Send className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|