From 2f0ca68e8a829e11e636146b1ac43c11454aeac2 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 25 Jun 2026 22:05:35 +0200 Subject: [PATCH] feat(messages): E2EE real-time messaging UI + system welcome A Messages tab in the notification center (hidden for demo): set up secure messaging with a passphrase (key generated + wrapped in-browser via WebCrypto, stored non-extractable per device), unlock on other devices, then chat with end-to-end encrypted, live-delivered messages. The server-authored Siftlode welcome is readable before any key setup. - lib/e2ee.ts: ECDH P-256 + HKDF + AES-GCM, PBKDF2-wrapped key, IndexedDB. - lib/messagesSocket.ts: WebSocket client with backoff reconnect. - Messages.tsx: key gate, conversation list with decrypted previews, directory, thread with client-side decrypt + encrypt-on-send. Unread feeds the nav badge. EN/HU/DE strings. --- frontend/src/App.tsx | 2 +- frontend/src/components/Messages.tsx | 470 ++++++++++++++++++ frontend/src/components/NavSidebar.tsx | 10 +- .../src/components/NotificationsPanel.tsx | 51 +- frontend/src/i18n/locales/de/inbox.json | 1 + frontend/src/i18n/locales/de/messages.json | 29 ++ frontend/src/i18n/locales/en/inbox.json | 1 + frontend/src/i18n/locales/en/messages.json | 29 ++ frontend/src/i18n/locales/hu/inbox.json | 1 + frontend/src/i18n/locales/hu/messages.json | 29 ++ frontend/src/lib/api.ts | 59 +++ frontend/src/lib/e2ee.ts | 189 +++++++ frontend/src/lib/messagesSocket.ts | 78 +++ 13 files changed, 943 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/Messages.tsx create mode 100644 frontend/src/i18n/locales/de/messages.json create mode 100644 frontend/src/i18n/locales/en/messages.json create mode 100644 frontend/src/i18n/locales/hu/messages.json create mode 100644 frontend/src/lib/e2ee.ts create mode 100644 frontend/src/lib/messagesSocket.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d76b819..183ef2e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -545,7 +545,7 @@ export default function App() { ) : page === "playlists" ? ( ) : page === "notifications" ? ( - + ) : page === "settings" ? ( (); +async function partnerPub(partnerId: number): Promise { + const cached = pubCache.get(partnerId); + if (cached) return cached; + const r = await api.messagePublicKey(partnerId); + pubCache.set(partnerId, r.public_key); + return r.public_key; +} + +type View = "list" | "new" | { partnerId: number; name?: string; avatar?: string | null; system?: boolean }; + +export default function Messages({ meId }: { meId: number }) { + const qc = useQueryClient(); + const [view, setView] = useState("list"); + const [unlocked, setUnlocked] = useState(e2ee.isUnlocked()); + + const keyQ = useQuery({ queryKey: ["message-key"], queryFn: api.messageMyKey, staleTime: 60_000 }); + const configured = keyQ.data?.configured ?? false; + + // Restore this device's unlocked key (set once per device, then survives reloads). + useEffect(() => { + e2ee.loadFromDevice(meId).then((ok) => ok && setUnlocked(true)); + }, [meId]); + + // Live delivery: a pushed message refreshes the conversation list, unread badge, and the + // affected thread (which re-decrypts). Near-instant; the poll is only a fallback. + useEffect( + () => + onMessage((m) => { + qc.invalidateQueries({ queryKey: ["conversations"] }); + qc.invalidateQueries({ queryKey: ["message-unread"] }); + const partner = m.kind === "system" ? SYSTEM_ID : m.sender_id === meId ? m.recipient_id : m.sender_id; + qc.invalidateQueries({ queryKey: ["thread", partner] }); + }), + [qc, meId] + ); + + const gate = !unlocked ? ( + { + setUnlocked(true); + qc.invalidateQueries({ queryKey: ["message-key"] }); + }} + /> + ) : null; + + if (typeof view === "object") { + return ( + setView("list")} + /> + ); + } + if (view === "new") { + return setView({ partnerId: u.id, name: u.name, avatar: u.avatar_url })} onBack={() => setView("list")} />; + } + return ( + + setView({ partnerId: c.partner.id, name: c.partner.name, avatar: c.partner.avatar_url, system: c.partner.id === SYSTEM_ID }) + } + onNew={() => setView("new")} + /> + ); +} + +function KeyGate({ + meId, + configured, + bundle, + onUnlocked, +}: { + meId: number; + configured: boolean; + bundle: MyKeyBundle | undefined; + onUnlocked: () => void; +}) { + const { t } = useTranslation(); + const [pass, setPass] = useState(""); + const [confirm, setConfirm] = useState(""); + const [err, setErr] = useState(null); + const [busy, setBusy] = useState(false); + + async function submit() { + setErr(null); + if (pass.length < 6) return setErr(t("messages.passTooShort")); + if (!configured && pass !== confirm) return setErr(t("messages.passMismatch")); + setBusy(true); + try { + if (configured) { + await e2ee.unlock(meId, pass, bundle!); + } else { + const payload = await e2ee.setup(meId, pass); + await api.setupMessageKey(payload); + } + onUnlocked(); + } catch { + setErr(configured ? t("messages.wrongPass") : t("messages.setupFailed")); + } finally { + setBusy(false); + } + } + + return ( +
+
+ +
{configured ? t("messages.unlockTitle") : t("messages.setupTitle")}
+
+

{configured ? t("messages.unlockBody") : t("messages.setupBody")}

+ setPass(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && configured && submit()} + placeholder={t("messages.passphrase")} + className="w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent" + /> + {!configured && ( + setConfirm(e.target.value)} + placeholder={t("messages.passphraseConfirm")} + className="w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent" + /> + )} + {err &&
{err}
} + + {!configured &&

{t("messages.setupWarn")}

} +
+ ); +} + +function ConversationList({ + unlocked, + gate, + onOpen, + onNew, +}: { + unlocked: boolean; + gate: React.ReactNode; + onOpen: (c: Conversation) => void; + onNew: () => void; +}) { + const { t } = useTranslation(); + const q = useLiveQuery<{ items: Conversation[] }>(["conversations"], api.conversations, { intervalMs: POLL_MS }); + const items = q.data?.items ?? []; + const [previews, setPreviews] = useState>({}); + + // Decrypt the latest message of each user conversation for the list preview (system previews + // are already plaintext). Runs whenever the conversations or unlock state change. + useEffect(() => { + if (!unlocked) return; + let cancelled = false; + (async () => { + const out: Record = {}; + for (const c of items) { + const m = c.last_message; + if (m.kind === "user" && m.ciphertext && m.iv) { + try { + const pub = await partnerPub(c.partner.id); + out[c.partner.id] = await e2ee.decryptFrom(c.partner.id, pub, m.ciphertext, m.iv); + } catch { + out[c.partner.id] = "⚠"; + } + } + } + if (!cancelled) setPreviews(out); + })(); + return () => { + cancelled = true; + }; + }, [items, unlocked]); + + function previewText(c: Conversation): string { + const m = c.last_message; + if (m.kind === "system") return m.body ?? ""; + if (!unlocked) return "🔒 " + t("messages.encrypted"); + return previews[c.partner.id] ?? "…"; + } + + return ( +
+
+
{t("messages.conversations")}
+ {unlocked && ( + + )} +
+ {gate} + {q.isLoading && !q.data ? ( +
{t("common.loading")}
+ ) : items.length === 0 ? ( +
{t("messages.empty")}
+ ) : ( +
+ {items.map((c) => ( + + ))} +
+ )} +
+ ); +} + +function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBack: () => void }) { + const { t } = useTranslation(); + const [filter, setFilter] = useState(""); + const q = useQuery({ queryKey: ["message-users"], queryFn: api.messageUsers }); + const users = (q.data ?? []).filter((u) => u.name.toLowerCase().includes(filter.trim().toLowerCase())); + return ( +
+
+ +
{t("messages.newTitle")}
+
+ setFilter(e.target.value)} + placeholder={t("messages.searchPeople")} + className="w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent" + /> + {q.isLoading ? ( +
{t("common.loading")}
+ ) : users.length === 0 ? ( +
{t("messages.noPeople")}
+ ) : ( +
+ {users.map((u) => ( + + ))} +
+ )} +
+ ); +} + +function Thread({ + meId, + partnerId, + isSystem, + seedName, + seedAvatar, + unlocked, + gate, + onBack, +}: { + meId: number; + partnerId: number; + isSystem: boolean; + seedName?: string; + seedAvatar?: string | null; + unlocked: boolean; + gate: React.ReactNode; + onBack: () => void; +}) { + 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 partner = q.data?.partner; + const items = q.data?.items ?? []; + + // Decrypt user-message bodies for display (system messages are already plaintext). + 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]); + + 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); + }; + + const name = partner?.name ?? seedName ?? ""; + return ( +
+
+ + + {name} + {!isSystem && } +
+ + {needsKey ? ( +
+
{gate}
+
+ ) : ( + <> +
+ {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")}
+ ) : ( +
+