import type { ChatMessage } from '../types'; import type { ChatNotifier } from './types'; export interface CrossTabNotifierOptions { /** * The underlying notifier that performs the actual title/favicon * mutation (usually `createBrowserNotifier()`). The decorator only * forwards calls when this tab is the elected leader; followers stay * silent so the title doesn't flicker in stereo across 4 open tabs. */ inner: ChatNotifier; /** * Live "this tab is the leader" flag. Read from `useActiveTab` in * the hook layer and passed in via the live-getter form so the * notifier sees the latest value without React re-rendering it. */ isLeader: () => boolean; /** * Optional broadcast channel name. Followers subscribe to count * updates here so the in-tab badge UI (FAB counter) stays in sync * even when they're not the one mutating the title. * * Default: `djangocfg-chat:unread`. */ channel?: string; /** * Callback for follower tabs: fired when a peer (leader) reports an * unread count update. Use it to drive a Zustand store that the FAB * badge subscribes to. */ onPeerUpdate?: (count: number) => void; } interface BroadcastPayload { count: number; } const DEFAULT_CHANNEL = 'djangocfg-chat:unread'; /** * Decorator over an inner notifier that adds cross-tab coordination. * * Behaviour: * - Leader tab → forwards `setUnread/clear` to the inner notifier * AND broadcasts the count so follower tabs can update their FAB * badge UI. * - Follower tab → does NOT call inner (silent on title/favicon), * but still broadcasts via `onPeerUpdate` so the host store * reflects the truth. * * When leadership flips at runtime (e.g. the previous leader closed * its tab), the next `setUnread` call from the new leader will start * mutating the title — there's no special handoff needed because the * inner notifier is idempotent. */ export function createCrossTabNotifier(opts: CrossTabNotifierOptions): ChatNotifier { const { inner, isLeader, onPeerUpdate } = opts; const channelName = opts.channel ?? DEFAULT_CHANNEL; let channel: BroadcastChannel | null = null; if (typeof BroadcastChannel !== 'undefined') { channel = new BroadcastChannel(channelName); if (onPeerUpdate) { channel.addEventListener('message', (e) => { const data = e.data as BroadcastPayload | undefined; if (!data || typeof data.count !== 'number') return; onPeerUpdate(data.count); }); } } return { setUnread(count: number, latest?: ChatMessage | null) { // Broadcast first so followers learn the count even if our local // inner notifier no-ops in this environment. channel?.postMessage({ count } satisfies BroadcastPayload); if (isLeader()) { inner.setUnread(count, latest); } }, clear() { channel?.postMessage({ count: 0 } satisfies BroadcastPayload); if (isLeader()) { inner.clear(); } else { // Followers never armed the inner notifier, but call clear() // anyway in case leadership flipped between an old setUnread // and this clear — inner is idempotent. inner.clear(); } }, dispose() { channel?.close(); channel = null; inner.dispose?.(); }, }; }