136 lines
4.2 KiB
TypeScript
136 lines
4.2 KiB
TypeScript
'use client'
|
|
|
|
import { createContext, useContext, useState, useEffect, useCallback, useRef, type ReactNode } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { HubConnectionBuilder, LogLevel } from '@microsoft/signalr'
|
|
import { useAuth } from '@/contexts/auth-context'
|
|
import { notifications, NOTIFICATION_HUB_URL } from '@/lib/api/notifications'
|
|
import { toast } from '@/components/ui/toast'
|
|
import type { NotificationResponse, NotificationSeverity } from '@/types/notifications'
|
|
|
|
const RECENT_COUNT = 20
|
|
|
|
const TOAST_TYPE: Record<NotificationSeverity, 'info' | 'success' | 'warning' | 'error'> = {
|
|
Info: 'info',
|
|
Success: 'success',
|
|
Warning: 'warning',
|
|
Critical: 'error',
|
|
}
|
|
|
|
interface NotificationContextValue {
|
|
/** The most recent notifications, newest first (what the bell popover shows). */
|
|
recent: NotificationResponse[]
|
|
unreadCount: number
|
|
/** Marks the notification read (if needed) and navigates to its link. */
|
|
open: (n: NotificationResponse) => Promise<void>
|
|
markAllRead: () => Promise<void>
|
|
/** Re-fetches the recent list and the unread count. */
|
|
refresh: () => Promise<void>
|
|
}
|
|
|
|
const NotificationContext = createContext<NotificationContextValue | null>(null)
|
|
|
|
export function NotificationProvider({ children }: { children: ReactNode }) {
|
|
const router = useRouter()
|
|
const { user } = useAuth()
|
|
const [recent, setRecent] = useState<NotificationResponse[]>([])
|
|
const [unreadCount, setUnreadCount] = useState(0)
|
|
|
|
const refresh = useCallback(async () => {
|
|
try {
|
|
const [page, count] = await Promise.all([
|
|
notifications.list({ page: 1, pageSize: RECENT_COUNT }),
|
|
notifications.unreadCount(),
|
|
])
|
|
setRecent(page.items)
|
|
setUnreadCount(count)
|
|
} catch {
|
|
// The bell is a convenience; never break the page if it can't load.
|
|
}
|
|
}, [])
|
|
|
|
const markRead = useCallback(async (n: NotificationResponse) => {
|
|
if (n.isRead) return
|
|
setRecent(prev => prev.map(x => (x.id === n.id ? { ...x, isRead: true } : x)))
|
|
setUnreadCount(c => Math.max(0, c - 1))
|
|
try {
|
|
await notifications.markRead(n.id)
|
|
} catch {
|
|
await refresh()
|
|
}
|
|
}, [refresh])
|
|
|
|
const open = useCallback(async (n: NotificationResponse) => {
|
|
await markRead(n)
|
|
if (n.link) router.push(n.link)
|
|
}, [markRead, router])
|
|
|
|
const markAllRead = useCallback(async () => {
|
|
setRecent(prev => prev.map(x => ({ ...x, isRead: true })))
|
|
setUnreadCount(0)
|
|
try {
|
|
await notifications.markAllRead()
|
|
} catch {
|
|
await refresh()
|
|
}
|
|
}, [refresh])
|
|
|
|
// Keep the latest `open` for the long-lived socket handler without reconnecting.
|
|
const openRef = useRef(open)
|
|
useEffect(() => { openRef.current = open }, [open])
|
|
|
|
useEffect(() => {
|
|
if (!user) return
|
|
|
|
void refresh()
|
|
|
|
const connection = new HubConnectionBuilder()
|
|
.withUrl(NOTIFICATION_HUB_URL, { withCredentials: true })
|
|
.withAutomaticReconnect()
|
|
.configureLogging(LogLevel.Warning)
|
|
.build()
|
|
|
|
connection.on('notification', (n: NotificationResponse) => {
|
|
setRecent(prev => [n, ...prev.filter(x => x.id !== n.id)].slice(0, RECENT_COUNT))
|
|
setUnreadCount(c => c + 1)
|
|
toast.add({
|
|
title: n.title,
|
|
description: n.message,
|
|
type: TOAST_TYPE[n.severity] ?? 'info',
|
|
actionProps: n.link
|
|
? { children: 'Open', onClick: () => { void openRef.current(n) } }
|
|
: undefined,
|
|
})
|
|
})
|
|
|
|
// Anything pushed while the socket was down is only in the inbox — catch up.
|
|
connection.onreconnected(() => { void refresh() })
|
|
|
|
connection.start().catch(() => {
|
|
// Initial connect failed (offline / API down); the inbox endpoints still work.
|
|
})
|
|
|
|
return () => { void connection.stop() }
|
|
}, [user, refresh])
|
|
|
|
return (
|
|
<NotificationContext.Provider
|
|
value={{
|
|
recent: user ? recent : [],
|
|
unreadCount: user ? unreadCount : 0,
|
|
open,
|
|
markAllRead,
|
|
refresh,
|
|
}}
|
|
>
|
|
{children}
|
|
</NotificationContext.Provider>
|
|
)
|
|
}
|
|
|
|
export function useNotifications(): NotificationContextValue {
|
|
const ctx = useContext(NotificationContext)
|
|
if (!ctx) throw new Error('useNotifications must be used inside NotificationProvider')
|
|
return ctx
|
|
}
|