feat(messages): E2EE real-time messaging UI + system welcome
A Messages tab in the notification center (hidden for demo): set up secure messaging with a passphrase (key generated + wrapped in-browser via WebCrypto, stored non-extractable per device), unlock on other devices, then chat with end-to-end encrypted, live-delivered messages. The server-authored Siftlode welcome is readable before any key setup. - lib/e2ee.ts: ECDH P-256 + HKDF + AES-GCM, PBKDF2-wrapped key, IndexedDB. - lib/messagesSocket.ts: WebSocket client with backoff reconnect. - Messages.tsx: key gate, conversation list with decrypted previews, directory, thread with client-side decrypt + encrypt-on-send. Unread feeds the nav badge. EN/HU/DE strings.
This commit is contained in:
parent
002a79949b
commit
2f0ca68e8a
13 changed files with 943 additions and 6 deletions
|
|
@ -545,7 +545,7 @@ export default function App() {
|
|||
) : page === "playlists" ? (
|
||||
<Playlists canWrite={meQuery.data!.can_write} />
|
||||
) : page === "notifications" ? (
|
||||
<NotificationsPanel filters={filters} setFilters={setFilters} setPage={setPage} onFocusChannel={focusChannel} />
|
||||
<NotificationsPanel filters={filters} setFilters={setFilters} setPage={setPage} onFocusChannel={focusChannel} meId={meQuery.data!.id} isDemo={meQuery.data!.is_demo} />
|
||||
) : page === "settings" ? (
|
||||
<SettingsPanel
|
||||
me={meQuery.data!}
|
||||
|
|
|
|||
470
frontend/src/components/Messages.tsx
Normal file
470
frontend/src/components/Messages.tsx
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
import { useEffect, useRef, 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 { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { relativeTime } from "../lib/format";
|
||||
import { onMessage } from "../lib/messagesSocket";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import Avatar from "./Avatar";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (typeof view === "object") {
|
||||
return (
|
||||
<Thread
|
||||
meId={meId}
|
||||
partnerId={view.partnerId}
|
||||
isSystem={!!view.system}
|
||||
seedName={view.name}
|
||||
seedAvatar={view.avatar}
|
||||
unlocked={unlocked}
|
||||
gate={gate}
|
||||
onBack={() => setView("list")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (view === "new") {
|
||||
return <Directory onPick={(u) => setView({ partnerId: u.id, name: u.name, avatar: u.avatar_url })} onBack={() => setView("list")} />;
|
||||
}
|
||||
return (
|
||||
<ConversationList
|
||||
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 })
|
||||
}
|
||||
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({
|
||||
unlocked,
|
||||
gate,
|
||||
onOpen,
|
||||
onNew,
|
||||
}: {
|
||||
unlocked: boolean;
|
||||
gate: React.ReactNode;
|
||||
onOpen: (c: Conversation) => void;
|
||||
onNew: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const q = useLiveQuery<{ items: Conversation[] }>(["conversations"], api.conversations, { intervalMs: POLL_MS });
|
||||
const items = q.data?.items ?? [];
|
||||
const [previews, setPreviews] = useState<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;
|
||||
(async () => {
|
||||
const out: Record<number, string> = {};
|
||||
for (const c of items) {
|
||||
const m = c.last_message;
|
||||
if (m.kind === "user" && m.ciphertext && m.iv) {
|
||||
try {
|
||||
const pub = await partnerPub(c.partner.id);
|
||||
out[c.partner.id] = await e2ee.decryptFrom(c.partner.id, pub, m.ciphertext, m.iv);
|
||||
} catch {
|
||||
out[c.partner.id] = "⚠";
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!cancelled) setPreviews(out);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [items, unlocked]);
|
||||
|
||||
function previewText(c: Conversation): string {
|
||||
const m = c.last_message;
|
||||
if (m.kind === "system") return m.body ?? "";
|
||||
if (!unlocked) return "🔒 " + t("messages.encrypted");
|
||||
return previews[c.partner.id] ?? "…";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs uppercase tracking-wide text-muted">{t("messages.conversations")}</div>
|
||||
{unlocked && (
|
||||
<button
|
||||
onClick={onNew}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg bg-accent text-accent-fg hover:opacity-90 transition"
|
||||
>
|
||||
<SquarePen className="w-4 h-4" />
|
||||
{t("messages.new")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{gate}
|
||||
{q.isLoading && !q.data ? (
|
||||
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="glass rounded-2xl p-10 text-center text-muted">{t("messages.empty")}</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{items.map((c) => (
|
||||
<button
|
||||
key={c.partner.id}
|
||||
onClick={() => onOpen(c)}
|
||||
className="flex items-center gap-3 p-2.5 rounded-xl text-left hover:bg-card transition"
|
||||
>
|
||||
<Avatar src={c.partner.avatar_url} fallback={c.partner.name} className="w-10 h-10 rounded-full shrink-0 text-sm" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`truncate ${c.unread > 0 ? "font-semibold" : "font-medium"}`}>{c.partner.name}</span>
|
||||
<span className="ml-auto text-[11px] text-muted shrink-0">{relativeTime(c.last_message.created_at)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`truncate text-sm ${c.unread > 0 ? "text-fg" : "text-muted"}`}>{previewText(c)}</span>
|
||||
{c.unread > 0 && (
|
||||
<span className="ml-auto shrink-0 min-w-5 h-5 px-1.5 grid place-items-center rounded-full bg-accent text-accent-fg text-[11px] font-semibold">
|
||||
{c.unread}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBack: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [filter, setFilter] = useState("");
|
||||
const q = useQuery({ queryKey: ["message-users"], queryFn: api.messageUsers });
|
||||
const users = (q.data ?? []).filter((u) => u.name.toLowerCase().includes(filter.trim().toLowerCase()));
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
<div className="text-xs uppercase tracking-wide text-muted">{t("messages.newTitle")}</div>
|
||||
</div>
|
||||
<input
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder={t("messages.searchPeople")}
|
||||
className="w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
{q.isLoading ? (
|
||||
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="glass rounded-2xl p-10 text-center text-muted">{t("messages.noPeople")}</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{users.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => onPick(u)}
|
||||
className="flex items-center gap-3 p-2.5 rounded-xl text-left hover:bg-card transition"
|
||||
>
|
||||
<Avatar src={u.avatar_url} fallback={u.name} className="w-9 h-9 rounded-full shrink-0 text-sm" />
|
||||
<span className="truncate font-medium">{u.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Thread({
|
||||
meId,
|
||||
partnerId,
|
||||
isSystem,
|
||||
seedName,
|
||||
seedAvatar,
|
||||
unlocked,
|
||||
gate,
|
||||
onBack,
|
||||
}: {
|
||||
meId: number;
|
||||
partnerId: number;
|
||||
isSystem: boolean;
|
||||
seedName?: string;
|
||||
seedAvatar?: string | null;
|
||||
unlocked: boolean;
|
||||
gate: React.ReactNode;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [draft, setDraft] = useState("");
|
||||
const [plain, setPlain] = useState<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 (
|
||||
<div className="flex flex-col h-[60vh] 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" />
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -126,7 +126,15 @@ 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);
|
||||
const unread = (unreadQuery.data?.count ?? 0) + clientUnread;
|
||||
// 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 msgUnreadQuery = useLiveQuery(
|
||||
["message-unread"],
|
||||
api.messageUnreadCount,
|
||||
{ intervalMs: 30000, enabled: !me.is_demo }
|
||||
);
|
||||
const unread =
|
||||
(unreadQuery.data?.count ?? 0) + clientUnread + (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.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Activity, Bell, Check, CheckCheck, ExternalLink, Eye, RotateCcw, Search, Trash2, Tv, UserPlus, X } from "lucide-react";
|
||||
import { Activity, Bell, Check, CheckCheck, ExternalLink, Eye, MessageSquare, 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,
|
||||
|
|
@ -49,14 +50,26 @@ 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(),
|
||||
|
|
@ -126,7 +139,7 @@ export default function NotificationsPanel({
|
|||
<div className="font-semibold">{t("inbox.title")}</div>
|
||||
<div className="text-xs text-muted">{t("inbox.subtitle")}</div>
|
||||
</div>
|
||||
{hasAny && (
|
||||
{tab === "inbox" && hasAny && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={markAll}
|
||||
|
|
@ -148,7 +161,37 @@ export default function NotificationsPanel({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{q.isLoading && !q.data && clientItems.length === 0 ? (
|
||||
{!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 ? (
|
||||
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
|
||||
) : !hasAny ? (
|
||||
<div className="glass rounded-2xl p-10 text-center text-muted">
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"navLabel": "Benachrichtigungen",
|
||||
"title": "Benachrichtigungen",
|
||||
"subtitle": "Neuigkeiten aus deiner Bibliothek und vom System.",
|
||||
"tabInbox": "Benachrichtigungen",
|
||||
"empty": "Alles erledigt.",
|
||||
"markAllRead": "Alle als gelesen markieren",
|
||||
"clearAll": "Alle löschen",
|
||||
|
|
|
|||
29
frontend/src/i18n/locales/de/messages.json
Normal file
29
frontend/src/i18n/locales/de/messages.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"tab": "Nachrichten",
|
||||
"conversations": "Unterhaltungen",
|
||||
"new": "Neue Nachricht",
|
||||
"newTitle": "Neue Nachricht",
|
||||
"empty": "Noch keine Unterhaltungen. Starte eine über die Schaltfläche „Neue Nachricht“.",
|
||||
"searchPeople": "Personen suchen…",
|
||||
"noPeople": "Noch niemand zum Schreiben.",
|
||||
"threadEmpty": "Noch keine Nachrichten — sag Hallo.",
|
||||
"writePlaceholder": "Nachricht schreiben…",
|
||||
"send": "Senden",
|
||||
"encrypted": "Verschlüsselte Nachricht",
|
||||
"encryptedHint": "Ende-zu-Ende-verschlüsselt",
|
||||
"cantDecrypt": "Kann nicht entschlüsselt werden",
|
||||
"systemReadOnly": "Dies ist eine automatische Nachricht.",
|
||||
"setupTitle": "Sichere Nachrichten einrichten",
|
||||
"setupBody": "Nachrichten sind Ende-zu-Ende-verschlüsselt. Wähle ein Passwort zum Schutz deines Schlüssels — er verlässt nie dein Gerät, sodass niemand (nicht einmal ein Admin) deine Unterhaltungen lesen kann.",
|
||||
"setupWarn": "Wenn du dieses Passwort vergisst, kann dein Nachrichtenverlauf nicht wiederhergestellt werden.",
|
||||
"setUp": "Einrichten",
|
||||
"unlockTitle": "Nachrichten entsperren",
|
||||
"unlockBody": "Gib dein Nachrichten-Passwort ein, um deine Unterhaltungen auf diesem Gerät zu entsperren.",
|
||||
"unlock": "Entsperren",
|
||||
"passphrase": "Passwort",
|
||||
"passphraseConfirm": "Passwort bestätigen",
|
||||
"passTooShort": "Mindestens 6 Zeichen verwenden.",
|
||||
"passMismatch": "Die Passwörter stimmen nicht überein.",
|
||||
"wrongPass": "Falsches Passwort.",
|
||||
"setupFailed": "Sichere Nachrichten konnten nicht eingerichtet werden. Bitte erneut versuchen."
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
"navLabel": "Notifications",
|
||||
"title": "Notifications",
|
||||
"subtitle": "Updates from your library and the system.",
|
||||
"tabInbox": "Notifications",
|
||||
"empty": "You're all caught up.",
|
||||
"markAllRead": "Mark all read",
|
||||
"clearAll": "Clear all",
|
||||
|
|
|
|||
29
frontend/src/i18n/locales/en/messages.json
Normal file
29
frontend/src/i18n/locales/en/messages.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"tab": "Messages",
|
||||
"conversations": "Conversations",
|
||||
"new": "New message",
|
||||
"newTitle": "New message",
|
||||
"empty": "No conversations yet. Start one with the New message button.",
|
||||
"searchPeople": "Search people…",
|
||||
"noPeople": "No one to message yet.",
|
||||
"threadEmpty": "No messages yet — say hello.",
|
||||
"writePlaceholder": "Write a message…",
|
||||
"send": "Send",
|
||||
"encrypted": "Encrypted message",
|
||||
"encryptedHint": "End-to-end encrypted",
|
||||
"cantDecrypt": "Can't decrypt",
|
||||
"systemReadOnly": "This is an automated message.",
|
||||
"setupTitle": "Set up secure messaging",
|
||||
"setupBody": "Messages are end-to-end encrypted. Choose a passphrase to protect your key — it never leaves your device, so no one (not even an admin) can read your conversations.",
|
||||
"setupWarn": "If you forget this passphrase, your message history can't be recovered.",
|
||||
"setUp": "Set up",
|
||||
"unlockTitle": "Unlock messaging",
|
||||
"unlockBody": "Enter your message passphrase to unlock your conversations on this device.",
|
||||
"unlock": "Unlock",
|
||||
"passphrase": "Passphrase",
|
||||
"passphraseConfirm": "Confirm passphrase",
|
||||
"passTooShort": "Use at least 6 characters.",
|
||||
"passMismatch": "The passphrases don't match.",
|
||||
"wrongPass": "Wrong passphrase.",
|
||||
"setupFailed": "Couldn't set up secure messaging. Please try again."
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
"navLabel": "Értesítések",
|
||||
"title": "Értesítések",
|
||||
"subtitle": "Frissítések a könyvtáradból és a rendszertől.",
|
||||
"tabInbox": "Értesítések",
|
||||
"empty": "Nincs új értesítés.",
|
||||
"markAllRead": "Összes olvasott",
|
||||
"clearAll": "Összes törlése",
|
||||
|
|
|
|||
29
frontend/src/i18n/locales/hu/messages.json
Normal file
29
frontend/src/i18n/locales/hu/messages.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"tab": "Üzenetek",
|
||||
"conversations": "Beszélgetések",
|
||||
"new": "Új üzenet",
|
||||
"newTitle": "Új üzenet",
|
||||
"empty": "Még nincs beszélgetés. Indíts egyet az Új üzenet gombbal.",
|
||||
"searchPeople": "Emberek keresése…",
|
||||
"noPeople": "Még nincs kinek üzenni.",
|
||||
"threadEmpty": "Még nincs üzenet — köszönj be.",
|
||||
"writePlaceholder": "Írj egy üzenetet…",
|
||||
"send": "Küldés",
|
||||
"encrypted": "Titkosított üzenet",
|
||||
"encryptedHint": "Végpontig titkosítva",
|
||||
"cantDecrypt": "Nem fejthető vissza",
|
||||
"systemReadOnly": "Ez egy automatikus üzenet.",
|
||||
"setupTitle": "Biztonságos üzenetküldés beállítása",
|
||||
"setupBody": "Az üzenetek végpontig titkosítottak. Válassz egy jelszót a kulcsod védelméhez — sosem hagyja el az eszközödet, így senki (még az admin sem) olvashatja a beszélgetéseidet.",
|
||||
"setupWarn": "Ha elfelejted ezt a jelszót, az üzenet-előzményeid nem állíthatók helyre.",
|
||||
"setUp": "Beállítás",
|
||||
"unlockTitle": "Üzenetküldés feloldása",
|
||||
"unlockBody": "Add meg az üzenet-jelszavadat a beszélgetéseid feloldásához ezen az eszközön.",
|
||||
"unlock": "Feloldás",
|
||||
"passphrase": "Jelszó",
|
||||
"passphraseConfirm": "Jelszó megerősítése",
|
||||
"passTooShort": "Legalább 6 karakter legyen.",
|
||||
"passMismatch": "A jelszavak nem egyeznek.",
|
||||
"wrongPass": "Hibás jelszó.",
|
||||
"setupFailed": "A biztonságos üzenetküldés beállítása nem sikerült. Próbáld újra."
|
||||
}
|
||||
|
|
@ -469,6 +469,48 @@ export interface AppNotification {
|
|||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface MessageUser {
|
||||
id: number;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: number;
|
||||
kind: "user" | "system";
|
||||
sender_id: number | null;
|
||||
recipient_id: number;
|
||||
body: string | null; // plaintext, system messages only
|
||||
ciphertext: string | null; // base64, E2EE user messages only
|
||||
iv: string | null;
|
||||
read_at: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
partner: MessageUser;
|
||||
last_message: Message;
|
||||
unread: number;
|
||||
}
|
||||
|
||||
// My own E2EE key bundle. When configured, the wrapped blob + params let a device unlock.
|
||||
export interface MyKeyBundle {
|
||||
configured: boolean;
|
||||
public_key?: string;
|
||||
wrapped_private_key?: string;
|
||||
salt?: string;
|
||||
wrap_iv?: string;
|
||||
key_check?: string;
|
||||
}
|
||||
|
||||
export interface KeySetup {
|
||||
public_key: string;
|
||||
wrapped_private_key: string;
|
||||
salt: string;
|
||||
wrap_iv: string;
|
||||
key_check: string;
|
||||
}
|
||||
|
||||
// Admin Configuration page: one DB-overridable setting (registry entry on the backend).
|
||||
export interface ConfigItem {
|
||||
key: string;
|
||||
|
|
@ -712,6 +754,23 @@ export const api = {
|
|||
req(`/api/me/notifications/${id}/dismiss`, { method: "POST" }),
|
||||
clearNotifications: () => req("/api/me/notifications/clear", { method: "POST" }),
|
||||
|
||||
// --- direct messages (end-to-end encrypted) ---
|
||||
messageMyKey: (): Promise<MyKeyBundle> => req("/api/messages/keys/me"),
|
||||
setupMessageKey: (k: KeySetup): Promise<{ configured: boolean }> =>
|
||||
req("/api/messages/keys", { method: "POST", body: JSON.stringify(k) }),
|
||||
messagePublicKey: (userId: number): Promise<{ user_id: number; public_key: string }> =>
|
||||
req(`/api/messages/keys/${userId}`),
|
||||
messageUsers: (): Promise<MessageUser[]> => req("/api/messages/users"),
|
||||
conversations: (): Promise<{ items: Conversation[] }> => req("/api/messages/conversations"),
|
||||
thread: (partnerId: number): Promise<{ partner: MessageUser; items: Message[] }> =>
|
||||
req(`/api/messages/conversations/${partnerId}`),
|
||||
sendMessage: (recipientId: number, ciphertext: string, iv: string): Promise<Message> =>
|
||||
req("/api/messages", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ recipient_id: recipientId, ciphertext, iv }),
|
||||
}),
|
||||
messageUnreadCount: (): Promise<{ count: number }> => req("/api/messages/unread_count"),
|
||||
|
||||
// --- user tags ---
|
||||
createTag: (t: { name: string; color?: string; category?: string }) =>
|
||||
req("/api/tags", { method: "POST", body: JSON.stringify(t) }),
|
||||
|
|
|
|||
189
frontend/src/lib/e2ee.ts
Normal file
189
frontend/src/lib/e2ee.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
// End-to-end encryption for direct messages (notification module phase 2).
|
||||
//
|
||||
// Contract (validated against the backend): each user has an ECDH P-256 keypair. The private
|
||||
// key is wrapped in the browser with an AES-GCM key derived (PBKDF2) from a message passphrase
|
||||
// the server never sees; only the wrapped blob is uploaded. A conversation key is derived from
|
||||
// ECDH(my private, partner public) → HKDF → AES-GCM, and each message is AES-GCM encrypted with
|
||||
// a random IV. The server stores only ciphertext, so not even an admin can read a conversation.
|
||||
//
|
||||
// The unlocked private key is persisted per-device as a NON-EXTRACTABLE CryptoKey in IndexedDB,
|
||||
// so the passphrase is needed once per device, then survives reloads (cleared with browser data).
|
||||
import type { KeySetup, MyKeyBundle } from "./api";
|
||||
|
||||
const subtle = crypto.subtle;
|
||||
const enc = new TextEncoder();
|
||||
const dec = new TextDecoder();
|
||||
const PBKDF2_ITERS = 600_000;
|
||||
const HKDF_INFO = "siftlode-dm-v1";
|
||||
|
||||
const b64 = (buf: ArrayBuffer | Uint8Array): string => {
|
||||
const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
|
||||
let s = "";
|
||||
for (const b of bytes) s += String.fromCharCode(b);
|
||||
return btoa(s);
|
||||
};
|
||||
const ub64 = (s: string): Uint8Array => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
||||
// WebCrypto byte inputs: the DOM lib types want BufferSource backed by a plain ArrayBuffer;
|
||||
// our Uint8Arrays/ArrayBuffers are functionally that, so coerce at the call sites.
|
||||
const bsrc = (x: Uint8Array | ArrayBuffer): BufferSource => x as BufferSource;
|
||||
|
||||
// --- module state ---
|
||||
let privKey: CryptoKey | null = null; // current unlocked (non-extractable) private key
|
||||
const convKeys = new Map<number, CryptoKey>(); // partnerId → derived AES key
|
||||
|
||||
export function isUnlocked(): boolean {
|
||||
return !!privKey;
|
||||
}
|
||||
|
||||
export function lock(): void {
|
||||
privKey = null;
|
||||
convKeys.clear();
|
||||
}
|
||||
|
||||
// --- IndexedDB (per-device persistence of the unlocked private key) ---
|
||||
const DB_NAME = "siftlode-e2ee";
|
||||
const STORE = "privkeys";
|
||||
|
||||
function idb(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, 1);
|
||||
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
async function idbGet(userId: number): Promise<CryptoKey | null> {
|
||||
const db = await idb();
|
||||
return new Promise((resolve) => {
|
||||
const r = db.transaction(STORE, "readonly").objectStore(STORE).get(userId);
|
||||
r.onsuccess = () => resolve((r.result as CryptoKey) ?? null);
|
||||
r.onerror = () => resolve(null);
|
||||
});
|
||||
}
|
||||
async function idbPut(userId: number, key: CryptoKey): Promise<void> {
|
||||
const db = await idb();
|
||||
await new Promise<void>((resolve) => {
|
||||
const tx = db.transaction(STORE, "readwrite");
|
||||
tx.objectStore(STORE).put(key, userId);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => resolve();
|
||||
});
|
||||
}
|
||||
export async function clearDevice(userId: number): Promise<void> {
|
||||
const db = await idb();
|
||||
await new Promise<void>((resolve) => {
|
||||
const tx = db.transaction(STORE, "readwrite");
|
||||
tx.objectStore(STORE).delete(userId);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => resolve();
|
||||
});
|
||||
}
|
||||
|
||||
// --- key derivation ---
|
||||
async function wrapKeyFrom(passphrase: string, salt: Uint8Array): Promise<CryptoKey> {
|
||||
const base = await subtle.importKey("raw", bsrc(enc.encode(passphrase)), "PBKDF2", false, ["deriveKey"]);
|
||||
return subtle.deriveKey(
|
||||
{ name: "PBKDF2", salt: bsrc(salt), iterations: PBKDF2_ITERS, hash: "SHA-256" },
|
||||
base,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
}
|
||||
async function importPrivate(pkcs8: ArrayBuffer): Promise<CryptoKey> {
|
||||
// Non-extractable: usable for deriveBits but can never be exported again.
|
||||
return subtle.importKey("pkcs8", bsrc(pkcs8), { name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]);
|
||||
}
|
||||
|
||||
// Try to restore the unlocked key from this device's storage. Returns true if unlocked.
|
||||
export async function loadFromDevice(userId: number): Promise<boolean> {
|
||||
const stored = await idbGet(userId);
|
||||
if (stored) {
|
||||
privKey = stored;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// First-run setup: generate a keypair, wrap the private key with the passphrase, persist a
|
||||
// non-extractable copy locally, and return the bundle to upload to the server.
|
||||
export async function setup(userId: number, passphrase: string): Promise<KeySetup> {
|
||||
const kp = await subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const wrapIv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const wrapKey = await wrapKeyFrom(passphrase, salt);
|
||||
const pkcs8 = await subtle.exportKey("pkcs8", kp.privateKey);
|
||||
const wrapped = await subtle.encrypt({ name: "AES-GCM", iv: bsrc(wrapIv) }, wrapKey, bsrc(pkcs8));
|
||||
const keyCheck = await subtle.encrypt({ name: "AES-GCM", iv: bsrc(wrapIv) }, wrapKey, bsrc(enc.encode("ok")));
|
||||
const spki = await subtle.exportKey("spki", kp.publicKey);
|
||||
|
||||
privKey = await importPrivate(pkcs8);
|
||||
convKeys.clear();
|
||||
await idbPut(userId, privKey);
|
||||
return {
|
||||
public_key: b64(spki),
|
||||
wrapped_private_key: b64(wrapped),
|
||||
salt: b64(salt),
|
||||
wrap_iv: b64(wrapIv),
|
||||
key_check: b64(keyCheck),
|
||||
};
|
||||
}
|
||||
|
||||
// Unlock on a device from the server blob; throws if the passphrase is wrong.
|
||||
export async function unlock(userId: number, passphrase: string, bundle: MyKeyBundle): Promise<void> {
|
||||
if (!bundle.wrapped_private_key || !bundle.salt || !bundle.wrap_iv || !bundle.key_check) {
|
||||
throw new Error("incomplete key bundle");
|
||||
}
|
||||
const wrapKey = await wrapKeyFrom(passphrase, ub64(bundle.salt));
|
||||
// Wrong passphrase → AES-GCM auth tag fails here.
|
||||
await subtle.decrypt({ name: "AES-GCM", iv: bsrc(ub64(bundle.wrap_iv)) }, wrapKey, bsrc(ub64(bundle.key_check)));
|
||||
const pkcs8 = await subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: bsrc(ub64(bundle.wrap_iv)) },
|
||||
wrapKey,
|
||||
bsrc(ub64(bundle.wrapped_private_key))
|
||||
);
|
||||
privKey = await importPrivate(pkcs8);
|
||||
convKeys.clear();
|
||||
await idbPut(userId, privKey);
|
||||
}
|
||||
|
||||
// --- per-conversation encryption ---
|
||||
async function convKeyFor(partnerId: number, partnerPubB64: string): Promise<CryptoKey> {
|
||||
const cached = convKeys.get(partnerId);
|
||||
if (cached) return cached;
|
||||
if (!privKey) throw new Error("locked");
|
||||
const pub = await subtle.importKey("spki", bsrc(ub64(partnerPubB64)), { name: "ECDH", namedCurve: "P-256" }, false, []);
|
||||
const bits = await subtle.deriveBits({ name: "ECDH", public: pub }, privKey, 256);
|
||||
const hk = await subtle.importKey("raw", bits, "HKDF", false, ["deriveKey"]);
|
||||
const key = await subtle.deriveKey(
|
||||
{ name: "HKDF", hash: "SHA-256", salt: bsrc(new Uint8Array(0)), info: bsrc(enc.encode(HKDF_INFO)) },
|
||||
hk,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
convKeys.set(partnerId, key);
|
||||
return key;
|
||||
}
|
||||
|
||||
export async function encryptFor(
|
||||
partnerId: number,
|
||||
partnerPubB64: string,
|
||||
plaintext: string
|
||||
): Promise<{ ciphertext: string; iv: string }> {
|
||||
const key = await convKeyFor(partnerId, partnerPubB64);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ct = await subtle.encrypt({ name: "AES-GCM", iv: bsrc(iv) }, key, bsrc(enc.encode(plaintext)));
|
||||
return { ciphertext: b64(ct), iv: b64(iv) };
|
||||
}
|
||||
|
||||
export async function decryptFrom(
|
||||
partnerId: number,
|
||||
partnerPubB64: string,
|
||||
ciphertext: string,
|
||||
iv: string
|
||||
): Promise<string> {
|
||||
const key = await convKeyFor(partnerId, partnerPubB64);
|
||||
const pt = await subtle.decrypt({ name: "AES-GCM", iv: bsrc(ub64(iv)) }, key, bsrc(ub64(ciphertext)));
|
||||
return dec.decode(pt);
|
||||
}
|
||||
78
frontend/src/lib/messagesSocket.ts
Normal file
78
frontend/src/lib/messagesSocket.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
// Live message delivery over a WebSocket. The server pushes a {type:"message", message} frame
|
||||
// to all of a user's open tabs when a message is stored; subscribers react (e.g. refetch the
|
||||
// thread + conversations). Auto-reconnects with backoff; a low-frequency poll elsewhere is the
|
||||
// safety net if the socket is down.
|
||||
import type { Message } from "./api";
|
||||
|
||||
type Handler = (msg: Message) => void;
|
||||
|
||||
const handlers = new Set<Handler>();
|
||||
let ws: WebSocket | null = null;
|
||||
let started = false;
|
||||
let backoff = 1000;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function url(): string {
|
||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
return `${proto}://${location.host}/api/messages/ws`;
|
||||
}
|
||||
|
||||
function open() {
|
||||
try {
|
||||
ws = new WebSocket(url());
|
||||
} catch {
|
||||
schedule();
|
||||
return;
|
||||
}
|
||||
ws.onopen = () => {
|
||||
backoff = 1000;
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const data = JSON.parse(ev.data);
|
||||
if (data?.type === "message" && data.message) {
|
||||
for (const h of handlers) h(data.message as Message);
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed frames */
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
ws = null;
|
||||
if (started) schedule();
|
||||
};
|
||||
ws.onerror = () => {
|
||||
ws?.close();
|
||||
};
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (reconnectTimer) return;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
if (started) open();
|
||||
}, backoff);
|
||||
backoff = Math.min(backoff * 2, 30000);
|
||||
}
|
||||
|
||||
// Subscribe to live messages. Opens the socket on the first subscriber and closes it when the
|
||||
// last one leaves.
|
||||
export function onMessage(h: Handler): () => void {
|
||||
handlers.add(h);
|
||||
if (!started) {
|
||||
started = true;
|
||||
open();
|
||||
}
|
||||
return () => {
|
||||
handlers.delete(h);
|
||||
if (handlers.size === 0) {
|
||||
started = false;
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
ws?.close();
|
||||
ws = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue