Files
siftlode/frontend/src/components/ChatDock.tsx
T
peter 34297b696f refactor(fe): migrate plex/messaging/admin/downloads/notifications to qk (R7 S2b)
Extend lib/queryKeys.ts to the remaining ~34 key roots and migrate every
remaining literal (queryKey: [...] AND the positional useLiveQuery keys) in
36 files. All ~200 React Query keys across the app now route through the
factory; zero raw key literals remain outside queryKeys.ts.

The heterogeneous composite plex keys (plex-library/facets/collections/
playlists carry a filters object, a ratingKeys array, or a union/group
discriminator) take a variadic pass-through — the factory centralizes the
ROOT string; single-id keys stay precisely typed. Nullable keys (thread,
playlist, ytSearch) accept null so a null segment is preserved.

Pure substitution — byte-identical key arrays, so TanStack prefix matching
is unchanged. Gate green: typecheck (app+node+e2e), eslint (0 err),
prettier, 68 vitest. Runtime-verified on a fresh dev server: Feed + Plex
modules load, feed/facets/tags/my-status/plex-library/plex-facets/
plex-collections/plex-playlists all 200, full UI renders.
2026-07-27 22:10:03 +02:00

120 lines
4.5 KiB
TypeScript

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 Avatar from "./Avatar";
import ChatThread from "./ChatThread";
import Overlay from "./Overlay";
// 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);
e2ee.loadFromDevice(meId);
}, [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="pointer-events-auto relative w-80 max-w-[calc(100vw-2rem)] glass rounded-2xl shadow-2xl 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">
<button
onClick={() => toggleMinimize(chat.partnerId)}
className="flex items-center gap-2 min-w-0 flex-1 text-left"
title={chat.minimized ? t("messages.expand") : t("messages.minimize")}
>
<Avatar
src={chat.avatar}
fallback={chat.name}
className="w-7 h-7 rounded-full shrink-0 text-xs"
/>
<span className="font-semibold text-sm truncate">{chat.name}</span>
</button>
<button
onClick={() => toggleMinimize(chat.partnerId)}
className="shrink-0 p-1 rounded-md text-muted hover:text-fg hover:bg-bg transition"
aria-label={chat.minimized ? t("messages.expand") : t("messages.minimize")}
>
{chat.minimized ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
<button
onClick={() => closeChat(chat.partnerId)}
className="shrink-0 p-1 rounded-md text-muted hover:text-fg hover:bg-bg transition"
aria-label={t("messages.close")}
>
<X className="w-4 h-4" />
</button>
</div>
{!chat.minimized && (
<div className="flex flex-col h-96">
<ChatThread meId={meId} partnerId={chat.partnerId} isSystem={false} />
</div>
)}
</div>
);
}