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
|
|
@ -35,6 +35,8 @@ import ConfigPanel from "./components/ConfigPanel";
|
||||||
import AdminUsers from "./components/AdminUsers";
|
import AdminUsers from "./components/AdminUsers";
|
||||||
import SettingsPanel from "./components/SettingsPanel";
|
import SettingsPanel from "./components/SettingsPanel";
|
||||||
import NotificationsPanel from "./components/NotificationsPanel";
|
import NotificationsPanel from "./components/NotificationsPanel";
|
||||||
|
import Messages from "./components/Messages";
|
||||||
|
import ChatDock from "./components/ChatDock";
|
||||||
import OnboardingWizard from "./components/OnboardingWizard";
|
import OnboardingWizard from "./components/OnboardingWizard";
|
||||||
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
|
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
|
||||||
import Toaster from "./components/Toaster";
|
import Toaster from "./components/Toaster";
|
||||||
|
|
@ -545,7 +547,11 @@ export default function App() {
|
||||||
) : page === "playlists" ? (
|
) : page === "playlists" ? (
|
||||||
<Playlists canWrite={meQuery.data!.can_write} />
|
<Playlists canWrite={meQuery.data!.can_write} />
|
||||||
) : page === "notifications" ? (
|
) : 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" ? (
|
) : page === "settings" ? (
|
||||||
<SettingsPanel
|
<SettingsPanel
|
||||||
me={meQuery.data!}
|
me={meQuery.data!}
|
||||||
|
|
@ -569,6 +575,7 @@ export default function App() {
|
||||||
(collapsed or expanded) without tracking its width. */}
|
(collapsed or expanded) without tracking its width. */}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
|
{meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />}
|
||||||
{wizardOpen && meQuery.data && !meQuery.data.is_demo && (
|
{wizardOpen && meQuery.data && !meQuery.data.is_demo && (
|
||||||
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
|
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
83
frontend/src/components/ChatDock.tsx
Normal file
83
frontend/src/components/ChatDock.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
frontend/src/components/KeyGate.tsx
Normal file
80
frontend/src/components/KeyGate.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,77 +1,34 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ArrowLeft, Lock, Send, ShieldCheck, SquarePen } from "lucide-react";
|
import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react";
|
||||||
import { api, type Conversation, type Message, type MessageUser, type MyKeyBundle } from "../lib/api";
|
import { api, type Conversation, type MessageUser } from "../lib/api";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
import { relativeTime } from "../lib/format";
|
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 * as e2ee from "../lib/e2ee";
|
||||||
import Avatar from "./Avatar";
|
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 POLL_MS = 20000;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
type View = "list" | "new" | { partnerId: number; name?: string; avatar?: string | null; system?: boolean };
|
type View = "list" | "new" | { partnerId: number; name?: string; avatar?: string | null; system?: boolean };
|
||||||
|
|
||||||
export default function Messages({ meId }: { meId: number }) {
|
export default function Messages({ meId }: { meId: number }) {
|
||||||
const qc = useQueryClient();
|
|
||||||
const [view, setView] = useState<View>("list");
|
const [view, setView] = useState<View>("list");
|
||||||
const [unlocked, setUnlocked] = useState(e2ee.isUnlocked());
|
const unlocked = useUnlocked();
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
if (typeof view === "object") {
|
if (typeof view === "object") {
|
||||||
return (
|
return (
|
||||||
<Thread
|
<PageThread
|
||||||
meId={meId}
|
meId={meId}
|
||||||
partnerId={view.partnerId}
|
partnerId={view.partnerId}
|
||||||
isSystem={!!view.system}
|
isSystem={!!view.system}
|
||||||
seedName={view.name}
|
seedName={view.name}
|
||||||
seedAvatar={view.avatar}
|
seedAvatar={view.avatar}
|
||||||
unlocked={unlocked}
|
unlocked={unlocked}
|
||||||
gate={gate}
|
|
||||||
onBack={() => setView("list")}
|
onBack={() => setView("list")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -81,99 +38,22 @@ export default function Messages({ meId }: { meId: number }) {
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ConversationList
|
<ConversationList
|
||||||
|
meId={meId}
|
||||||
unlocked={unlocked}
|
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")}
|
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({
|
function ConversationList({
|
||||||
|
meId,
|
||||||
unlocked,
|
unlocked,
|
||||||
gate,
|
|
||||||
onOpen,
|
onOpen,
|
||||||
onNew,
|
onNew,
|
||||||
}: {
|
}: {
|
||||||
|
meId: number;
|
||||||
unlocked: boolean;
|
unlocked: boolean;
|
||||||
gate: React.ReactNode;
|
|
||||||
onOpen: (c: Conversation) => void;
|
onOpen: (c: Conversation) => void;
|
||||||
onNew: () => void;
|
onNew: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -182,8 +62,6 @@ function ConversationList({
|
||||||
const items = q.data?.items ?? [];
|
const items = q.data?.items ?? [];
|
||||||
const [previews, setPreviews] = useState<Record<number, string>>({});
|
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(() => {
|
useEffect(() => {
|
||||||
if (!unlocked) return;
|
if (!unlocked) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -228,36 +106,47 @@ function ConversationList({
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{gate}
|
{!unlocked && <KeyGate meId={meId} />}
|
||||||
{q.isLoading && !q.data ? (
|
{q.isLoading && !q.data ? (
|
||||||
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
|
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<div className="glass rounded-2xl p-10 text-center text-muted">{t("messages.empty")}</div>
|
<div className="glass rounded-2xl p-10 text-center text-muted">{t("messages.empty")}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
{items.map((c) => (
|
{items.map((c) => {
|
||||||
<button
|
const isSystem = c.partner.id === SYSTEM_ID;
|
||||||
key={c.partner.id}
|
return (
|
||||||
onClick={() => onOpen(c)}
|
<div key={c.partner.id} className="group flex items-center gap-1 rounded-xl hover:bg-card transition">
|
||||||
className="flex items-center gap-3 p-2.5 rounded-xl text-left 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" />
|
||||||
<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="min-w-0 flex-1">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<span className={`truncate ${c.unread > 0 ? "font-semibold" : "font-medium"}`}>{c.partner.name}</span>
|
||||||
<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>
|
||||||
<span className="ml-auto text-[11px] text-muted shrink-0">{relativeTime(c.last_message.created_at)}</span>
|
</div>
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<span className={`truncate text-sm ${c.unread > 0 ? "text-fg" : "text-muted"}`}>{previewText(c)}</span>
|
||||||
<span className={`truncate text-sm ${c.unread > 0 ? "text-fg" : "text-muted"}`}>{previewText(c)}</span>
|
{c.unread > 0 && (
|
||||||
{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">
|
||||||
<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}
|
||||||
{c.unread}
|
</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</button>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -305,14 +194,13 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Thread({
|
function PageThread({
|
||||||
meId,
|
meId,
|
||||||
partnerId,
|
partnerId,
|
||||||
isSystem,
|
isSystem,
|
||||||
seedName,
|
seedName,
|
||||||
seedAvatar,
|
seedAvatar,
|
||||||
unlocked,
|
unlocked,
|
||||||
gate,
|
|
||||||
onBack,
|
onBack,
|
||||||
}: {
|
}: {
|
||||||
meId: number;
|
meId: number;
|
||||||
|
|
@ -321,150 +209,32 @@ function Thread({
|
||||||
seedName?: string;
|
seedName?: string;
|
||||||
seedAvatar?: string | null;
|
seedAvatar?: string | null;
|
||||||
unlocked: boolean;
|
unlocked: boolean;
|
||||||
gate: React.ReactNode;
|
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const name = seedName ?? "";
|
||||||
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 ?? "";
|
|
||||||
return (
|
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">
|
<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">
|
<button onClick={onBack} className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition">
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</button>
|
</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>
|
<span className="font-semibold truncate">{name}</span>
|
||||||
{!isSystem && <ShieldCheck className="w-4 h-4 text-muted shrink-0" aria-label={t("messages.encryptedHint")} />}
|
{!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>
|
</div>
|
||||||
|
<ChatThread meId={meId} partnerId={partnerId} isSystem={isSystem} unlocked={unlocked} />
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
Info,
|
Info,
|
||||||
ListVideo,
|
ListVideo,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
MessageSquare,
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
|
|
@ -126,15 +127,14 @@ export default function NavSidebar({
|
||||||
// One indicator for both layers: durable server notifications + the client-side transient
|
// One indicator for both layers: durable server notifications + the client-side transient
|
||||||
// events (the former separate bell is now folded into the inbox page).
|
// events (the former separate bell is now folded into the inbox page).
|
||||||
const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
|
const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
|
||||||
// Unread direct messages also live in the notification module, so they add to the same badge.
|
const unread = (unreadQuery.data?.count ?? 0) + clientUnread;
|
||||||
// The demo account can't use messaging, so don't poll it there.
|
// Direct messages are their own module with their own unread badge (demo can't message).
|
||||||
const msgUnreadQuery = useLiveQuery(
|
const msgUnreadQuery = useLiveQuery(
|
||||||
["message-unread"],
|
["message-unread"],
|
||||||
api.messageUnreadCount,
|
api.messageUnreadCount,
|
||||||
{ intervalMs: 30000, enabled: !me.is_demo }
|
{ intervalMs: 30000, enabled: !me.is_demo }
|
||||||
);
|
);
|
||||||
const unread =
|
const msgUnread = msgUnreadQuery.data?.count ?? 0;
|
||||||
(unreadQuery.data?.count ?? 0) + clientUnread + (msgUnreadQuery.data?.count ?? 0);
|
|
||||||
|
|
||||||
type NavItem = { page: Page; icon: typeof Home; label: string; badge?: number };
|
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.
|
// 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: "channels", icon: Tv, label: t("header.account.channels") },
|
||||||
{ page: "playlists", icon: ListVideo, label: t("header.account.playlists") },
|
{ page: "playlists", icon: ListVideo, label: t("header.account.playlists") },
|
||||||
{ page: "notifications", icon: Bell, label: t("inbox.navLabel"), badge: unread },
|
{ 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).
|
// 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") },
|
{ page: "stats", icon: BarChart3, label: t("header.account.stats") },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
import { useEffect, useSyncExternalStore } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
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 { api, type AppNotification, type FeedFilters } from "../lib/api";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
import Messages from "./Messages";
|
|
||||||
import {
|
import {
|
||||||
clearAll as clearClient,
|
clearAll as clearClient,
|
||||||
dismiss as dismissClient,
|
dismiss as dismissClient,
|
||||||
|
|
@ -50,26 +49,14 @@ export default function NotificationsPanel({
|
||||||
setFilters,
|
setFilters,
|
||||||
setPage,
|
setPage,
|
||||||
onFocusChannel,
|
onFocusChannel,
|
||||||
meId,
|
|
||||||
isDemo,
|
|
||||||
}: {
|
}: {
|
||||||
filters: FeedFilters;
|
filters: FeedFilters;
|
||||||
setFilters: (f: FeedFilters) => void;
|
setFilters: (f: FeedFilters) => void;
|
||||||
setPage: (p: Page) => void;
|
setPage: (p: Page) => void;
|
||||||
onFocusChannel: (name: string) => void;
|
onFocusChannel: (name: string) => void;
|
||||||
meId: number;
|
|
||||||
isDemo: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
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 }>(
|
const q = useLiveQuery<{ items: AppNotification[]; total: number }>(
|
||||||
["notifications"],
|
["notifications"],
|
||||||
() => api.notifications(),
|
() => api.notifications(),
|
||||||
|
|
@ -139,7 +126,7 @@ export default function NotificationsPanel({
|
||||||
<div className="font-semibold">{t("inbox.title")}</div>
|
<div className="font-semibold">{t("inbox.title")}</div>
|
||||||
<div className="text-xs text-muted">{t("inbox.subtitle")}</div>
|
<div className="text-xs text-muted">{t("inbox.subtitle")}</div>
|
||||||
</div>
|
</div>
|
||||||
{tab === "inbox" && hasAny && (
|
{hasAny && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={markAll}
|
onClick={markAll}
|
||||||
|
|
@ -161,37 +148,7 @@ export default function NotificationsPanel({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isDemo && (
|
{q.isLoading && !q.data && clientItems.length === 0 ? (
|
||||||
<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 ? (
|
|
||||||
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
|
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
|
||||||
) : !hasAny ? (
|
) : !hasAny ? (
|
||||||
<div className="glass rounded-2xl p-10 text-center text-muted">
|
<div className="glass rounded-2xl p-10 text-center text-muted">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
{
|
{
|
||||||
"tab": "Nachrichten",
|
"tab": "Nachrichten",
|
||||||
|
"navLabel": "Nachrichten",
|
||||||
|
"popOut": "In Pop-out öffnen",
|
||||||
|
"expand": "Aufklappen",
|
||||||
|
"minimize": "Minimieren",
|
||||||
|
"close": "Schließen",
|
||||||
"conversations": "Unterhaltungen",
|
"conversations": "Unterhaltungen",
|
||||||
"new": "Neue Nachricht",
|
"new": "Neue Nachricht",
|
||||||
"newTitle": "Neue Nachricht",
|
"newTitle": "Neue Nachricht",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
{
|
{
|
||||||
"tab": "Messages",
|
"tab": "Messages",
|
||||||
|
"navLabel": "Messages",
|
||||||
|
"popOut": "Open in pop-out",
|
||||||
|
"expand": "Expand",
|
||||||
|
"minimize": "Minimize",
|
||||||
|
"close": "Close",
|
||||||
"conversations": "Conversations",
|
"conversations": "Conversations",
|
||||||
"new": "New message",
|
"new": "New message",
|
||||||
"newTitle": "New message",
|
"newTitle": "New message",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
{
|
{
|
||||||
"tab": "Üzenetek",
|
"tab": "Üzenetek",
|
||||||
|
"navLabel": "Üzenetek",
|
||||||
|
"popOut": "Megnyitás felugró ablakban",
|
||||||
|
"expand": "Kinyitás",
|
||||||
|
"minimize": "Összecsukás",
|
||||||
|
"close": "Bezárás",
|
||||||
"conversations": "Beszélgetések",
|
"conversations": "Beszélgetések",
|
||||||
"new": "Új üzenet",
|
"new": "Új üzenet",
|
||||||
"newTitle": "Új üzenet",
|
"newTitle": "Új üzenet",
|
||||||
|
|
|
||||||
56
frontend/src/lib/chatDock.ts
Normal file
56
frontend/src/lib/chatDock.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,17 @@ const bsrc = (x: Uint8Array | ArrayBuffer): BufferSource => x as BufferSource;
|
||||||
let privKey: CryptoKey | null = null; // current unlocked (non-extractable) private key
|
let privKey: CryptoKey | null = null; // current unlocked (non-extractable) private key
|
||||||
const convKeys = new Map<number, CryptoKey>(); // partnerId → derived AES 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 {
|
export function isUnlocked(): boolean {
|
||||||
return !!privKey;
|
return !!privKey;
|
||||||
}
|
}
|
||||||
|
|
@ -38,6 +49,7 @@ export function isUnlocked(): boolean {
|
||||||
export function lock(): void {
|
export function lock(): void {
|
||||||
privKey = null;
|
privKey = null;
|
||||||
convKeys.clear();
|
convKeys.clear();
|
||||||
|
emitUnlock();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- IndexedDB (per-device persistence of the unlocked private key) ---
|
// --- 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);
|
const stored = await idbGet(userId);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
privKey = stored;
|
privKey = stored;
|
||||||
|
emitUnlock();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -120,6 +133,7 @@ export async function setup(userId: number, passphrase: string): Promise<KeySetu
|
||||||
privKey = await importPrivate(pkcs8);
|
privKey = await importPrivate(pkcs8);
|
||||||
convKeys.clear();
|
convKeys.clear();
|
||||||
await idbPut(userId, privKey);
|
await idbPut(userId, privKey);
|
||||||
|
emitUnlock();
|
||||||
return {
|
return {
|
||||||
public_key: b64(spki),
|
public_key: b64(spki),
|
||||||
wrapped_private_key: b64(wrapped),
|
wrapped_private_key: b64(wrapped),
|
||||||
|
|
@ -145,6 +159,7 @@ export async function unlock(userId: number, passphrase: string, bundle: MyKeyBu
|
||||||
privKey = await importPrivate(pkcs8);
|
privKey = await importPrivate(pkcs8);
|
||||||
convKeys.clear();
|
convKeys.clear();
|
||||||
await idbPut(userId, privKey);
|
await idbPut(userId, privKey);
|
||||||
|
emitUnlock();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- per-conversation encryption ---
|
// --- per-conversation encryption ---
|
||||||
|
|
|
||||||
21
frontend/src/lib/messaging.ts
Normal file
21
frontend/src/lib/messaging.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -91,6 +91,7 @@ export const PAGES = [
|
||||||
"config",
|
"config",
|
||||||
"users",
|
"users",
|
||||||
"notifications",
|
"notifications",
|
||||||
|
"messages",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type Page = (typeof PAGES)[number];
|
export type Page = (typeof PAGES)[number];
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue