/** * Realtime bridge for `notification` user events (protocol.ts). One hub * subscription serves the whole app: * - the inbox page registers as *foreground* and refreshes its list in place; * - anywhere else the event is turned into a toast. * * A framework-free module store (same shape as the ws hub itself): the socket * outlives any single view, and StrictMode's double mount must not open a * second one. */ import { getRealtimeHub, isNotificationUserEvent, type WsNotification } from '@app/services/realtime' export interface NotifyToast { key: number notification: WsNotification } type LiveListener = (notification: WsNotification) => void const liveListeners = new Set() const toastListeners = new Set<() => void>() const badgeListeners = new Set<() => void>() let foreground = false let toasts: NotifyToast[] = [] let toastSeq = 0 let bridge: (() => void) | null = null /** The session socket, started without any room intent of its own. */ function ensureBridge(): void { if (bridge) return const hub = getRealtimeHub() hub.ensureClient().start() bridge = hub.onUserEvent((event) => { if (!isNotificationUserEvent(event)) return const notification = event.notification for (const listener of [...liveListeners]) listener(notification) refreshUnreadBadge() // The inbox already refetches on its own; only off-page events toast. if (!foreground) pushToast(notification) }) } export function watchNotifications(listener: LiveListener): () => void { ensureBridge() liveListeners.add(listener) return () => { liveListeners.delete(listener) } } /** Called by the inbox page for its mounted lifetime. */ export function setNotifyForeground(on: boolean): void { foreground = on } /* ------------------------------------------------------------------ toasts */ function emitToasts(): void { for (const listener of [...toastListeners]) listener() } function pushToast(notification: WsNotification): void { toastSeq += 1 toasts = [...toasts, { key: toastSeq, notification }].slice(-3) emitToasts() } export function dismissToast(key: number): void { const next = toasts.filter((toast) => toast.key !== key) if (next.length === toasts.length) return toasts = next emitToasts() } export function getToasts(): NotifyToast[] { return toasts } export function subscribeToasts(listener: () => void): () => void { toastListeners.add(listener) return () => toastListeners.delete(listener) } /* ------------------------------------------------------------- unread badge */ /** Bell refetch signal: live events and the inbox's own read/delete writes. */ export function refreshUnreadBadge(): void { for (const listener of [...badgeListeners]) listener() } export function watchUnreadBadge(listener: () => void): () => void { badgeListeners.add(listener) return () => badgeListeners.delete(listener) }