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:
parent
8392037e5f
commit
963afa33a6
5 changed files with 42 additions and 29 deletions
|
|
@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { ChevronDown, ChevronUp, X } from "lucide-react";
|
import { ChevronDown, ChevronUp, X } from "lucide-react";
|
||||||
import { closeChat, initDock, notifyIncoming, toggleMinimize, useDockChats, type DockChat } from "../lib/chatDock";
|
import { closeChat, initDock, notifyIncoming, toggleMinimize, useDockChats, type DockChat } from "../lib/chatDock";
|
||||||
import { useUnlocked } from "../lib/messaging";
|
|
||||||
import { onMessage } from "../lib/messagesSocket";
|
import { onMessage } from "../lib/messagesSocket";
|
||||||
import * as e2ee from "../lib/e2ee";
|
import * as e2ee from "../lib/e2ee";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
|
|
@ -15,7 +14,6 @@ import ChatThread from "./ChatThread";
|
||||||
export default function ChatDock({ meId }: { meId: number }) {
|
export default function ChatDock({ meId }: { meId: number }) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const chats = useDockChats();
|
const chats = useDockChats();
|
||||||
const unlocked = useUnlocked();
|
|
||||||
|
|
||||||
// Restore the per-device unlocked key and the persisted dock windows once, app-wide.
|
// Restore the per-device unlocked key and the persisted dock windows once, app-wide.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -45,13 +43,13 @@ export default function ChatDock({ meId }: { meId: number }) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 right-0 z-40 flex flex-row-reverse items-end gap-3 p-4 pointer-events-none">
|
<div className="fixed bottom-0 right-0 z-40 flex flex-row-reverse items-end gap-3 p-4 pointer-events-none">
|
||||||
{chats.map((c) => (
|
{chats.map((c) => (
|
||||||
<ChatWindow key={c.partnerId} chat={c} meId={meId} unlocked={unlocked} />
|
<ChatWindow key={c.partnerId} chat={c} meId={meId} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChatWindow({ chat, meId, unlocked }: { chat: DockChat; meId: number; unlocked: boolean }) {
|
function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [flashing, setFlashing] = useState(false);
|
const [flashing, setFlashing] = useState(false);
|
||||||
|
|
||||||
|
|
@ -95,7 +93,7 @@ function ChatWindow({ chat, meId, unlocked }: { chat: DockChat; meId: number; un
|
||||||
</div>
|
</div>
|
||||||
{!chat.minimized && (
|
{!chat.minimized && (
|
||||||
<div className="flex flex-col h-96">
|
<div className="flex flex-col h-96">
|
||||||
<ChatThread meId={meId} partnerId={chat.partnerId} isSystem={false} unlocked={unlocked} />
|
<ChatThread meId={meId} partnerId={chat.partnerId} isSystem={false} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { Send } from "lucide-react";
|
||||||
import { api, type Message, type MessageUser } from "../lib/api";
|
import { api, type Message, type MessageUser } from "../lib/api";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
import { relativeTime } from "../lib/format";
|
import { relativeTime } from "../lib/format";
|
||||||
import { partnerPub } from "../lib/messaging";
|
import { partnerPub, useKeyState } from "../lib/messaging";
|
||||||
import * as e2ee from "../lib/e2ee";
|
import * as e2ee from "../lib/e2ee";
|
||||||
import KeyGate from "./KeyGate";
|
import KeyGate from "./KeyGate";
|
||||||
|
|
||||||
|
|
@ -13,24 +13,24 @@ const POLL_MS = 20000; // safety net; live updates arrive over the WebSocket
|
||||||
|
|
||||||
// The message list + composer for one conversation, shared by the full Messages page and the
|
// 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
|
// floating dock window. Handles client-side decryption and encrypt-on-send; the surrounding
|
||||||
// chrome (back button / minimize / close) is the caller's.
|
// 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({
|
export default function ChatThread({
|
||||||
meId,
|
meId,
|
||||||
partnerId,
|
partnerId,
|
||||||
isSystem,
|
isSystem,
|
||||||
unlocked,
|
|
||||||
}: {
|
}: {
|
||||||
meId: number;
|
meId: number;
|
||||||
partnerId: number;
|
partnerId: number;
|
||||||
isSystem: boolean;
|
isSystem: boolean;
|
||||||
unlocked: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [plain, setPlain] = useState<Record<number, string>>({});
|
const [plain, setPlain] = useState<Record<number, string>>({});
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const needsKey = !isSystem && !unlocked;
|
const ready = useKeyState() === "ready";
|
||||||
|
const needsKey = !isSystem && !ready;
|
||||||
|
|
||||||
const q = useLiveQuery<{ partner: MessageUser; items: Message[] }>(
|
const q = useLiveQuery<{ partner: MessageUser; items: Message[] }>(
|
||||||
["thread", partnerId],
|
["thread", partnerId],
|
||||||
|
|
@ -40,7 +40,7 @@ export default function ChatThread({
|
||||||
const items = q.data?.items ?? [];
|
const items = q.data?.items ?? [];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSystem || !unlocked) return;
|
if (isSystem || !ready) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
const out: Record<number, string> = {};
|
const out: Record<number, string> = {};
|
||||||
|
|
@ -59,7 +59,7 @@ export default function ChatThread({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [items, unlocked, isSystem, partnerId, t]);
|
}, [items, ready, isSystem, partnerId, t]);
|
||||||
|
|
||||||
// Opening / refreshing the thread marks incoming messages read, so clear the badges.
|
// Opening / refreshing the thread marks incoming messages read, so clear the badges.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,11 @@ export default function Header({
|
||||||
? t("header.configuration")
|
? t("header.configuration")
|
||||||
: page === "users"
|
: page === "users"
|
||||||
? t("header.users")
|
? t("header.users")
|
||||||
: t("header.channelManager")}
|
: page === "notifications"
|
||||||
|
? t("inbox.navLabel")
|
||||||
|
: page === "messages"
|
||||||
|
? t("messages.navLabel")
|
||||||
|
: t("header.channelManager")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react";
|
||||||
import { api, type Conversation, type MessageUser } from "../lib/api";
|
import { api, type Conversation, type MessageUser } from "../lib/api";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
import { relativeTime } from "../lib/format";
|
import { relativeTime } from "../lib/format";
|
||||||
import { partnerPub, SYSTEM_ID, useUnlocked } from "../lib/messaging";
|
import { partnerPub, SYSTEM_ID, useKeyState } from "../lib/messaging";
|
||||||
import { openChat } from "../lib/chatDock";
|
import { openChat } from "../lib/chatDock";
|
||||||
import * as e2ee from "../lib/e2ee";
|
import * as e2ee from "../lib/e2ee";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
|
|
@ -18,7 +18,6 @@ type View = "list" | "new" | { partnerId: number; name?: string; avatar?: string
|
||||||
|
|
||||||
export default function Messages({ meId }: { meId: number }) {
|
export default function Messages({ meId }: { meId: number }) {
|
||||||
const [view, setView] = useState<View>("list");
|
const [view, setView] = useState<View>("list");
|
||||||
const unlocked = useUnlocked();
|
|
||||||
|
|
||||||
if (typeof view === "object") {
|
if (typeof view === "object") {
|
||||||
return (
|
return (
|
||||||
|
|
@ -28,7 +27,6 @@ export default function Messages({ meId }: { meId: number }) {
|
||||||
isSystem={!!view.system}
|
isSystem={!!view.system}
|
||||||
seedName={view.name}
|
seedName={view.name}
|
||||||
seedAvatar={view.avatar}
|
seedAvatar={view.avatar}
|
||||||
unlocked={unlocked}
|
|
||||||
onBack={() => setView("list")}
|
onBack={() => setView("list")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -39,7 +37,6 @@ export default function Messages({ meId }: { meId: number }) {
|
||||||
return (
|
return (
|
||||||
<ConversationList
|
<ConversationList
|
||||||
meId={meId}
|
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 })}
|
onOpen={(c) => setView({ partnerId: c.partner.id, name: c.partner.name, avatar: c.partner.avatar_url, system: c.partner.id === SYSTEM_ID })}
|
||||||
onNew={() => setView("new")}
|
onNew={() => setView("new")}
|
||||||
/>
|
/>
|
||||||
|
|
@ -48,22 +45,22 @@ export default function Messages({ meId }: { meId: number }) {
|
||||||
|
|
||||||
function ConversationList({
|
function ConversationList({
|
||||||
meId,
|
meId,
|
||||||
unlocked,
|
|
||||||
onOpen,
|
onOpen,
|
||||||
onNew,
|
onNew,
|
||||||
}: {
|
}: {
|
||||||
meId: number;
|
meId: number;
|
||||||
unlocked: boolean;
|
|
||||||
onOpen: (c: Conversation) => void;
|
onOpen: (c: Conversation) => void;
|
||||||
onNew: () => void;
|
onNew: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const keyState = useKeyState();
|
||||||
|
const ready = keyState === "ready";
|
||||||
const q = useLiveQuery<{ items: Conversation[] }>(["conversations"], api.conversations, { intervalMs: POLL_MS });
|
const q = useLiveQuery<{ items: Conversation[] }>(["conversations"], api.conversations, { intervalMs: POLL_MS });
|
||||||
const items = q.data?.items ?? [];
|
const items = q.data?.items ?? [];
|
||||||
const [previews, setPreviews] = useState<Record<number, string>>({});
|
const [previews, setPreviews] = useState<Record<number, string>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!unlocked) return;
|
if (!ready) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
const out: Record<number, string> = {};
|
const out: Record<number, string> = {};
|
||||||
|
|
@ -83,12 +80,12 @@ function ConversationList({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [items, unlocked]);
|
}, [items, ready]);
|
||||||
|
|
||||||
function previewText(c: Conversation): string {
|
function previewText(c: Conversation): string {
|
||||||
const m = c.last_message;
|
const m = c.last_message;
|
||||||
if (m.kind === "system") return m.body ?? "";
|
if (m.kind === "system") return m.body ?? "";
|
||||||
if (!unlocked) return "🔒 " + t("messages.encrypted");
|
if (!ready) return "🔒 " + t("messages.encrypted");
|
||||||
return previews[c.partner.id] ?? "…";
|
return previews[c.partner.id] ?? "…";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,7 +93,7 @@ function ConversationList({
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-xs uppercase tracking-wide text-muted">{t("messages.conversations")}</div>
|
<div className="text-xs uppercase tracking-wide text-muted">{t("messages.conversations")}</div>
|
||||||
{unlocked && (
|
{ready && (
|
||||||
<button
|
<button
|
||||||
onClick={onNew}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!unlocked && <KeyGate meId={meId} />}
|
{(keyState === "setup" || keyState === "unlock") && <KeyGate meId={meId} />}
|
||||||
{q.isLoading && !q.data ? (
|
{q.isLoading && !q.data ? (
|
||||||
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
|
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
|
|
@ -134,7 +131,7 @@ function ConversationList({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{!isSystem && unlocked && (
|
{!isSystem && ready && (
|
||||||
<button
|
<button
|
||||||
onClick={() => openChat(c.partner)}
|
onClick={() => openChat(c.partner)}
|
||||||
title={t("messages.popOut")}
|
title={t("messages.popOut")}
|
||||||
|
|
@ -200,7 +197,6 @@ function PageThread({
|
||||||
isSystem,
|
isSystem,
|
||||||
seedName,
|
seedName,
|
||||||
seedAvatar,
|
seedAvatar,
|
||||||
unlocked,
|
|
||||||
onBack,
|
onBack,
|
||||||
}: {
|
}: {
|
||||||
meId: number;
|
meId: number;
|
||||||
|
|
@ -208,10 +204,10 @@ function PageThread({
|
||||||
isSystem: boolean;
|
isSystem: boolean;
|
||||||
seedName?: string;
|
seedName?: string;
|
||||||
seedAvatar?: string | null;
|
seedAvatar?: string | null;
|
||||||
unlocked: boolean;
|
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const ready = useKeyState() === "ready";
|
||||||
const name = seedName ?? "";
|
const name = seedName ?? "";
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-[65vh] min-h-80">
|
<div className="flex flex-col h-[65vh] min-h-80">
|
||||||
|
|
@ -223,7 +219,7 @@ function PageThread({
|
||||||
<span className="font-semibold truncate">{name}</span>
|
<span className="font-semibold truncate">{name}</span>
|
||||||
{!isSystem && <ShieldCheck className="w-4 h-4 text-muted shrink-0" aria-label={t("messages.encryptedHint")} />}
|
{!isSystem && <ShieldCheck className="w-4 h-4 text-muted shrink-0" aria-label={t("messages.encryptedHint")} />}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{!isSystem && unlocked && (
|
{!isSystem && ready && (
|
||||||
<button
|
<button
|
||||||
onClick={() => openChat({ id: partnerId, name, avatar_url: seedAvatar ?? null })}
|
onClick={() => openChat({ id: partnerId, name, avatar_url: seedAvatar ?? null })}
|
||||||
title={t("messages.popOut")}
|
title={t("messages.popOut")}
|
||||||
|
|
@ -234,7 +230,7 @@ function PageThread({
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ChatThread meId={meId} partnerId={partnerId} isSystem={isSystem} unlocked={unlocked} />
|
<ChatThread meId={meId} partnerId={partnerId} isSystem={isSystem} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// Shared messaging helpers used by both the Messages page and the floating chat dock.
|
// Shared messaging helpers used by both the Messages page and the floating chat dock.
|
||||||
import { useSyncExternalStore } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api } from "./api";
|
import { api } from "./api";
|
||||||
import { isUnlocked, subscribeUnlock } from "./e2ee";
|
import { isUnlocked, subscribeUnlock } from "./e2ee";
|
||||||
|
|
||||||
|
|
@ -19,3 +20,17 @@ export async function partnerPub(partnerId: number): Promise<string> {
|
||||||
export function useUnlocked(): boolean {
|
export function useUnlocked(): boolean {
|
||||||
return useSyncExternalStore(subscribeUnlock, isUnlocked, isUnlocked);
|
return useSyncExternalStore(subscribeUnlock, isUnlocked, isUnlocked);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The SERVER is the source of truth for whether messaging is set up. Combining it with the
|
||||||
|
// local unlock state gives the real gate: "setup" if the server has no key (even if a stale
|
||||||
|
// private key lingers in this browser — setup overwrites it), "unlock" if the server has a key
|
||||||
|
// but this device hasn't unlocked it, else "ready".
|
||||||
|
export type KeyState = "loading" | "setup" | "unlock" | "ready";
|
||||||
|
export function useKeyState(): KeyState {
|
||||||
|
const unlocked = useUnlocked();
|
||||||
|
const q = useQuery({ queryKey: ["message-key"], queryFn: api.messageMyKey, staleTime: 60_000 });
|
||||||
|
if (q.isLoading && !q.data) return "loading";
|
||||||
|
if (!(q.data?.configured ?? false)) return "setup";
|
||||||
|
if (!unlocked) return "unlock";
|
||||||
|
return "ready";
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue