main
/**
* 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<LiveListener>()
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)
}