'use client'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useActiveTabStore } from '@djangocfg/ui-core/hooks'; import { createBrowserNotifier, createCrossTabNotifier, isPageHidden, onVisibilityChange, type BrowserNotifierOptions, type ChatNotifier, } from '../notifier'; import { useChatUnread, type UseChatUnreadOptions } from './useChatUnread'; export interface UseChatUnreadNotifierOptions extends UseChatUnreadOptions { /** * Custom notifier. Pass a host-specific implementation (Wails dock * badge etc.) to opt out of the built-in browser title/favicon * mutation. If omitted, a `createBrowserNotifier` instance is used. */ notifier?: ChatNotifier; /** * Options forwarded to the default browser notifier. Ignored when an * explicit `notifier` is provided. */ browser?: BrowserNotifierOptions; /** * Master switch. Default `true`. Set false to keep the unread * tracking but skip all environment mutation. */ enabled?: boolean; /** * Cross-tab coordination. When enabled (default), only the elected * leader tab mutates `document.title` / favicon — other tabs stay * silent. The unread count is broadcast so every tab's FAB badge UI * still reflects reality. * * Pass `false` to disable; pass an options object to customise the * BroadcastChannel name. Disable in single-tab hosts (Wails / Electron) * where leadership is moot. */ crossTab?: boolean | { channel?: string }; } /** * Glue between `useChatUnread` and a `ChatNotifier`. * * Inputs that drive the notifier: * 1. `useChatUnread` — provider-state-derived `{ count, unread }`. * 2. Page visibility — clear when visible; re-arm when hidden+count>0. * 3. Tab leadership (when `crossTab` enabled) — only leader mutates * title/favicon; followers receive count broadcasts so their * in-tab badge UI stays in sync. * * Returns `useChatUnread`'s shape, with the `count` overridden by * cross-tab broadcasts when this tab is a follower (so the FAB badge * shows the same number across every tab). */ export function useChatUnreadNotifier(opts: UseChatUnreadNotifierOptions = {}) { const { notifier: notifierProp, browser, enabled = true, crossTab = true, ...unreadOpts } = opts; const unread = useChatUnread(unreadOpts); // Cross-tab count from peers (followers see this; leader publishes). const [peerCount, setPeerCount] = useState(null); const crossTabChannel = typeof crossTab === 'object' ? crossTab.channel : undefined; const crossTabEnabled = crossTab !== false; // Build the notifier. Inner = host-supplied OR built-in browser. // Wrap with cross-tab decorator when enabled. const notifier = useMemo(() => { const inner = notifierProp ?? createBrowserNotifier(browser); if (!crossTabEnabled) return inner; return createCrossTabNotifier({ inner, isLeader: () => useActiveTabStore.getState().isLeader, channel: crossTabChannel, onPeerUpdate: (count) => setPeerCount(count), }); }, [notifierProp, browser, crossTabEnabled, crossTabChannel]); const lastSyncedCount = useRef(0); // Visibility-driven sync. Single effect owns both the listener and // the imperative calls to keep ordering deterministic. useEffect(() => { if (!enabled) { notifier.clear(); return; } const sync = () => { const hidden = isPageHidden(); if (hidden && unread.count > 0) { notifier.setUnread(unread.count, unread.unread); lastSyncedCount.current = unread.count; } else { notifier.clear(); lastSyncedCount.current = 0; } }; sync(); const unsub = onVisibilityChange(sync); return () => { unsub(); notifier.clear(); }; }, [enabled, notifier, unread.count, unread.unread]); // Final cleanup — release any host-side resources. useEffect(() => () => notifier.dispose?.(), [notifier]); // Effective count: max of local (this tab's own unread tracking) and // peer broadcast. The max handles the case where a peer hasn't sent // a broadcast yet (peerCount === null) — we trust local. const effectiveCount = peerCount !== null ? Math.max(unread.count, peerCount) : unread.count; return { ...unread, count: effectiveCount, }; }