Second sweep, 34 more sites: NotificationsPanel (14 -- eight identical accent text links, three ghost icon buttons, two ghost text actions), Welcome's five auth links, KeyGate, Messages, NavSidebar, ChatThread, ShareDialog, ChannelPage, PlexPlaylistView, PlexPlaylistAdd, PlexCollectionEditor and plexViews. Moves the font-weight out of Button's BASE and into each variant, so the `link` variant can be semibold (which is what every text link in this app looks like) while the action variants stay medium, and no element can end up with two font-* utilities. Welcome is the one signed-out surface this session cannot see, so only its byte-identical link swaps were taken; its primary submit and the bg-card button family are left for a pass that can UAT the screen. 3x tsc, eslint, knip, prettier, 689 vitest green; UAT on Notifications, Messages and the nav rail, clean console in a fresh tab.
232 lines
9.2 KiB
TypeScript
232 lines
9.2 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 { Button } from "./ui/Button";
|
|
import { LoadingState, StateMessage } from "./ui/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
|
|
variant="primary"
|
|
size="icon"
|
|
className="shrink-0"
|
|
onClick={submit}
|
|
disabled={!draft.trim() || send.isPending}
|
|
aria-label={t("messages.send")}
|
|
title={t("messages.send")}
|
|
icon={<Send className="w-4 h-4" />}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|