The Messages UI trusted only the per-device IndexedDB key, so if the server's key record was gone (deleted, DB-restored, admin-reset) while a stale private key lingered in the browser, the app looked 'unlocked' but no one could be messaged and no setup was offered. Add useKeyState (server 'configured' AND local unlock): show setup when the server has no key (setup overwrites the stale local key), unlock when it has one this device hasn't opened, ready otherwise. ChatThread is now self-contained. Also fix the header title on the Messages and Notifications pages (was falling through to 'Channel manager').
162 lines
5.5 KiB
TypeScript
162 lines
5.5 KiB
TypeScript
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, useKeyState } 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. 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 ready = useKeyState() === "ready";
|
|
const needsKey = !isSystem && !ready;
|
|
|
|
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 || !ready) 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, ready, 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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|