// 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 { useSyncExternalStore } from "react"; 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 let chats: DockChat[] = []; let userId: number | null = null; const subs = new Set<() => void>(); function storageKey(): string | null { return userId != null ? `siftlode.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(chats.map(({ flash, ...c }) => c))); } catch { /* ignore quota/serialization errors */ } } function emit(next: DockChat[]): void { chats = next; persist(); for (const s of subs) s(); } // 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[]).map((c) => ({ ...c, flash: 0 })); } catch { restored = []; } chats = restored.slice(0, MAX_OPEN); for (const s of subs) s(); } export function openChat(partner: PartnerLike): void { const existing = chats.find((c) => c.partnerId === partner.id); if (existing) { // Re-opening focuses it: ensure expanded and move to the front. emit([ { ...existing, minimized: false }, ...chats.filter((c) => c.partnerId !== partner.id), ]); return; } emit( [ { partnerId: partner.id, name: partner.name, avatar: partner.avatar_url, minimized: false, flash: 0 }, ...chats, ].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 = chats.find((c) => c.partnerId === partner.id); if (!existing) { emit( [ { partnerId: partner.id, name: partner.name, avatar: partner.avatar_url, minimized: false, flash: 1 }, ...chats, ].slice(0, MAX_OPEN) ); return; } emit(chats.map((c) => (c.partnerId === partner.id ? { ...c, flash: c.flash + 1 } : c))); } export function closeChat(partnerId: number): void { emit(chats.filter((c) => c.partnerId !== partnerId)); } export function toggleMinimize(partnerId: number): void { emit(chats.map((c) => (c.partnerId === partnerId ? { ...c, minimized: !c.minimized } : c))); } export function useDockChats(): DockChat[] { return useSyncExternalStore( (cb) => { subs.add(cb); return () => subs.delete(cb); }, () => chats, () => chats ); }