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:
npeter83 2026-06-25 22:34:24 +02:00
parent 2f0ca68e8a
commit 3fd71cd316
14 changed files with 518 additions and 347 deletions

View file

@ -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" ? (
<Playlists canWrite={meQuery.data!.can_write} />
) : page === "notifications" ? (
<NotificationsPanel filters={filters} setFilters={setFilters} setPage={setPage} onFocusChannel={focusChannel} meId={meQuery.data!.id} isDemo={meQuery.data!.is_demo} />
<NotificationsPanel filters={filters} setFilters={setFilters} setPage={setPage} onFocusChannel={focusChannel} />
) : page === "messages" && !meQuery.data!.is_demo ? (
<div className="p-4 max-w-3xl w-full mx-auto">
<Messages meId={meQuery.data!.id} />
</div>
) : page === "settings" ? (
<SettingsPanel
me={meQuery.data!}
@ -569,6 +575,7 @@ export default function App() {
(collapsed or expanded) without tracking its width. */}
<Toaster />
</div>
{meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />}
{wizardOpen && meQuery.data && !meQuery.data.is_demo && (
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
)}

View file

@ -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 (
<div className="fixed bottom-0 right-0 z-40 flex flex-row-reverse items-end gap-3 p-4 pointer-events-none">
{chats.map((c) => (
<ChatWindow key={c.partnerId} chat={c} meId={meId} unlocked={unlocked} />
))}
</div>
);
}
function ChatWindow({ chat, meId, unlocked }: { chat: DockChat; meId: number; unlocked: boolean }) {
const { t } = useTranslation();
return (
<div className="pointer-events-auto w-80 max-w-[calc(100vw-2rem)] glass rounded-2xl shadow-2xl flex flex-col overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-card/50">
<button
onClick={() => toggleMinimize(chat.partnerId)}
className="flex items-center gap-2 min-w-0 flex-1 text-left"
title={chat.minimized ? t("messages.expand") : t("messages.minimize")}
>
<Avatar src={chat.avatar} fallback={chat.name} className="w-7 h-7 rounded-full shrink-0 text-xs" />
<span className="font-semibold text-sm truncate">{chat.name}</span>
</button>
<button
onClick={() => toggleMinimize(chat.partnerId)}
className="shrink-0 p-1 rounded-md text-muted hover:text-fg hover:bg-bg transition"
aria-label={chat.minimized ? t("messages.expand") : t("messages.minimize")}
>
{chat.minimized ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
<button
onClick={() => closeChat(chat.partnerId)}
className="shrink-0 p-1 rounded-md text-muted hover:text-fg hover:bg-bg transition"
aria-label={t("messages.close")}
>
<X className="w-4 h-4" />
</button>
</div>
{!chat.minimized && (
<div className="flex flex-col h-96">
<ChatThread meId={meId} partnerId={chat.partnerId} isSystem={false} unlocked={unlocked} />
</div>
)}
</div>
);
}

View 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>
)}
</>
);
}

View file

