feat!: remove direct messaging (chat)
E2EE direct messages, the chat dock, the Messages module, the message WebSocket and its realtime registry, and the messages / message_keys tables (migration 0097; the downgrade restores the empty schema). Prod held two welcome notes, no message between people and one key — never used (owner decision, E34). WebSocket now has no owner module: the lint bans it everywhere. Privacy policy updated: it no longer promises E2EE for a feature that is gone.
This commit is contained in:
@@ -1,131 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronDown, ChevronUp, X } from "lucide-react";
|
||||
import {
|
||||
closeChat,
|
||||
initDock,
|
||||
notifyIncoming,
|
||||
toggleMinimize,
|
||||
useDockChats,
|
||||
type DockChat,
|
||||
} from "../lib/chatDock";
|
||||
import { onMessage, onUnread } from "../lib/messagesSocket";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import { errorAttrs, log } from "../lib/log";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import ChatThread from "./ChatThread";
|
||||
import Overlay from "./ui/Overlay";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// The Messenger-style floating chat dock: open conversations live here, bottom-right, across
|
||||
// page navigation (state persisted per user). 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();
|
||||
|
||||
// Restore the per-device unlocked key and the persisted dock windows once, app-wide.
|
||||
useEffect(() => {
|
||||
initDock(meId);
|
||||
// The device key store can be unopenable (storage disabled, some private windows): messages then
|
||||
// stay locked behind the passphrase, as on a new device — said in the log, not thrown at nobody.
|
||||
e2ee
|
||||
.loadFromDevice(meId)
|
||||
.catch((err: unknown) =>
|
||||
log.warn(
|
||||
"ui.e2ee_device_key_unreadable",
|
||||
"the device key store could not be read",
|
||||
errorAttrs(err),
|
||||
),
|
||||
);
|
||||
}, [meId]);
|
||||
|
||||
// Live delivery: refresh the conversation list, unread badge, and the affected thread; and
|
||||
// for an INCOMING message, open or flash that partner's dock window.
|
||||
useEffect(
|
||||
() =>
|
||||
onMessage(({ message: m, from }) => {
|
||||
qc.invalidateQueries({ queryKey: qk.conversations() });
|
||||
qc.invalidateQueries({ queryKey: qk.messageUnread() });
|
||||
const partnerId = m.sender_id === meId ? m.recipient_id : m.sender_id;
|
||||
qc.invalidateQueries({ queryKey: qk.thread(partnerId) });
|
||||
// Auto-open/flash only for messages from someone else (not my own echo) and only for
|
||||
// real user conversations.
|
||||
if (m.kind === "user" && m.sender_id !== meId && from) {
|
||||
notifyIncoming({ id: from.id, name: from.name, avatar_url: from.avatar_url });
|
||||
}
|
||||
}),
|
||||
[qc, meId],
|
||||
);
|
||||
|
||||
// Live badge sync: another tab/device read a thread → refresh this tab's unread count + list.
|
||||
useEffect(
|
||||
() =>
|
||||
onUnread(() => {
|
||||
qc.invalidateQueries({ queryKey: qk.messageUnread() });
|
||||
qc.invalidateQueries({ queryKey: qk.conversations() });
|
||||
}),
|
||||
[qc],
|
||||
);
|
||||
|
||||
if (chats.length === 0) return null;
|
||||
return (
|
||||
<Overlay>
|
||||
<div className="fixed bottom-0 right-0 z-rail flex flex-row-reverse items-end gap-3 p-4 pointer-events-none">
|
||||
{chats.map((c) => (
|
||||
<ChatWindow key={c.partnerId} chat={c} meId={meId} />
|
||||
))}
|
||||
</div>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className={`${SURFACE.panel} pointer-events-auto relative w-80 max-w-[calc(100vw-2rem)] flex flex-col overflow-hidden`}
|
||||
>
|
||||
{/* One-shot attention flash on a new message; keyed by the counter so it replays each time. */}
|
||||
{chat.flash > 0 && (
|
||||
<div
|
||||
key={chat.flash}
|
||||
className="chat-flash pointer-events-none absolute inset-0 rounded-2xl z-base"
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-card/50">
|
||||
<Pressable
|
||||
selectable
|
||||
onClick={() => toggleMinimize(chat.partnerId)}
|
||||
className="flex items-center gap-2 min-w-0 flex-1"
|
||||
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>
|
||||
</Pressable>
|
||||
<IconButton
|
||||
onClick={() => toggleMinimize(chat.partnerId)}
|
||||
label={chat.minimized ? t("messages.expand") : t("messages.minimize")}
|
||||
>
|
||||
{chat.minimized ? <ChevronUp /> : <ChevronDown />}
|
||||
</IconButton>
|
||||
<IconButton onClick={() => closeChat(chat.partnerId)} label={t("messages.close")}>
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
{!chat.minimized && (
|
||||
<div className="flex flex-col h-96">
|
||||
<ChatThread meId={meId} partnerId={chat.partnerId} isSystem={false} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
import { useEffect, useEffectEvent, useRef, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
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 { useScrollFade } from "../lib/useScrollFade";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { partnerPub, POLL_MS, refreshPartnerPub, useKeyState } from "../lib/messaging";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import KeyGate from "./KeyGate";
|
||||
import { Textarea } from "./ui/Input";
|
||||
import { RelativeTime } from "./ui/RelativeTime";
|
||||
|
||||
// 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. Self-contained key gating: prompts
|
||||
// setup/unlock (server-truth) for a user conversation until messaging is ready.
|
||||
export default function ChatThread({
|
||||
meId,
|
||||
partnerId,
|
||||
isSystem,
|
||||
}: {
|
||||
meId: number;
|
||||
partnerId: number;
|
||||
isSystem: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [draft, setDraft] = useState("");
|
||||
const [plain, setPlain] = useState<Record<number, string>>({});
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const { ref: fadeRef, style: fadeStyle } = useScrollFade();
|
||||
const ready = useKeyState() === "ready";
|
||||
const needsKey = !isSystem && !ready;
|
||||
|
||||
const q = useLiveQuery<{ partner: MessageUser; items: Message[] }>(
|
||||
qk.thread(partnerId),
|
||||
() => api.thread(partnerId),
|
||||
{ intervalMs: POLL_MS, enabled: !needsKey },
|
||||
);
|
||||
const items = q.data?.items ?? [];
|
||||
// The two effects below take the FETCH as their trigger and read the payload through this effect
|
||||
// event, so their dep lists name everything they touch without making the identity of `q.data` a
|
||||
// trigger.
|
||||
const latestThread = useEffectEvent(() => q.data);
|
||||
|
||||
// Decrypt the thread when fresh server data arrives. Keyed on dataUpdatedAt (not `items`): the
|
||||
// `?? []` fallback is a new array reference every render, which would otherwise re-run this
|
||||
// effect (and setPlain a new object) on every render while data is loading — a render storm.
|
||||
useEffect(() => {
|
||||
if (isSystem || !ready) return;
|
||||
let cancelled = false;
|
||||
const thread = latestThread();
|
||||
// Every await inside is caught per message (a failure reads "can't decrypt"), so this never rejects.
|
||||
void (async () => {
|
||||
const out: Record<number, string> = {};
|
||||
// Fetch the partner key lazily inside the try (only when there's a ciphertext message) and
|
||||
// once — so an empty thread never fetches, and a fetch failure (e.g. a partner who reset and
|
||||
// hasn't re-set-up, so has no key) degrades to "can't decrypt" instead of rejecting the pass.
|
||||
let pub: string | null = null;
|
||||
let refreshed = false;
|
||||
for (const m of thread?.items ?? []) {
|
||||
if (!m.ciphertext || !m.iv) continue;
|
||||
try {
|
||||
if (!pub) pub = await partnerPub(partnerId);
|
||||
out[m.id] = await e2ee.decryptFrom(partnerId, pub, m.ciphertext, m.iv);
|
||||
} catch {
|
||||
// A decrypt failure can mean the partner rotated their keypair (a passphrase reset), so
|
||||
// this device's cached public key is stale. Refresh it ONCE per pass and retry — that
|
||||
// also re-derives future sends under the current key. A genuinely bad message still ends
|
||||
// up "can't decrypt".
|
||||
if (!refreshed) {
|
||||
refreshed = true;
|
||||
try {
|
||||
pub = await refreshPartnerPub(partnerId);
|
||||
out[m.id] = await e2ee.decryptFrom(partnerId, pub, m.ciphertext, m.iv);
|
||||
continue;
|
||||
} catch {
|
||||
/* fall through to the error label below */
|
||||
}
|
||||
}
|
||||
out[m.id] = "⚠ " + t("messages.cantDecrypt");
|
||||
}
|
||||
}
|
||||
if (!cancelled) setPlain(out);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// `dataUpdatedAt` is the trigger and the payload is read through the effect event above — see
|
||||
// Messages, where keying this family on `q.data` itself produced a render loop.
|
||||
}, [q.dataUpdatedAt, ready, isSystem, partnerId, t]);
|
||||
|
||||
// Opening the thread (or a new incoming message arriving) marks messages read server-side, so
|
||||
// refresh the badges — but ONLY when the incoming-message count actually changed, not on every
|
||||
// 20s poll (which would needlessly refetch /conversations + /unread_count each tick).
|
||||
const lastIncoming = useRef(-1);
|
||||
const lastPartner = useRef(partnerId);
|
||||
useEffect(() => {
|
||||
if (!q.dataUpdatedAt) return;
|
||||
// The Messages page reuses one ChatThread instance across conversation switches (not keyed by
|
||||
// partnerId), so reset the counter when the partner changes — otherwise switching to a thread
|
||||
// with the same incoming-count would skip the open-marks-read refresh.
|
||||
if (lastPartner.current !== partnerId) {
|
||||
lastPartner.current = partnerId;
|
||||
lastIncoming.current = -1;
|
||||
}
|
||||
const incoming = (latestThread()?.items ?? []).filter((m) => m.recipient_id === meId).length;
|
||||
if (incoming !== lastIncoming.current) {
|
||||
lastIncoming.current = incoming;
|
||||
qc.invalidateQueries({ queryKey: qk.conversations() });
|
||||
qc.invalidateQueries({ queryKey: qk.messageUnread() });
|
||||
}
|
||||
}, [q.dataUpdatedAt, partnerId, meId, qc]);
|
||||
|
||||
// Scroll to the newest message when the count changes (open / new message) — NOT on `plain`,
|
||||
// which gets a fresh object on every poll's decrypt pass and would yank a scrolled-up reader
|
||||
// back to the bottom even when nothing new arrived.
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ block: "end" });
|
||||
}, [items.length]);
|
||||
|
||||
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: qk.thread(partnerId) });
|
||||
qc.invalidateQueries({ queryKey: qk.conversations() });
|
||||
},
|
||||
onError: (e) => {
|
||||
// The draft is kept (only cleared on success) so the user can retry. 400/409/422/500 already
|
||||
// raised the global error dialog; 404 (recipient gone) and 429 (rate-limited) are "caller-
|
||||
// handled" in api.req (no modal), so surface those here or the send fails silently.
|
||||
//
|
||||
// Through the shared helper, which knows both of those rules: it no-ops on anything already
|
||||
// reported, and it prefers the server's reason only when that reason is a STRUCTURED detail.
|
||||
// The hand-written copy that used to live here did `e.detail || …`, so a Hungarian reader got
|
||||
// the route's English sentence — "That member hasn't set up messaging yet." — which is
|
||||
// exactly what round 5 changed `notifyActionError` to stop doing. A second copy of a contract
|
||||
// is how the fix misses a caller (round 6).
|
||||
notifyActionError(e, t("messages.sendFailed"));
|
||||
},
|
||||
});
|
||||
|
||||
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
|
||||
ref={fadeRef}
|
||||
style={fadeStyle}
|
||||
className="flex-1 overflow-y-auto no-scrollbar p-3 flex flex-col gap-2"
|
||||
>
|
||||
{q.isError && !q.data ? (
|
||||
<div className="m-auto">
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
</div>
|
||||
) : q.isPending ? (
|
||||
<div className="m-auto">
|
||||
<LoadingState />
|
||||
</div>
|
||||
) : 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 wrap-break-word ${
|
||||
mine ? "bg-accent text-accent-fg rounded-br-sm" : "bg-card rounded-bl-sm"
|
||||
}`}
|
||||
>
|
||||
{text}
|
||||
<RelativeTime
|
||||
at={m.created_at}
|
||||
className={`block text-3xs mt-1 ${mine ? "text-accent-fg/70" : "text-muted"}`}
|
||||
/>
|
||||
</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
|
||||
className="flex-1 resize-none max-h-28"
|
||||
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")}
|
||||
/>
|
||||
<IconButton
|
||||
label={t("messages.send")}
|
||||
size="md"
|
||||
variant="primary"
|
||||
onClick={submit}
|
||||
disabled={!draft.trim() || send.isPending}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Lock, ShieldCheck } from "lucide-react";
|
||||
import { api, HttpError } from "../lib/api";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// 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. In unlock mode it also
|
||||
// offers a forgotten-passphrase reset (the only escape from the set-once wall).
|
||||
export default function KeyGate({ meId, compact = false }: { meId: number; compact?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const keyQ = useQuery({
|
||||
queryKey: qk.messageKey(),
|
||||
queryFn: api.messageMyKey,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const configured = keyQ.data?.configured ?? false;
|
||||
// Password accounts must re-verify to reset (guards a hijacked session); Google-only accounts
|
||||
// have no password to check. Passive observer of the qk.me() query BootGate fetches (enabled:false),
|
||||
// so hasPassword follows the cache when a refetch changes it.
|
||||
const meQ = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
||||
const hasPassword = meQ.data?.has_password ?? false;
|
||||
|
||||
const [view, setView] = useState<"gate" | "reset">("gate");
|
||||
const [pass, setPass] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [curPass, setCurPass] = useState("");
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
// Keys the error: failing the same way twice is a new node, so a screen reader announces it again (a clear
|
||||
// and a set in one handler commit together, leaving the node untouched).
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit() {
|
||||
setAttempt((n) => n + 1);
|
||||
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: qk.messageKey() });
|
||||
} catch {
|
||||
setErr(configured ? t("messages.wrongPass") : t("messages.setupFailed"));
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
function toReset() {
|
||||
setErr(null);
|
||||
setPass("");
|
||||
setConfirm("");
|
||||
setView("reset");
|
||||
}
|
||||
function cancelReset() {
|
||||
setErr(null);
|
||||
setCurPass("");
|
||||
setView("gate");
|
||||
}
|
||||
|
||||
async function doReset() {
|
||||
setAttempt((n) => n + 1);
|
||||
setErr(null);
|
||||
if (hasPassword && !curPass) return setErr(t("messages.resetNeedPass"));
|
||||
setBusy(true);
|
||||
const confirmPass = hasPassword ? curPass : undefined;
|
||||
try {
|
||||
await api.resetMessageKey(confirmPass);
|
||||
// Server key + dead history are gone; wipe this device's identity too so the gate re-appears
|
||||
// in setup mode. Invalidate last so the refetch sees configured:false.
|
||||
await e2ee.resetLocal(meId);
|
||||
setCurPass("");
|
||||
setView("gate");
|
||||
qc.invalidateQueries({ queryKey: qk.messageKey() });
|
||||
} catch (e) {
|
||||
setErr(e instanceof HttpError && e.detail ? e.detail : t("messages.resetFailed"));
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
if (keyQ.isLoading)
|
||||
return <div className="p-6 text-center text-muted text-sm">{t("common.loading")}</div>;
|
||||
|
||||
if (view === "reset")
|
||||
return (
|
||||
<div className={`${SURFACE.panel} space-y-3 ${compact ? "p-3" : "p-4"}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-danger shrink-0" />
|
||||
<div className="font-semibold text-sm">{t("messages.resetTitle")}</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted">{t("messages.resetWarn")}</p>
|
||||
{hasPassword && (
|
||||
<Input
|
||||
className="w-full"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={curPass}
|
||||
onChange={(e) => setCurPass(e.target.value)}
|
||||
placeholder={t("messages.resetCurrentPass")}
|
||||
/>
|
||||
)}
|
||||
{err && (
|
||||
<div key={attempt} role="alert" className="text-sm text-danger">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={cancelReset} disabled={busy}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={doReset}
|
||||
disabled={busy}
|
||||
icon={<AlertTriangle className="w-4 h-4" />}
|
||||
>
|
||||
{t("messages.resetButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`${SURFACE.panel} 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
|
||||
className="w-full"
|
||||
type="password"
|
||||
value={pass}
|
||||
onChange={(e) => setPass(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && configured && submit()}
|
||||
placeholder={t("messages.passphrase")}
|
||||
/>
|
||||
{!configured && (
|
||||
<Input
|
||||
className="w-full"
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder={t("messages.passphraseConfirm")}
|
||||
/>
|
||||
)}
|
||||
{err && (
|
||||
<div key={attempt} role="alert" className="text-sm text-danger">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
{/* One row with a gap: as two bare inline buttons the link sat flush against the primary. */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="primary" size="md" onClick={submit} disabled={busy || !pass}>
|
||||
<Lock className="w-4 h-4" />
|
||||
{configured ? t("messages.unlock") : t("messages.setUp")}
|
||||
</Button>
|
||||
{configured && (
|
||||
<Button variant="link-muted" onClick={toReset} className="text-xs">
|
||||
{t("messages.forgotPass")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!configured && !compact && <p className="text-xs text-muted">{t("messages.setupWarn")}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The conversation list as a keyboard list (E11 S5): `j`/`k` walk the conversations and Enter opens
|
||||
// the one the row's name button opens — not the pop-out button beside it. The live query, the key
|
||||
// state and the thread are stubbed: the list itself is what is under test.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||
};
|
||||
});
|
||||
|
||||
const conversation = (id: number, name: string) => ({
|
||||
partner: { id, name, avatar_url: null },
|
||||
last_message: { kind: "system", body: `hello from ${name}`, created_at: "2026-09-13T00:00:00Z" },
|
||||
unread: 0,
|
||||
});
|
||||
const items = [conversation(0, "Siftlode"), conversation(7, "Anna"), conversation(9, "Bence")];
|
||||
|
||||
vi.mock("../lib/useLiveQuery", () => ({
|
||||
useLiveQuery: () => ({ data: { items }, isError: false, isLoading: false, refetch: () => {} }),
|
||||
}));
|
||||
vi.mock("../lib/messaging", () => ({
|
||||
SYSTEM_ID: 0,
|
||||
POLL_MS: 20000,
|
||||
partnerPub: async () => "",
|
||||
useKeyState: () => "ready",
|
||||
}));
|
||||
const openChat = vi.fn();
|
||||
vi.mock("../lib/chatDock", () => ({ openChat: (...a: unknown[]) => openChat(...a) }));
|
||||
vi.mock("./ChatThread", () => ({
|
||||
default: ({ partnerId }: { partnerId: number }) => <div data-testid="thread">{partnerId}</div>,
|
||||
}));
|
||||
|
||||
import Messages from "./Messages";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
history.replaceState(null, "");
|
||||
});
|
||||
|
||||
function press(key: string) {
|
||||
act(() => {
|
||||
(document.activeElement ?? document.body).dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe("Messages · the conversation list is a keyboard list", () => {
|
||||
it("walks the conversations with j/k and opens the focused one on Enter", () => {
|
||||
render(<Messages meId={1} />);
|
||||
const focusedRow = () => document.activeElement?.getAttribute("data-nav-index");
|
||||
press("j");
|
||||
press("j");
|
||||
press("j");
|
||||
expect(focusedRow()).toBe("2");
|
||||
expect(document.activeElement?.textContent).toContain("Bence");
|
||||
press("k");
|
||||
expect(focusedRow()).toBe("1");
|
||||
expect(document.activeElement?.textContent).toContain("Anna");
|
||||
press("Enter");
|
||||
expect(screen.getByTestId("thread").textContent).toBe("7");
|
||||
expect(openChat).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,338 +0,0 @@
|
||||
import { useEffect, useEffectEvent, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { partnerPub, POLL_MS, SYSTEM_ID, useKeyState } from "../lib/messaging";
|
||||
import { useHistorySubview } from "../lib/history";
|
||||
import { openChat } from "../lib/chatDock";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import KeyGate from "./KeyGate";
|
||||
import ChatThread from "./ChatThread";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import { SectionLabel } from "./ui/Headings";
|
||||
import { RelativeTime } from "./ui/RelativeTime";
|
||||
|
||||
type View =
|
||||
| "list"
|
||||
| "new"
|
||||
| { partnerId: number; name?: string; avatar?: string | null; system?: boolean };
|
||||
|
||||
export default function Messages({ meId }: { meId: number }) {
|
||||
// Sub-views live in browser history, so Back returns to the conversation list before leaving
|
||||
// the module (open() pushes an entry; back() = history.back()).
|
||||
const { view, open, back } = useHistorySubview<View>("list");
|
||||
|
||||
// Guard against a stale sub-view whose partnerId is the current user's own id (you can't
|
||||
// message yourself). This can be inherited across an account switch — fall back to the list.
|
||||
if (typeof view === "object" && view.partnerId !== meId) {
|
||||
return (
|
||||
<PageThread
|
||||
meId={meId}
|
||||
partnerId={view.partnerId}
|
||||
isSystem={!!view.system}
|
||||
seedName={view.name}
|
||||
seedAvatar={view.avatar}
|
||||
onBack={back}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (view === "new") {
|
||||
return (
|
||||
<Directory
|
||||
onPick={(u) => open({ partnerId: u.id, name: u.name, avatar: u.avatar_url })}
|
||||
onBack={back}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ConversationList
|
||||
meId={meId}
|
||||
onOpen={(c) =>
|
||||
open({
|
||||
partnerId: c.partner.id,
|
||||
name: c.partner.name,
|
||||
avatar: c.partner.avatar_url,
|
||||
system: c.partner.id === SYSTEM_ID,
|
||||
})
|
||||
}
|
||||
onNew={() => open("new")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ConversationList({
|
||||
meId,
|
||||
onOpen,
|
||||
onNew,
|
||||
}: {
|
||||
meId: number;
|
||||
onOpen: (c: Conversation) => void;
|
||||
onNew: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const keyState = useKeyState();
|
||||
const ready = keyState === "ready";
|
||||
const q = useLiveQuery<{ items: Conversation[] }>(qk.conversations(), api.conversations, {
|
||||
intervalMs: POLL_MS,
|
||||
});
|
||||
const items = q.data?.items ?? [];
|
||||
const [previews, setPreviews] = useState<Record<number, string>>({});
|
||||
|
||||
// Decrypt conversation previews when fresh data arrives. The TRIGGER is `dataUpdatedAt` and the
|
||||
// payload is read through an effect event, which is what makes the dep list below complete without
|
||||
// making the identity of `q.data` a trigger. That distinction is load-bearing: the `?? []` fallback is a
|
||||
// new array every render, and anything handing back a fresh object per render (a stub, a caller
|
||||
// that rebuilds it) turns "re-run when the data changes" into a render loop through setPreviews.
|
||||
// Measured the hard way — E27 S2 keyed this on `q.data`, and Messages.keyboard.dom.test hung.
|
||||
const latestConversations = useEffectEvent(() => q.data);
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
let cancelled = false;
|
||||
const conversations = latestConversations();
|
||||
// Each decrypt is caught (a failure previews as "⚠"), so this never rejects.
|
||||
void (async () => {
|
||||
const out: Record<number, string> = {};
|
||||
for (const c of conversations?.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;
|
||||
};
|
||||
}, [q.dataUpdatedAt, ready]);
|
||||
|
||||
function previewText(c: Conversation): string {
|
||||
const m = c.last_message;
|
||||
if (m.kind === "system") return m.body ?? "";
|
||||
if (!ready) return "🔒 " + t("messages.encrypted");
|
||||
return previews[c.partner.id] ?? "…";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<SectionLabel>{t("messages.conversations")}</SectionLabel>
|
||||
{ready && (
|
||||
<Button variant="primary" size="xs" onClick={onNew}>
|
||||
<SquarePen className="w-4 h-4" />
|
||||
{t("messages.new")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{(keyState === "setup" || keyState === "unlock") && <KeyGate meId={meId} />}
|
||||
{q.isError && !q.data ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading && !q.data ? (
|
||||
<LoadingState shape="list" />
|
||||
) : items.length === 0 ? (
|
||||
<div className={`${SURFACE.panel} p-10 text-center text-muted`}>{t("messages.empty")}</div>
|
||||
) : (
|
||||
// A keyboard list (E11): `j`/`k` walk the conversations, Enter opens the one the name button
|
||||
// opens; the pop-out button beside it keeps its own Enter.
|
||||
<NavGrid className="flex flex-col gap-1.5">
|
||||
{items.map((c, i) => {
|
||||
const isSystem = c.partner.id === SYSTEM_ID;
|
||||
return (
|
||||
<div
|
||||
key={c.partner.id}
|
||||
{...navItemAttrs(i, c.partner.name)}
|
||||
className="group flex items-center gap-1 rounded-xl state-layer transition"
|
||||
>
|
||||
<Pressable
|
||||
selectable
|
||||
data-nav-open=""
|
||||
onClick={() => onOpen(c)}
|
||||
className="flex items-center gap-3 p-2.5 min-w-0 flex-1"
|
||||
>
|
||||
<Avatar
|
||||
src={c.partner.avatar_url}
|
||||
fallback={c.partner.name}
|
||||
className="w-10 h-10 rounded-full shrink-0 text-sm"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`truncate ${c.unread > 0 ? "font-semibold" : "font-medium"}`}
|
||||
>
|
||||
{c.partner.name}
|
||||
</span>
|
||||
<RelativeTime
|
||||
at={c.last_message.created_at}
|
||||
className="ml-auto text-2xs text-muted shrink-0"
|
||||
/>
|
||||
</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-2xs font-semibold">
|
||||
{c.unread}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Pressable>
|
||||
{!isSystem && ready && (
|
||||
<IconButton
|
||||
size="md"
|
||||
onClick={() => openChat(c.partner)}
|
||||
label={t("messages.popOut")}
|
||||
className="mr-1.5 reveal"
|
||||
>
|
||||
<ExternalLink />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</NavGrid>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBack: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [filter, setFilter] = useState("");
|
||||
const q = useQuery({ queryKey: qk.messageUsers(), 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">
|
||||
<IconButton
|
||||
label={t("channel.back")}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
// An icon-only button needs a name: both of these announced themselves as a bare "button",
|
||||
// and they are the only way out of the sub-view (E10 review round 1).
|
||||
onClick={onBack}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</IconButton>
|
||||
<SectionLabel>{t("messages.newTitle")}</SectionLabel>
|
||||
</div>
|
||||
<Input
|
||||
className="w-full"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder={t("messages.searchPeople")}
|
||||
/>
|
||||
{q.isError && !q.data ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading ? (
|
||||
<LoadingState shape="list" />
|
||||
) : users.length === 0 ? (
|
||||
<div className={`${SURFACE.panel} p-10 text-center text-muted`}>
|
||||
{t("messages.noPeople")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{users.map((u) => (
|
||||
<Pressable
|
||||
selectable
|
||||
key={u.id}
|
||||
layer
|
||||
onClick={() => onPick(u)}
|
||||
className="flex items-center gap-3 p-2.5 rounded-xl"
|
||||
>
|
||||
<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>
|
||||
</Pressable>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageThread({
|
||||
meId,
|
||||
partnerId,
|
||||
isSystem,
|
||||
seedName,
|
||||
seedAvatar,
|
||||
onBack,
|
||||
}: {
|
||||
meId: number;
|
||||
partnerId: number;
|
||||
isSystem: boolean;
|
||||
seedName?: string;
|
||||
seedAvatar?: string | null;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const ready = useKeyState() === "ready";
|
||||
const name = seedName ?? "";
|
||||
return (
|
||||
<div className="flex flex-col h-[65dvh] min-h-80">
|
||||
<div className="flex items-center gap-3 pb-3 border-b border-border">
|
||||
<IconButton
|
||||
label={t("channel.back")}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
// An icon-only button needs a name: both of these announced themselves as a bare "button",
|
||||
// and they are the only way out of the sub-view (E10 review round 1).
|
||||
onClick={onBack}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</IconButton>
|
||||
<Avatar
|
||||
src={seedAvatar}
|
||||
fallback={name}
|
||||
className="w-9 h-9 rounded-full shrink-0 text-sm"
|
||||
/>
|
||||
<span className="font-semibold truncate">{name}</span>
|
||||
{!isSystem && (
|
||||
<ShieldCheck
|
||||
className="w-4 h-4 text-muted shrink-0"
|
||||
aria-label={t("messages.encryptedHint")}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{!isSystem && ready && (
|
||||
<IconButton
|
||||
size="md"
|
||||
onClick={() => openChat({ id: partnerId, name, avatar_url: seedAvatar ?? null })}
|
||||
label={t("messages.popOut")}
|
||||
>
|
||||
<ExternalLink />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
<ChatThread meId={meId} partnerId={partnerId} isSystem={isSystem} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { Info, LogOut, Settings, Shield, UserPlus, type LucideIcon } from "lucid
|
||||
import { api, type Me } from "../lib/api";
|
||||
import { displayName, roleKey } from "../lib/api/account";
|
||||
import { pinOnNextPage } from "../lib/activeAccount";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { getUnreadCount, subscribe } from "../lib/notifications";
|
||||
import type { Page } from "../lib/urlState";
|
||||
@@ -84,14 +83,6 @@ export default function NavSidebar({
|
||||
} catch {
|
||||
/* clear locally and reload regardless */
|
||||
}
|
||||
// Forget this device's E2EE private key on sign-out: it's otherwise persisted in IndexedDB
|
||||
// and auto-unlocks on the next visit, so on a shared machine the conversations would stay
|
||||
// decryptable after logout. Re-entering the message passphrase is required next time.
|
||||
try {
|
||||
await e2ee.clearDevice(me.id);
|
||||
} catch {
|
||||
/* IndexedDB unavailable (e.g. private mode) — never let it block the logout + reload */
|
||||
}
|
||||
pinOnNextPage(null);
|
||||
location.reload();
|
||||
}
|
||||
@@ -112,8 +103,7 @@ export default function NavSidebar({
|
||||
pinOnNextPage(id);
|
||||
// Drop the previous account's in-module sub-view/overlay before the reload (which would
|
||||
// otherwise preserve history.state across the swap). Otherwise the new account lands on a
|
||||
// stale sub-view — e.g. an open chat thread whose partnerId belongs to the OLD identity
|
||||
// (often the new account's own id → an empty self-thread). Start at the module root instead.
|
||||
// stale sub-view that belongs to the OLD identity. Start at the module root instead.
|
||||
const st = { ...(window.history.state || {}) };
|
||||
delete st._sub;
|
||||
delete st._ov;
|
||||
@@ -130,12 +120,6 @@ export default function NavSidebar({
|
||||
// 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;
|
||||
// Direct messages are their own module with their own unread badge (demo can't message).
|
||||
const msgUnreadQuery = useLiveQuery(qk.messageUnread(), api.messageUnreadCount, {
|
||||
intervalMs: 30000,
|
||||
enabled: !me.is_demo,
|
||||
});
|
||||
const msgUnread = msgUnreadQuery.data?.count ?? 0;
|
||||
// Download center badge = items still working (queued/running/paused). Reuses the same
|
||||
// lightweight per-user index the feed cards poll, so no extra request shape.
|
||||
const dlIndexQuery = useLiveQuery(qk.downloadIndex(), api.downloadIndex, {
|
||||
@@ -155,7 +139,6 @@ export default function NavSidebar({
|
||||
// badge NUMBERS are the only nav-only concern (they need the live polling queries above).
|
||||
const BADGE: Partial<Record<Page, number>> = {
|
||||
notifications: unread,
|
||||
messages: msgUnread,
|
||||
downloads: dlActive,
|
||||
};
|
||||
const META = (p: Page): Omit<NavItem, "page"> => ({
|
||||
|
||||
@@ -1348,7 +1348,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
const currentAudio = detail?.audio_streams.find((x) => x.ord === (audioOrd ?? 0));
|
||||
const currentSub = detail?.subtitle_streams.find((x) => x.ord === subOrd);
|
||||
|
||||
// Portaled to <body>, like every other full-screen overlay here (Modal, PlayerModal, ChatDock,
|
||||
// Portaled to <body>, like every other full-screen overlay here (Modal, PlayerModal,
|
||||
// BackToTop). A fixed, full-viewport player must not be painted inside the page scroller: the
|
||||
// moment that scroller gets a mask/filter/transform/contain it becomes a stacking context, and
|
||||
// this z-overlay is then scoped inside it — the rail, the side panel and the header paint straight
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
// Every pick gets a new number, so the refusal below REMOUNTS rather than being reconciled. Two
|
||||
// over-cap picks in a row leave the node and its text identical — the message names the cap, not
|
||||
// the file — and a live region that is not touched is not announced, so the second refusal was
|
||||
// silent to a screen reader. `KeyGate`, `SetupWizard` and `Welcome` all key their alerts for the
|
||||
// silent to a screen reader. `SetupWizard` and `Welcome` both key their alerts for the
|
||||
// same reason, and `formAlerts.dom.test.tsx` is the test that exists because of it.
|
||||
const [pickSeq, setPickSeq] = useState(0);
|
||||
const pick_ = (f: File) => {
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// clear and the set of a validation failure commit together, so the alert node used to stay untouched on
|
||||
// the second attempt and a screen reader said nothing — a press that seemed to do nothing.
|
||||
//
|
||||
// i18n is mocked to a key-passthrough; the API and the E2EE module are stubbed so nothing reaches out.
|
||||
// i18n is mocked to a key-passthrough; the API is stubbed so nothing reaches out.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
@@ -24,16 +23,12 @@ vi.mock("../lib/api", async (importOriginal) => {
|
||||
api: {
|
||||
...actual.api,
|
||||
authConfig: () => Promise.resolve({ google_enabled: false }),
|
||||
messageMyKey: () => Promise.resolve({ configured: true }),
|
||||
me: () => Promise.resolve({ authenticated: false }),
|
||||
setupInfo: () => Promise.resolve({ secrets_manageable: false }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../lib/e2ee", () => ({ unlock: vi.fn(), setup: vi.fn(), clearDevice: vi.fn() }));
|
||||
|
||||
import KeyGate from "./KeyGate";
|
||||
import SetupWizard from "./SetupWizard";
|
||||
import Welcome from "./Welcome";
|
||||
|
||||
@@ -54,19 +49,6 @@ async function failTwice(fail: () => void, message: string) {
|
||||
}
|
||||
|
||||
describe("a form failing the same way twice announces it twice", () => {
|
||||
it("the message passphrase gate", async () => {
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<KeyGate meId={1} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const unlock = await screen.findByText("messages.unlock");
|
||||
fireEvent.change(screen.getByPlaceholderText("messages.passphrase"), {
|
||||
target: { value: "abc" },
|
||||
});
|
||||
await failTwice(() => fireEvent.click(unlock), "messages.passTooShort");
|
||||
});
|
||||
|
||||
it("the sign-in card", async () => {
|
||||
render(
|
||||
<Welcome verify={{ outcome: null, email: null, resetPending: false, dismiss: () => {} }} />,
|
||||
|
||||
@@ -16,7 +16,7 @@ import LanguageSwitcher from "../LanguageSwitcher";
|
||||
import { SURFACE } from "../ui/surfaces";
|
||||
|
||||
// The day the text last changed materially — bump it with every edit of either body.
|
||||
const LAST_UPDATED = "2026-09-17";
|
||||
const LAST_UPDATED = "2026-10-04";
|
||||
|
||||
// Contact shown on the legal pages: the instance operator's configured admin email, from the
|
||||
// public /auth/config. One shared fetch across the page; null when the operator set none.
|
||||
|
||||
@@ -87,10 +87,7 @@ export function PrivacyEn() {
|
||||
that log is sent to the server and stored with your account alongside the server's more
|
||||
detailed record of your requests, for the same retention period as the server logs;
|
||||
</li>
|
||||
<li>
|
||||
passwords, tokens, email addresses and the content of your messages are never included, at
|
||||
any level.
|
||||
</li>
|
||||
<li>passwords, tokens and email addresses are never included, at any level.</li>
|
||||
</ul>
|
||||
<p>
|
||||
After 24 hours the level returns to the default by itself, and you can return to it earlier
|
||||
@@ -112,10 +109,9 @@ export function PrivacyEn() {
|
||||
<H2 id="storage">Storage & security</H2>
|
||||
<p>
|
||||
Data is stored in a private PostgreSQL database on a server we control, accessible only to
|
||||
the operator. Google OAuth refresh tokens are encrypted at rest. Private messages between
|
||||
users are end-to-end encrypted in the browser: the server stores only the encrypted text and
|
||||
cannot read it. Access is restricted to the email addresses admitted to the app. No system
|
||||
is perfectly secure, but we take reasonable measures to protect your data.
|
||||
the operator. Google OAuth refresh tokens are encrypted at rest. Access is restricted to the
|
||||
email addresses admitted to the app. No system is perfectly secure, but we take reasonable
|
||||
measures to protect your data.
|
||||
</p>
|
||||
|
||||
<H2 id="retention">Retention & deletion</H2>
|
||||
@@ -230,7 +226,7 @@ export function PrivacyHu() {
|
||||
ezt a naplót elküldi a szervernek, amely a fiókodhoz tárolja, a kéréseid részletesebb
|
||||
szerveroldali naplójával együtt, a szervernapló megőrzési idejéig;
|
||||
</li>
|
||||
<li>jelszó, token, e-mail-cím és az üzeneteid tartalma egyik szinten sem kerül bele.</li>
|
||||
<li>jelszó, token és e-mail-cím egyik szinten sem kerül bele.</li>
|
||||
</ul>
|
||||
<p>
|
||||
24 óra múlva a szint magától visszaáll az alapra, és bármikor korábban is visszaállíthatod.
|
||||
@@ -252,11 +248,9 @@ export function PrivacyHu() {
|
||||
<H2 id="storage">Tárolás és biztonság</H2>
|
||||
<p>
|
||||
Az adatokat egy általunk kezelt szerveren, privát PostgreSQL-adatbázisban tároljuk, amelyhez
|
||||
csak az üzemeltető fér hozzá. A Google OAuth frissítő tokeneket titkosítva tároljuk. A
|
||||
felhasználók közötti privát üzenetek végponttól végpontig titkosítottak a böngészőben: a
|
||||
szerver csak a titkosított szöveget tárolja, és nem tudja elolvasni. Csak az alkalmazásba
|
||||
beengedett e-mail-címek férnek hozzá. Egyetlen rendszer sem tökéletesen biztonságos, de
|
||||
észszerű intézkedésekkel védjük az adataidat.
|
||||
csak az üzemeltető fér hozzá. A Google OAuth frissítő tokeneket titkosítva tároljuk. Csak az
|
||||
alkalmazásba beengedett e-mail-címek férnek hozzá. Egyetlen rendszer sem tökéletesen
|
||||
biztonságos, de észszerű intézkedésekkel védjük az adataidat.
|
||||
</p>
|
||||
|
||||
<H2 id="retention">Megőrzés és törlés</H2>
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useSavedViews } from "./savedViewsContext";
|
||||
// that file exports only data and this one only components (Fast Refresh). Their lazy chunks are
|
||||
// exported alongside so the registry can attach each chunk's preload to the wrapper that renders it.
|
||||
export const Feed = lazyWithPreload(() => import("./Feed"));
|
||||
export const Messages = lazyWithPreload(() => import("./Messages"));
|
||||
|
||||
// The GLOBAL feed page: wires <Feed> to the app-wide feed filters / view / live-search. (The channel
|
||||
// page renders <Feed> itself with its own channel-scoped filters, so those two never share state.)
|
||||
@@ -43,12 +42,3 @@ export function FeedPage() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessagesPage() {
|
||||
const me = useMe();
|
||||
return (
|
||||
<div className="p-4 max-w-3xl w-full mx-auto">
|
||||
<Messages meId={me.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Page } from "../lib/urlState";
|
||||
import type { PanelId, PanelLayout } from "../lib/panelLayout";
|
||||
import Sidebar from "./Sidebar";
|
||||
import PlaylistsRail from "./PlaylistsRail";
|
||||
import { Feed, FeedPage, Messages, MessagesPage } from "./modulePages";
|
||||
import { Feed, FeedPage } from "./modulePages";
|
||||
|
||||
// The RENDER half of the module registry: which component (and side rail) each page renders. The
|
||||
// metadata half — nav order, label, icon, gate — lives in lib/modules.ts (imported by NavSidebar /
|
||||
@@ -54,7 +54,6 @@ export const PAGE_CONTENT: Record<Page, PageDef> = {
|
||||
playlists: { Component: Playlists, maxWidth: "64rem" },
|
||||
plex: { Component: PlexBrowse, maxWidth: "100rem" },
|
||||
notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
|
||||
messages: { Component: Object.assign(MessagesPage, { preload: Messages.preload }) },
|
||||
downloads: { Component: DownloadCenter, maxWidth: "56rem" },
|
||||
stats: { Component: Stats },
|
||||
scheduler: { Component: Scheduler },
|
||||
|
||||
@@ -60,8 +60,8 @@ describe("Avatar · a failed image must not follow the component to the next ite
|
||||
});
|
||||
|
||||
it("retries a URL it comes back to — a 429 is transient, and so is the memory of it", () => {
|
||||
// The instance is not remounted here on purpose: the player's channel avatar and the Messages
|
||||
// thread header both swap `src` on a live instance. A memory that outlived the reason for it
|
||||
// The instance is not remounted here on purpose: the player's channel avatar swaps `src` on a
|
||||
// live instance as it steps through a queue. A memory that outlived the reason for it
|
||||
// would pin a throttled channel to its initial for the rest of the session.
|
||||
const { rerender } = render(
|
||||
<Avatar src="https://cdn.test/a.jpg" alt="avatar" fallback="Zed" />,
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function Avatar({
|
||||
//
|
||||
// Forgetting the URL once `src` has moved off it is the other half, and it is what keeps the 429
|
||||
// TRANSIENT: without it the memory outlives the reason for it, and a mounted instance whose `src`
|
||||
// swaps — the player's channel avatar stepping through a queue, the Messages thread header — would
|
||||
// swaps — the player's channel avatar stepping through a queue — would
|
||||
// show the initial for a throttled channel forever, never re-requesting even after the CDN
|
||||
// relented. Written in render because it is convergent (the write falsifies its own condition) and
|
||||
// because an effect would leave one committed frame in which the memory is stale, which is the
|
||||
|
||||
@@ -112,7 +112,7 @@ export function NavRailItem({
|
||||
}}
|
||||
// With the label visible the row's name starts with that text (WCAG 2.5.3: the spoken name
|
||||
// contains the visible one); the full name stays in the tooltip. Icon-only, the full name is the
|
||||
// name. The exact count rides in the name either way, so "Messages (3)" is heard and not only
|
||||
// name. The exact count rides in the name either way, so "Alerts (3)" is heard and not only
|
||||
// seen; the drawn badge is hidden (labelled, it was read first and capped: "9+Alerts").
|
||||
aria-label={`${showLabel ? shortLabel : label}${shown ? ` (${badge})` : ""}`}
|
||||
aria-current={active ? "page" : undefined}
|
||||
|
||||
@@ -30,9 +30,8 @@ The direction is one-way from below, too: `lib/` may not import from `components
|
||||
included, not even a type (same lint file). A contract both layers need lives in `lib/`: the `Column`
|
||||
/ `FilterMap` shape and `filterRows` are `lib/tableColumns`, which `tableHeader` and `DataTable` import.
|
||||
|
||||
Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage posters), `KeyGate`
|
||||
(secure-messaging key setup), `plexCards`, `downloadRow`, `channelColumns` — all shared, none
|
||||
domain-free.
|
||||
Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage posters),
|
||||
`plexCards`, `downloadRow`, `channelColumns` — all shared, none domain-free.
|
||||
|
||||
## What lives here
|
||||
|
||||
|
||||
Reference in New Issue
Block a user