Files
siftlode/frontend/src/lib/chatDock.ts
T
peter e5b155ada9 refactor(ui): reuse the store, media-query and deleted-tag rules the review found rebuilt, and name a loading pick
E32 review round 1 (F3–F6).

- Channels' open-row store and the chat dock use lib/store's createStore instead of a hand-rolled
  listener set (the chat dock gets a test of its own).
- The Plex backdrop reads prefers-reduced-motion through useMediaQuery; the query string is one
  constant (REDUCED_MOTION) for the hook and the carousel's one-off read.
- A link-restored collection filter showed its raw rating_key in the picker while the titles
  loaded: usePlexCollectionTitles now says it is pending, the picker draws a placeholder
  (labelsPending, as the channel picker does) and so does the chip row.
- The "deleted tag" rule was written twice with two i18n keys and had drifted: the channel manager
  showed NO chip for a pick while the tag list loaded, or ever after a failed load, while the pick
  still narrowed the table. One rule now (lib/tagPicks), one key (common.deletedTag); the manager's
  row shows every pick, as a placeholder while loading and as its id after a failure.
2026-10-04 02:29:03 +02:00

114 lines
3.3 KiB
TypeScript

// App-wide store of open floating chat windows (the Messenger-style dock, bottom-right).
// A window can be opened from anywhere (a conversation's "pop out" button, or automatically
// when a message arrives) and stays open across page navigation. The dock state (which chats
// are open + their minimised state) is persisted per user so it survives a reload.
import { LS } from "./storage";
import { createStore } from "./store";
export interface DockChat {
partnerId: number;
name: string;
avatar: string | null;
minimized: boolean;
flash: number; // bumped to trigger a one-shot attention flash on the window
}
type PartnerLike = { id: number; name: string; avatar_url: string | null };
const MAX_OPEN = 3; // keep the dock from overflowing the corner
const dock = createStore<DockChat[]>([]);
let userId: number | null = null;
function storageKey(): string | null {
return userId != null ? LS.chatDock(userId) : null;
}
function persist(): void {
const k = storageKey();
if (!k) return;
try {
// Don't persist the transient flash counter.
localStorage.setItem(k, JSON.stringify(dock.get().map(({ flash, ...c }) => c)));
} catch {
/* ignore quota/serialization errors */
}
}
function emit(next: DockChat[]): void {
dock.set(next);
persist();
}
// Bind the dock to the signed-in user and restore their persisted windows (once per user).
export function initDock(uid: number): void {
if (userId === uid) return;
userId = uid;
let restored: DockChat[] = [];
try {
const raw = localStorage.getItem(storageKey()!);
if (raw)
restored = (JSON.parse(raw) as Omit<DockChat, "flash">[]).map((c) => ({ ...c, flash: 0 }));
} catch {
restored = [];
}
dock.set(restored.slice(0, MAX_OPEN));
}
export function openChat(partner: PartnerLike): void {
const existing = dock.get().find((c) => c.partnerId === partner.id);
if (existing) {
// Re-opening focuses it: ensure expanded and move to the front.
emit([
{ ...existing, minimized: false },
...dock.get().filter((c) => c.partnerId !== partner.id),
]);
return;
}
emit(
[
{
partnerId: partner.id,
name: partner.name,
avatar: partner.avatar_url,
minimized: false,
flash: 0,
},
...dock.get(),
].slice(0, MAX_OPEN),
);
}
// An incoming message arrived from `partner`: open the window if it's closed, otherwise just
// flash it (whether expanded or minimised — don't disturb the minimised state).
export function notifyIncoming(partner: PartnerLike): void {
const existing = dock.get().find((c) => c.partnerId === partner.id);
if (!existing) {
emit(
[
{
partnerId: partner.id,
name: partner.name,
avatar: partner.avatar_url,
minimized: false,
flash: 1,
},
...dock.get(),
].slice(0, MAX_OPEN),
);
return;
}
emit(dock.get().map((c) => (c.partnerId === partner.id ? { ...c, flash: c.flash + 1 } : c)));
}
export function closeChat(partnerId: number): void {
emit(dock.get().filter((c) => c.partnerId !== partnerId));
}
export function toggleMinimize(partnerId: number): void {
emit(dock.get().map((c) => (c.partnerId === partnerId ? { ...c, minimized: !c.minimized } : c)));
}
export function useDockChats(): DockChat[] {
return dock.useValue();
}