@ -0,0 +1,80 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Lock, ShieldCheck } from "lucide-react";
import { api } from "../lib/api";
import * as e2ee from "../lib/e2ee";
// Secure-messaging key gate: first-run setup (choose a passphrase) or unlock on this device.
// Self-contained — on success it updates the shared e2ee unlock state, so any observing view
// (the Messages page, a dock window) swaps out of the gate automatically.
export default function KeyGate({ meId, compact = false }: { meId: number; compact?: boolean }) {
const { t } = useTranslation();
const qc = useQueryClient();
const keyQ = useQuery({ queryKey: ["message-key"], queryFn: api.messageMyKey, staleTime: 60_000 });
const configured = keyQ.data?.configured ?? false;
const [pass, setPass] = useState("");
const [confirm, setConfirm] = useState("");
const [err, setErr] = useState<string | null>(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, keyQ.data!);
} else {
const payload = await e2ee.setup(meId, pass);
await api.setupMessageKey(payload);
}
qc.invalidateQueries({ queryKey: ["message-key"] });
} catch {
setErr(configured ? t("messages.wrongPass") : t("messages.setupFailed"));
} finally {
setBusy(false);
}
}
if (keyQ.isLoading) return <div className="p-6 text-center text-muted text-sm">{t("common.loading")}</div>;
return (
<div className={`glass rounded-2xl space-y-3 ${compact ? "p-3" : "p-4"}`}>
<div className="flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-accent shrink-0" />
<div className="font-semibold text-sm">{configured ? t("messages.unlockTitle") : t("messages.setupTitle")}</div>
</div>
{!compact && <p className="text-sm text-muted">{configured ? t("messages.unlockBody") : t("messages.setupBody")}</p>}
<input
type="password"
value={pass}
onChange={(e) => 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 && (
<input
type="password"
value={confirm}
onChange={(e) => 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 && <div className="text-sm text-red-400">{err}</div>}
<button
onClick={submit}
disabled={busy || !pass}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition disabled:opacity-40"
>
<Lock className="w-4 h-4" />
{configured ? t("messages.unlock") : t("messages.setUp")}
</button>
{!configured && !compact && <p className="text-xs text-muted">{t("messages.setupWarn")}</p>}
</div>
);
}

View file

@ -1,77 +1,34 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Lock, Send, ShieldCheck, SquarePen } from "lucide-react";
import { api, type Conversation, type Message, type MessageUser, type MyKeyBundle } from "../lib/api";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react";
import { api, type Conversation, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { relativeTime } from "../lib/format";
import { onMessage } from "../lib/messagesSocket";
import { partnerPub, SYSTEM_ID, useUnlocked } from "../lib/messaging";
import { openChat } from "../lib/chatDock";
import * as e2ee from "../lib/e2ee";
import Avatar from "./Avatar";
import KeyGate from "./KeyGate";
import ChatThread from "./ChatThread";
const POLL_MS = 20000; // safety-net poll; live updates come over the WebSocket
const SYSTEM_ID = 0;
// Set-once cache of partner public keys (the server is the directory).
const pubCache = new Map<number, string>();
async function partnerPub(partnerId: number): Promise<string> {
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;
}
const POLL_MS = 20000;
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<View>("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 ? (
<KeyGate
meId={meId}
configured={configured}
bundle={keyQ.data}
onUnlocked={() => {
setUnlocked(true);
qc.invalidateQueries({ queryKey: ["message-key"] });
}}
/>
) : null;
const unlocked = useUnlocked();
if (typeof view === "object") {
return (
<Thread
<PageThread
meId={meId}
partnerId={view.partnerId}
isSystem={!!view.system}
seedName={view.name}
seedAvatar={view.avatar}
unlocked={unlocked}
gate={gate}
onBack={() => setView("list")}
/>
);
@ -81,99 +38,22 @@ export default function Messages({ meId }: { meId: number }) {
}
return (
<ConversationList
meId={meId}
unlocked={unlocked}
gate={gate}
onOpen={(c) =>
setView({ partnerId: c.partner.id, name: c.partner.name, avatar: c.partner.avatar_url, system: c.partner.id === SYSTEM_ID })
}
onOpen={(c) => 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<string | null>(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 (
<div className="glass rounded-2xl p-4 space-y-3">
<div className="flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-accent shrink-0" />
<div className="font-semibold">{configured ? t("messages.unlockTitle") : t("messages.setupTitle")}</div>
</div>
<p className="text-sm text-muted">{configured ? t("messages.unlockBody") : t("messages.setupBody")}</p>
<input
type="password"
value={pass}
onChange={(e) => 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 && (
<input
type="password"
value={confirm}
onChange={(e) => 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 && <div className="text-sm text-red-400">{err}</div>}
<button
onClick={submit}
disabled={busy || !pass}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition disabled:opacity-40"
>
<Lock className="w-4 h-4" />
{configured ? t("messages.unlock") : t("messages.setUp")}
</button>
{!configured && <p className="text-xs text-muted">{t("messages.setupWarn")}</p>}
</div>
);
}
function ConversationList({
meId,
unlocked,
gate,
onOpen,
onNew,
}: {
meId: number;
unlocked: boolean;
gate: React.ReactNode;
onOpen: (c: Conversation) => void;
onNew: () => void;
}) {
@ -182,8 +62,6 @@ function ConversationList({
const items = q.data?.items ?? [];
const [previews, setPreviews] = useState<Record<number, string>>({});
// 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;
@ -228,36 +106,47 @@ function ConversationList({
</button>
)}
</div>
{gate}
{!unlocked && <KeyGate meId={meId} />}
{q.isLoading && !q.data ? (
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
) : items.length === 0 ? (
<div className="glass rounded-2xl p-10 text-center text-muted">{t("messages.empty")}</div>
) : (
<div className="flex flex-col gap-1.5">
{items.map((c) => (
<button
key={c.partner.id}
onClick={() => onOpen(c)}
className="flex items-center gap-3 p-2.5 rounded-xl text-left hover:bg-card transition"
>
<Avatar src={c.partner.avatar_url} fallback={c.partner.name} className="w-10 h-10 rounded-full shrink-0 text-sm" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className={`truncate ${c.unread > 0 ? "font-semibold" : "font-medium"}`}>{c.partner.name}</span>
<span className="ml-auto text-[11px] text-muted shrink-0">{relativeTime(c.last_message.created_at)}</span>
</div>
<div className="flex items-center gap-2">
<span className={`truncate text-sm ${c.unread > 0 ? "text-fg" : "text-muted"}`}>{previewText(c)}</span>
{c.unread > 0 && (
<span className="ml-auto shrink-0 min-w-5 h-5 px-1.5 grid place-items-center rounded-full bg-accent text-accent-fg text-[11px] font-semibold">
{c.unread}
</span>
)}
</div>
{items.map((c) => {
const isSystem = c.partner.id === SYSTEM_ID;
return (
<div key={c.partner.id} className="group flex items-center gap-1 rounded-xl hover:bg-card transition">
<button onClick={() => onOpen(c)} className="flex items-center gap-3 p-2.5 text-left min-w-0 flex-1">
<Avatar src={c.partner.avatar_url} fallback={c.partner.name} className="w-10 h-10 rounded-full shrink-0 text-sm" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className={`truncate ${c.unread > 0 ? "font-semibold" : "font-medium"}`}>{c.partner.name}</span>
<span className="ml-auto text-[11px] text-muted shrink-0">{relativeTime(c.last_message.created_at)}</span>
</div>
<div className="flex items-center gap-2">
<span className={`truncate text-sm ${c.unread > 0 ? "text-fg" : "text-muted"}`}>{previewText(c)}</span>
{c.unread > 0 && (
<span className="ml-auto shrink-0 min-w-5 h-5 px-1.5 grid place-items-center rounded-full bg-accent text-accent-fg text-[11px] font-semibold">
{c.unread}
</span>
)}
</div>
</div>
</button>
{!isSystem && unlocked && (
<button
onClick={() => openChat(c.partner)}
title={t("messages.popOut")}
aria-label={t("messages.popOut")}
className="shrink-0 mr-1.5 p-1.5 rounded-lg text-muted opacity-0 group-hover:opacity-100 hover:text-accent hover:bg-bg transition"
>
<ExternalLink className="w-4 h-4" />
</button>
)}
</div>
</button>
))}
);
})}
</div>
)}
</div>
@ -305,14 +194,13 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
);
}
function Thread({
function PageThread({
meId,
partnerId,
isSystem,
seedName,
seedAvatar,
unlocked,
gate,
onBack,
}: {
meId: number;
@ -321,150 +209,32 @@ function Thread({
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<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 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<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]);
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 ?? "";
const name = seedName ?? "";
return (
<div className="flex flex-col h-[60vh] min-h-80">
<div className="flex flex-col h-[65vh] min-h-80">
<div className="flex items-center gap-3 pb-3 border-b border-border">
<button onClick={onBack} className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition">
<ArrowLeft className="w-4 h-4" />
</button>
<Avatar src={partner?.avatar_url ?? seedAvatar} fallback={name} className="w-9 h-9 rounded-full shrink-0 text-sm" />
<Avatar src={seedAvatar} fallback={name} className="w-9 h-9 rounded-full shrink-0 text-sm" />
<span className="font-semibold truncate">{name}</span>
{!isSystem && <ShieldCheck className="w-4 h-4 text-muted shrink-0" aria-label={t("messages.encryptedHint")} />}
<div className="flex-1" />
{!isSystem && unlocked && (
<button
onClick={() => openChat({ id: partnerId, name, avatar_url: seedAvatar ?? null })}
title={t("messages.popOut")}
aria-label={t("messages.popOut")}
className="p-1.5 rounded-lg text-muted hover:text-accent hover:bg-card transition"
>
<ExternalLink className="w-4 h-4" />
</button>
)}
</div>
{needsKey ? (
<div className="flex-1 grid place-items-center py-6">
<div className="w-full max-w-sm">{gate}</div>
</div>
) : (
<>
<div className="flex-1 overflow-y-auto py-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-[78%] 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="pt-3 border-t border-border text-center text-xs text-muted">{t("messages.systemReadOnly")}</div>
) : (
<div className="flex items-end gap-2 pt-3 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-32 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>
)}
</>
)}
<ChatThread meId={meId} partnerId={partnerId} isSystem={isSystem} unlocked={unlocked} />
</div>
);
}

View file

@ -12,6 +12,7 @@ import {
Info,
ListVideo,
LogOut,
MessageSquare,
Settings,
Shield,
SlidersHorizontal,
@ -126,15 +127,14 @@ export default function NavSidebar({
// One indicator for both layers: durable server notifications + the client-side transient
// events (the former separate bell is now folded into the inbox page).
const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
// Unread direct messages also live in the notification module, so they add to the same badge.
// The demo account can't use messaging, so don't poll it there.
const unread = (unreadQuery.data?.count ?? 0) + clientUnread;
// Direct messages are their own module with their own unread badge (demo can't message).
const msgUnreadQuery = useLiveQuery(
["message-unread"],
api.messageUnreadCount,
{ intervalMs: 30000, enabled: !me.is_demo }
);
const unread =
(unreadQuery.data?.count ?? 0) + clientUnread + (msgUnreadQuery.data?.count ?? 0);
const msgUnread = msgUnreadQuery.data?.count ?? 0;
type NavItem = { page: Page; icon: typeof Home; label: string; badge?: number };
// User-facing content modules vs. admin/system modules, separated by a divider in the rail.
@ -143,6 +143,10 @@ export default function NavSidebar({
{ page: "channels", icon: Tv, label: t("header.account.channels") },
{ page: "playlists", icon: ListVideo, label: t("header.account.playlists") },
{ page: "notifications", icon: Bell, label: t("inbox.navLabel"), badge: unread },
// Direct messaging — its own module; hidden for the shared demo account.
...(me.is_demo
? []
: [{ page: "messages" as Page, icon: MessageSquare, label: t("messages.navLabel"), badge: msgUnread }]),
// Per-user sync status + your own API usage (admins get an extra system-wide tab inside).
{ page: "stats", icon: BarChart3, label: t("header.account.stats") },
];

View file

@ -1,10 +1,9 @@
import { useEffect, useState, useSyncExternalStore } from "react";
import { useEffect, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Activity, Bell, Check, CheckCheck, ExternalLink, Eye, MessageSquare, RotateCcw, Search, Trash2, Tv, UserPlus, X } from "lucide-react";
import { Activity, Bell, Check, CheckCheck, ExternalLink, Eye, RotateCcw, Search, Trash2, Tv, UserPlus, X } from "lucide-react";
import { api, type AppNotification, type FeedFilters } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import Messages from "./Messages";
import {
clearAll as clearClient,
dismiss as dismissClient,
@ -50,26 +49,14 @@ export default function NotificationsPanel({
setFilters,
setPage,
onFocusChannel,
meId,
isDemo,
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
setPage: (p: Page) => void;
onFocusChannel: (name: string) => void;
meId: number;
isDemo: boolean;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const [tab, setTab] = useState<"inbox" | "messages">("inbox");
// Messages unread for the tab badge (the nav rail badge is polled separately in NavSidebar).
// Demo can't use messaging, so don't poll it there.
const msgUnread = useLiveQuery<{ count: number }>(
["message-unread"],
api.messageUnreadCount,
{ intervalMs: POLL_MS, enabled: !isDemo }
);
const q = useLiveQuery<{ items: AppNotification[]; total: number }>(
["notifications"],
() => api.notifications(),
@ -139,7 +126,7 @@ export default function NotificationsPanel({
<div className="font-semibold">{t("inbox.title")}</div>
<div className="text-xs text-muted">{t("inbox.subtitle")}</div>
</div>
{tab === "inbox" && hasAny && (
{hasAny && (
<div className="flex items-center gap-2">
<button
onClick={markAll}
@ -161,37 +148,7 @@ export default function NotificationsPanel({
)}
</div>
{!isDemo && (
<div className="flex items-center gap-1">
<button
onClick={() => setTab("inbox")}
className={`inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition ${
tab === "inbox" ? "bg-card text-fg font-medium" : "text-muted hover:text-fg"
}`}
>
<Bell className="w-4 h-4" />
{t("inbox.tabInbox")}
</button>
<button
onClick={() => setTab("messages")}
className={`inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition ${
tab === "messages" ? "bg-card text-fg font-medium" : "text-muted hover:text-fg"
}`}
>
<MessageSquare className="w-4 h-4" />
{t("messages.tab")}
{(msgUnread.data?.count ?? 0) > 0 && (
<span className="min-w-5 h-5 px-1.5 grid place-items-center rounded-full bg-accent text-accent-fg text-[11px] font-semibold">
{msgUnread.data!.count}
</span>
)}
</button>
</div>
)}
{tab === "messages" && !isDemo ? (
<Messages meId={meId} />
) : q.isLoading && !q.data && clientItems.length === 0 ? (
{q.isLoading && !q.data && clientItems.length === 0 ? (
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
) : !hasAny ? (
<div className="glass rounded-2xl p-10 text-center text-muted">

View file

@ -1,5 +1,10 @@
{
"tab": "Nachrichten",
"navLabel": "Nachrichten",
"popOut": "In Pop-out öffnen",
"expand": "Aufklappen",
"minimize": "Minimieren",
"close": "Schließen",
"conversations": "Unterhaltungen",
"new": "Neue Nachricht",
"newTitle": "Neue Nachricht",

View file

@ -1,5 +1,10 @@
{
"tab": "Messages",
"navLabel": "Messages",
"popOut": "Open in pop-out",
"expand": "Expand",
"minimize": "Minimize",
"close": "Close",
"conversations": "Conversations",
"new": "New message",
"newTitle": "New message",

View file

@ -1,5 +1,10 @@
{
"tab": "Üzenetek",
"navLabel": "Üzenetek",
"popOut": "Megnyitás felugró ablakban",
"expand": "Kinyitás",
"minimize": "Összecsukás",
"close": "Bezárás",
"conversations": "Beszélgetések",
"new": "Új üzenet",
"newTitle": "Új üzenet",

View file

@ -0,0 +1,56 @@
// App-wide store of open floating chat windows (the Messenger-style dock, bottom-right).
// A window can be opened from anywhere (e.g. a conversation's "pop out" button) and stays
// open across page navigation. Plain external store so any component can read/drive it.
import { useSyncExternalStore } from "react";
export interface DockChat {
partnerId: number;
name: string;
avatar: string | null;
minimized: boolean;
}
const MAX_OPEN = 3; // keep the dock from overflowing the corner
let chats: DockChat[] = [];
const subs = new Set<() => void>();
function emit(next: DockChat[]): void {
chats = next;
for (const s of subs) s();
}
export function openChat(partner: { id: number; name: string; avatar_url: string | null }): void {
const existing = chats.find((c) => c.partnerId === partner.id);
if (existing) {
// Re-opening focuses it: ensure expanded and move to the front.
emit([
{ ...existing, minimized: false },
...chats.filter((c) => c.partnerId !== partner.id),
]);
return;
}
const next = [
{ partnerId: partner.id, name: partner.name, avatar: partner.avatar_url, minimized: false },
...chats,
].slice(0, MAX_OPEN);
emit(next);
}
export function closeChat(partnerId: number): void {
emit(chats.filter((c) => c.partnerId !== partnerId));
}
export function toggleMinimize(partnerId: number): void {
emit(chats.map((c) => (c.partnerId === partnerId ? { ...c, minimized: !c.minimized } : c)));
}
export function useDockChats(): DockChat[] {
return useSyncExternalStore(
(cb) => {
subs.add(cb);
return () => subs.delete(cb);
},
() => chats,
() => chats
);
}

View file

@ -31,6 +31,17 @@ const bsrc = (x: Uint8Array | ArrayBuffer): BufferSource => x as BufferSource;
let privKey: CryptoKey | null = null; // current unlocked (non-extractable) private key
const convKeys = new Map<number, CryptoKey>(); // partnerId → derived AES key
// Unlock state is shared app-wide (the Messages page and the floating chat dock both observe
// it), so expose a subscription for useSyncExternalStore.
const unlockSubs = new Set<() => void>();
function emitUnlock(): void {
for (const cb of unlockSubs) cb();
}
export function subscribeUnlock(cb: () => void): () => void {
unlockSubs.add(cb);
return () => unlockSubs.delete(cb);
}
export function isUnlocked(): boolean {
return !!privKey;
}
@ -38,6 +49,7 @@ export function isUnlocked(): boolean {
export function lock(): void {
privKey = null;
convKeys.clear();
emitUnlock();
}
// --- IndexedDB (per-device persistence of the unlocked private key) ---
@ -100,6 +112,7 @@ export async function loadFromDevice(userId: number): Promise<boolean> {
const stored = await idbGet(userId);
if (stored) {
privKey = stored;
emitUnlock();
return true;
}
return false;
@ -120,6 +133,7 @@ export async function setup(userId: number, passphrase: string): Promise<KeySetu
privKey = await importPrivate(pkcs8);
convKeys.clear();
await idbPut(userId, privKey);
emitUnlock();
return {
public_key: b64(spki),
wrapped_private_key: b64(wrapped),
@ -145,6 +159,7 @@ export async function unlock(userId: number, passphrase: string, bundle: MyKeyBu
privKey = await importPrivate(pkcs8);
convKeys.clear();
await idbPut(userId, privKey);
emitUnlock();
}
// --- per-conversation encryption ---

View file

@ -0,0 +1,21 @@
// Shared messaging helpers used by both the Messages page and the floating chat dock.
import { useSyncExternalStore } from "react";
import { api } from "./api";
import { isUnlocked, subscribeUnlock } from "./e2ee";
export const SYSTEM_ID = 0; // synthetic "Siftlode" system conversation partner id
// Set-once cache of partner public keys (the server is the directory; keys never change).
const pubCache = new Map<number, string>();
export async function partnerPub(partnerId: number): Promise<string> {
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;
}
// Live "is secure messaging unlocked on this device" — shared so the page and the dock agree.
export function useUnlocked(): boolean {
return useSyncExternalStore(subscribeUnlock, isUnlocked, isUnlocked);
}

View file

@ -91,6 +91,7 @@ export const PAGES = [
"config",
"users",
"notifications",
"messages",
] as const;
export type Page = (typeof PAGES)[number];