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 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)} />
|
||||
)}
|
||||
|
|
|
|||
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 { 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,19 +106,18 @@ 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"
|
||||
>
|
||||
{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">
|
||||
|
|
@ -257,7 +134,19 @@ function ConversationList({
|
|||
</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>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{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"
|
||||
/>
|
||||
<div className="flex-1" />
|
||||
{!isSystem && unlocked && (
|
||||
<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")}
|
||||
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"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ChatThread meId={meId} partnerId={partnerId} isSystem={isSystem} unlocked={unlocked} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
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
|
||||
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 ---
|
||||
|
|
|
|||
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",
|
||||
"users",
|
||||
"notifications",
|
||||
"messages",
|
||||
] as const;
|
||||
|
||||
export type Page = (typeof PAGES)[number];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue