'use client' import * as React from 'react' import type { AddNotificationInput, Notification, RenderNotificationTile } from './types' interface NotificationsContextValue { notifications: Notification[] unreadCount: number isOpen: boolean showPopups: boolean addNotification: (input: AddNotificationInput) => string upsertNotification: (input: AddNotificationInput & { id: string }) => string setNotifications: (list: Notification[]) => void markRead: (id: string) => void markAllRead: () => void markSettled: (id: string) => void remove: (id: string) => void clear: () => void open: () => void close: () => void toggle: () => void setShowPopups: (value: boolean) => void /** OS-level notification preference; only meaningful when `desktopPopupsConfigured`. */ showDesktopPopups: boolean setShowDesktopPopups: (value: boolean) => void /** True when the host app wired desktop notifications (passed `onShowDesktopPopupsChange`). */ desktopPopupsConfigured: boolean onHistoryClick?: () => void /** Destination for the history button's "open in a new tab" split action. */ historyHref?: string hasMore: boolean isLoadingMore: boolean loadMore?: () => void renderTile?: RenderNotificationTile } const NotificationsContext = React.createContext(null) export interface NotificationsActions { /** Called after the local reducer marks an item read; persist server-side. */ onMarkRead?: (id: string) => void | Promise /** Called after the local reducer marks all read; persist server-side. */ onMarkAllRead?: () => void | Promise /** Called after the local reducer removes an item; persist server-side. */ onRemove?: (id: string) => void | Promise /** Called after the local reducer marks an item settled; optional persistence. */ onMarkSettled?: (id: string) => void | Promise } export interface NotificationsProviderProps { children: React.ReactNode initialNotifications?: Notification[] maxNotifications?: number defaultShowPopups?: boolean onShowPopupsChange?: (value: boolean) => void /** Desktop (OS-level) notification preference; providing `onShowDesktopPopupsChange` opts into the drawer toggle. */ defaultShowDesktopPopups?: boolean onShowDesktopPopupsChange?: (value: boolean) => void onHistoryClick?: () => void /** Destination for the history button's "open in a new tab" split action. */ historyHref?: string actions?: NotificationsActions /** Pagination — when omitted, the drawer hides its load-more sentinel. */ hasMore?: boolean isLoadingMore?: boolean onLoadMore?: () => void /** Override how individual tiles render (drawer + popups); falls back to `NotificationTile`. */ renderTile?: RenderNotificationTile } type Action = | { type: 'add'; notification: Notification; max: number } | { type: 'upsert'; notification: Notification; max: number } | { type: 'set'; notifications: Notification[] } | { type: 'markRead'; id: string } | { type: 'markAllRead' } | { type: 'markSettled'; id: string } | { type: 'remove'; id: string } | { type: 'clear' } function mergeNotification(base: Notification, incoming: Notification): Notification { const out: Notification = { ...base } for (const key of Object.keys(incoming) as (keyof Notification)[]) { const value = incoming[key] if (value !== undefined) (out as unknown as Record)[key] = value } out.read = (incoming.read ?? false) || (base.read ?? false) || false out.settled = (incoming.settled ?? false) || (base.settled ?? false) || false return out } function reducer(state: Notification[], action: Action): Notification[] { switch (action.type) { case 'add': { const existing = state.find((n) => n.id === action.notification.id) if (existing) { const merged = mergeNotification(existing, action.notification) return [merged, ...state.filter((n) => n.id !== action.notification.id)] } const next = [action.notification, ...state] return next.length > action.max ? next.slice(0, action.max) : next } case 'upsert': { const existing = state.find((n) => n.id === action.notification.id) if (existing) { const merged = mergeNotification(existing, action.notification) return state.map((n) => (n.id === action.notification.id ? merged : n)) } const next = [action.notification, ...state] return next.length > action.max ? next.slice(0, action.max) : next } case 'set': { const existingById = new Map(state.map((n) => [n.id, n])) return action.notifications.map((incoming) => { const existing = existingById.get(incoming.id) if (!existing) return incoming return { ...incoming, read: incoming.read || existing.read, settled: incoming.settled || existing.settled, } }) } case 'markRead': return state.map((n) => (n.id === action.id ? { ...n, read: true } : n)) case 'markAllRead': return state.map((n) => (n.read ? n : { ...n, read: true })) case 'markSettled': return state.map((n) => (n.id === action.id && !n.settled ? { ...n, settled: true } : n)) case 'remove': return state.filter((n) => n.id !== action.id) case 'clear': return [] default: return state } } function generateId() { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID() } return `n_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}` } export function NotificationsProvider({ children, initialNotifications = [], maxNotifications = 50, defaultShowPopups = true, onShowPopupsChange, defaultShowDesktopPopups = false, onShowDesktopPopupsChange, onHistoryClick, historyHref, actions, hasMore = false, isLoadingMore = false, onLoadMore, renderTile, }: NotificationsProviderProps) { const [notifications, dispatch] = React.useReducer(reducer, initialNotifications) const [isOpen, setIsOpen] = React.useState(false) const [showPopups, setShowPopupsState] = React.useState(defaultShowPopups) const [showDesktopPopups, setShowDesktopPopupsState] = React.useState(defaultShowDesktopPopups) const desktopPopupsConfigured = onShowDesktopPopupsChange !== undefined const actionsRef = React.useRef(actions) React.useEffect(() => { actionsRef.current = actions }, [actions]) const addNotification = React.useCallback( (input: AddNotificationInput) => { const id = input.id ?? generateId() const notification: Notification = { ...input, id, createdAt: input.createdAt ?? Date.now(), read: input.read ?? false, } dispatch({ type: 'add', notification, max: maxNotifications }) return id }, [maxNotifications], ) const upsertNotification = React.useCallback( (input: AddNotificationInput & { id: string }) => { const notification: Notification = { ...input, id: input.id, createdAt: input.createdAt ?? Date.now(), read: input.read ?? false, } dispatch({ type: 'upsert', notification, max: maxNotifications }) return input.id }, [maxNotifications], ) const setNotifications = React.useCallback((list: Notification[]) => { dispatch({ type: 'set', notifications: list }) }, []) const markRead = React.useCallback((id: string) => { dispatch({ type: 'markRead', id }) void actionsRef.current?.onMarkRead?.(id) }, []) const markAllRead = React.useCallback(() => { dispatch({ type: 'markAllRead' }) void actionsRef.current?.onMarkAllRead?.() }, []) const markSettled = React.useCallback((id: string) => { dispatch({ type: 'markSettled', id }) void actionsRef.current?.onMarkSettled?.(id) }, []) const remove = React.useCallback((id: string) => { dispatch({ type: 'remove', id }) void actionsRef.current?.onRemove?.(id) }, []) const clear = React.useCallback(() => dispatch({ type: 'clear' }), []) const open = React.useCallback(() => setIsOpen(true), []) const close = React.useCallback(() => setIsOpen(false), []) const toggle = React.useCallback(() => setIsOpen((v) => !v), []) const setShowPopups = React.useCallback( (value: boolean) => { setShowPopupsState(value) onShowPopupsChange?.(value) }, [onShowPopupsChange], ) const setShowDesktopPopups = React.useCallback( (value: boolean) => { setShowDesktopPopupsState(value) onShowDesktopPopupsChange?.(value) }, [onShowDesktopPopupsChange], ) const unreadCount = React.useMemo( () => notifications.reduce((acc, n) => (n.read ? acc : acc + 1), 0), [notifications], ) const value = React.useMemo( () => ({ notifications, unreadCount, isOpen, showPopups, addNotification, upsertNotification, setNotifications, markRead, markAllRead, markSettled, remove, clear, open, close, toggle, setShowPopups, showDesktopPopups, setShowDesktopPopups, desktopPopupsConfigured, onHistoryClick, historyHref, hasMore, isLoadingMore, loadMore: onLoadMore, renderTile, }), [ notifications, unreadCount, isOpen, showPopups, showDesktopPopups, setShowDesktopPopups, desktopPopupsConfigured, addNotification, upsertNotification, setNotifications, markRead, markAllRead, markSettled, remove, clear, open, close, toggle, setShowPopups, onHistoryClick, historyHref, hasMore, isLoadingMore, onLoadMore, renderTile, ], ) return {children} } export function useNotifications(): NotificationsContextValue { const ctx = React.useContext(NotificationsContext) if (!ctx) { throw new Error('useNotifications must be used inside ') } return ctx } export function useOptionalNotifications(): NotificationsContextValue | null { return React.useContext(NotificationsContext) }