siftlode/frontend/src/components/ChatThread.tsx

163 lines
5.4 KiB
TypeScript
Raw Normal View History

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 { useLiveQuery } from "../lib/useLiveQuery";
import { relativeTime } from "../lib/format";
import { partnerPub } from "../lib/messaging";
import * as e2ee from "../lib/e2ee";
import KeyGate from "./KeyGate";
const POLL_MS = 20000; // safety net; live updates arrive over the WebSocket
// 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.
export default function ChatThread({
meId,
partnerId,
isSystem,
unlocked,
}: {
meId: number;
partnerId: number;
isSystem: boolean;
unlocked: boolean;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const [draft, setDraft] = useState("");
const [plain, setPlain] = useState<Record<number, string>>({});
const bottomRef = useRef<HTMLDivElement>(null);
const needsKey = !isSystem && !unlocked;
const q = useLiveQuery<{ partner: MessageUser; items: Message[] }>(
["thread", partnerId],
() => api.thread(partnerId),
{ intervalMs: POLL_MS, enabled: !needsKey }
);
const items = q.data?.items ?? [];
useEffect(() => {
if (isSystem || !unlocked) return;
let cancelled = false;
(async () => {
const out: Record<number, string> = {};
for (const m of items) {
if (m.ciphertext && m.iv) {
try {
const pub = await partnerPub(partnerId);
out[m.id] = await e2ee.decryptFrom(partnerId, pub, m.ciphertext, m.iv);
} catch {
out[m.id] = "⚠ " + t("messages.cantDecrypt");
}
}
}
if (!cancelled) setPlain(out);
})();
return () => {
cancelled = true;
};
}, [items, unlocked, isSystem, partnerId, t]);
// Opening / refreshing the thread marks incoming messages read, so clear the badges.
useEffect(() => {
if (q.dataUpdatedAt) {
qc.invalidateQueries({ queryKey: ["conversations"] });
qc.invalidateQueries({ queryKey: ["message-unread"] });
}
}, [q.dataUpdatedAt, qc]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ block: "end" });
}, [items.length, plain]);
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: ["thread", partnerId] });
qc.invalidateQueries({ queryKey: ["conversations"] });
},
});
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 className="flex-1 overflow-y-auto p-3 flex flex-col gap-2">
{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 break-words ${
mine ? "bg-accent text-accent-fg rounded-br-sm" : "bg-card rounded-bl-sm"
}`}
>
{text}
<div className={`text-[10px] 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-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
/>
<button
onClick={submit}
disabled={!draft.trim() || send.isPending}
className="shrink-0 p-2.5 rounded-xl bg-accent text-accent-fg hover:opacity-90 transition disabled:opacity-40"
aria-label={t("messages.send")}
title={t("messages.send")}
>
<Send className="w-4 h-4" />
</button>
</div>
)}
</>
);
}