From 3fd71cd31607400fed9fcc7d37e25e147ed40d1d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 25 Jun 2026 22:34:24 +0200 Subject: [PATCH] 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. --- frontend/src/App.tsx | 9 +- frontend/src/components/ChatDock.tsx | 83 ++++ frontend/src/components/ChatThread.tsx | 162 ++++++++ frontend/src/components/KeyGate.tsx | 80 ++++ frontend/src/components/Messages.tsx | 360 ++++-------------- frontend/src/components/NavSidebar.tsx | 12 +- .../src/components/NotificationsPanel.tsx | 51 +-- frontend/src/i18n/locales/de/messages.json | 5 + frontend/src/i18n/locales/en/messages.json | 5 + frontend/src/i18n/locales/hu/messages.json | 5 + frontend/src/lib/chatDock.ts | 56 +++ frontend/src/lib/e2ee.ts | 15 + frontend/src/lib/messaging.ts | 21 + frontend/src/lib/urlState.ts | 1 + 14 files changed, 518 insertions(+), 347 deletions(-) create mode 100644 frontend/src/components/ChatDock.tsx create mode 100644 frontend/src/components/ChatThread.tsx create mode 100644 frontend/src/components/KeyGate.tsx create mode 100644 frontend/src/lib/chatDock.ts create mode 100644 frontend/src/lib/messaging.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 183ef2e..20c5dd3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -35,6 +35,8 @@ import ConfigPanel from "./components/ConfigPanel"; import AdminUsers from "./components/AdminUsers"; import SettingsPanel from "./components/SettingsPanel"; import NotificationsPanel from "./components/NotificationsPanel"; +import Messages from "./components/Messages"; +import ChatDock from "./components/ChatDock"; import OnboardingWizard from "./components/OnboardingWizard"; import { shouldAutoOpenOnboarding } from "./lib/onboarding"; import Toaster from "./components/Toaster"; @@ -545,7 +547,11 @@ export default function App() { ) : page === "playlists" ? ( ) : page === "notifications" ? ( - + + ) : page === "messages" && !meQuery.data!.is_demo ? ( +
+ +
) : page === "settings" ? ( + {meQuery.data && !meQuery.data.is_demo && } {wizardOpen && meQuery.data && !meQuery.data.is_demo && ( setWizardOpen(false)} /> )} diff --git a/frontend/src/components/ChatDock.tsx b/frontend/src/components/ChatDock.tsx new file mode 100644 index 0000000..e0902ff --- /dev/null +++ b/frontend/src/components/ChatDock.tsx @@ -0,0 +1,83 @@ +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useQueryClient } from "@tanstack/react-query"; +import { ChevronDown, ChevronUp, X } from "lucide-react"; +import { closeChat, toggleMinimize, useDockChats, type DockChat } from "../lib/chatDock"; +import { useUnlocked } from "../lib/messaging"; +import { onMessage } from "../lib/messagesSocket"; +import * as e2ee from "../lib/e2ee"; +import Avatar from "./Avatar"; +import ChatThread from "./ChatThread"; + +// The Messenger-style floating chat dock: open conversations live here, bottom-right, across +// page navigation. Always mounted (for human users) so it also owns the app-wide live-message +// subscription and restores this device's unlock state on load. +export default function ChatDock({ meId }: { meId: number }) { + const qc = useQueryClient(); + const chats = useDockChats(); + const unlocked = useUnlocked(); + + // Restore the per-device unlocked key once, app-wide. + useEffect(() => { + e2ee.loadFromDevice(meId); + }, [meId]); + + // Live delivery: any pushed message refreshes the conversation list, unread badge, and the + // affected thread (re-decrypts). Drives both the dock and the Messages page. + useEffect( + () => + onMessage((m) => { + qc.invalidateQueries({ queryKey: ["conversations"] }); + qc.invalidateQueries({ queryKey: ["message-unread"] }); + const partner = m.sender_id === meId ? m.recipient_id : m.sender_id; + qc.invalidateQueries({ queryKey: ["thread", partner] }); + }), + [qc, meId] + ); + + if (chats.length === 0) return null; + return ( +
+ {chats.map((c) => ( + + ))} +
+ ); +} + +function ChatWindow({ chat, meId, unlocked }: { chat: DockChat; meId: number; unlocked: boolean }) { + const { t } = useTranslation(); + return ( +
+
+ + + +
+ {!chat.minimized && ( +
+ +
+ )} +
+ ); +} diff --git a/frontend/src/components/ChatThread.tsx b/frontend/src/components/ChatThread.tsx new file mode 100644 index 0000000..bd373e7 --- /dev/null +++ b/frontend/src/components/ChatThread.tsx @@ -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>({}); + const bottomRef = useRef(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 = {}; + 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 ( +
+
+ +
+
+ ); + } + + return ( + <> +
+ {items.length === 0 ? ( +
{t("messages.threadEmpty")}
+ ) : ( + items.map((m) => { + const mine = m.sender_id === meId; + const text = m.kind === "system" ? m.body ?? "" : plain[m.id] ?? "…"; + return ( +
+
+ {text} +
+ {relativeTime(m.created_at)} +
+
+
+ ); + }) + )} +
+
+ + {isSystem ? ( +
{t("messages.systemReadOnly")}
+ ) : ( +
+