fix(messages): gate on server key state, not just local unlock

The Messages UI trusted only the per-device IndexedDB key, so if the server's
key record was gone (deleted, DB-restored, admin-reset) while a stale private
key lingered in the browser, the app looked 'unlocked' but no one could be
messaged and no setup was offered. Add useKeyState (server 'configured' AND
local unlock): show setup when the server has no key (setup overwrites the stale
local key), unlock when it has one this device hasn't opened, ready otherwise.
ChatThread is now self-contained. Also fix the header title on the Messages and
Notifications pages (was falling through to 'Channel manager').
This commit is contained in:
npeter83 2026-06-25 22:58:29 +02:00
parent 8392037e5f
commit 963afa33a6
5 changed files with 42 additions and 29 deletions

View file

@ -5,7 +5,7 @@ import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react";
import { api, type Conversation, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { relativeTime } from "../lib/format";
import { partnerPub, SYSTEM_ID, useUnlocked } from "../lib/messaging";
import { partnerPub, SYSTEM_ID, useKeyState } from "../lib/messaging";
import { openChat } from "../lib/chatDock";
import * as e2ee from "../lib/e2ee";
import Avatar from "./Avatar";
@ -18,7 +18,6 @@ type View = "list" | "new" | { partnerId: number; name?: string; avatar?: string
export default function Messages({ meId }: { meId: number }) {
const [view, setView] = useState<View>("list");
const unlocked = useUnlocked();
if (typeof view === "object") {
return (
@ -28,7 +27,6 @@ export default function Messages({ meId }: { meId: number }) {
isSystem={!!view.system}
seedName={view.name}
seedAvatar={view.avatar}
unlocked={unlocked}
onBack={() => setView("list")}
/>
);
@ -39,7 +37,6 @@ export default function Messages({ meId }: { meId: number }) {
return (
<ConversationList
meId={meId}
unlocked={unlocked}
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")}
/>
@ -48,22 +45,22 @@ export default function Messages({ meId }: { meId: number }) {
function ConversationList({
meId,
unlocked,
onOpen,
onNew,
}: {
meId: number;
unlocked: boolean;
onOpen: (c: Conversation) => void;
onNew: () => void;
}) {
const { t } = useTranslation();
const keyState = useKeyState();
const ready = keyState === "ready";
const q = useLiveQuery<{ items: Conversation[] }>(["conversations"], api.conversations, { intervalMs: POLL_MS });
const items = q.data?.items ?? [];
const [previews, setPreviews] = useState<Record<number, string>>({});
useEffect(() => {
if (!unlocked) return;
if (!ready) return;
let cancelled = false;
(async () => {
const out: Record<number, string> = {};
@ -83,12 +80,12 @@ function ConversationList({
return () => {
cancelled = true;
};
}, [items, unlocked]);
}, [items, ready]);
function previewText(c: Conversation): string {
const m = c.last_message;
if (m.kind === "system") return m.body ?? "";
if (!unlocked) return "🔒 " + t("messages.encrypted");
if (!ready) return "🔒 " + t("messages.encrypted");
return previews[c.partner.id] ?? "…";
}
@ -96,7 +93,7 @@ function ConversationList({
<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 && (
{ready && (
<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"
@ -106,7 +103,7 @@ function ConversationList({
</button>
)}
</div>
{!unlocked && <KeyGate meId={meId} />}
{(keyState === "setup" || keyState === "unlock") && <KeyGate meId={meId} />}
{q.isLoading && !q.data ? (
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
) : items.length === 0 ? (
@ -134,7 +131,7 @@ function ConversationList({
</div>
</div>
</button>
{!isSystem && unlocked && (
{!isSystem && ready && (
<button
onClick={() => openChat(c.partner)}
title={t("messages.popOut")}
@ -200,7 +197,6 @@ function PageThread({
isSystem,
seedName,
seedAvatar,
unlocked,
onBack,
}: {
meId: number;
@ -208,10 +204,10 @@ function PageThread({
isSystem: boolean;
seedName?: string;
seedAvatar?: string | null;
unlocked: boolean;
onBack: () => void;
}) {
const { t } = useTranslation();
const ready = useKeyState() === "ready";
const name = seedName ?? "";
return (
<div className="flex flex-col h-[65vh] min-h-80">
@ -223,7 +219,7 @@ function PageThread({
<span className="font-semibold truncate">{name}</span>
{!isSystem && <ShieldCheck className="w-4 h-4 text-muted shrink-0" aria-label={t("messages.encryptedHint")} />}
<div className="flex-1" />
{!isSystem && unlocked && (
{!isSystem && ready && (
<button
onClick={() => openChat({ id: partnerId, name, avatar_url: seedAvatar ?? null })}
title={t("messages.popOut")}
@ -234,7 +230,7 @@ function PageThread({
</button>
)}
</div>
<ChatThread meId={meId} partnerId={partnerId} isSystem={isSystem} unlocked={unlocked} />
<ChatThread meId={meId} partnerId={partnerId} isSystem={isSystem} />
</div>
);
}