feat(messages): standalone Messages module + floating chat dock
Move messaging out of the notification center into its own left-nav module (own page, own unread badge), so a reload returns to it and notifications stay separate. Add a Messenger-style floating dock (bottom-right): pop a conversation out from the page, keep chatting across navigation, minimise (rollup) or close. - Messages is now a routed page; NotificationsPanel reverts to inbox-only and the nav badge no longer mixes in messages. - Extract shared KeyGate + ChatThread (used by both the page and dock windows); e2ee exposes a shared unlock subscription so page and dock agree. - ChatDock (always mounted for human users) owns the app-wide live-message subscription and per-device key restore. EN/HU/DE strings.
This commit is contained in:
parent
2f0ca68e8a
commit
3fd71cd316
14 changed files with 518 additions and 347 deletions
162
frontend/src/components/ChatThread.tsx
Normal file
162
frontend/src/components/ChatThread.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue