Files
ai-agent/web/components/notification-provider.tsx
T

160 lines
4.5 KiB
TypeScript

"use client"
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import {
createNotificationWebSocketUrl,
fetchNotificationUnreadCount,
markNotificationRead,
type NotificationItem,
} from "@/lib/api/notification"
import { readSession } from "@/lib/auth"
import {
createRealtimeConnectionManager,
type RealtimeConnectionStatus,
} from "@/lib/realtime-connection"
type NotificationRealtimeEnvelope = {
eventId?: string
type?: string
data?: {
notification?: NotificationItem
}
}
type NotificationContextValue = {
unreadCount: number
realtimeStatus: RealtimeConnectionStatus
refreshUnreadCount: () => Promise<void>
markReadAndNavigate: (notification: NotificationItem) => Promise<void>
}
const NotificationContext = createContext<NotificationContextValue | null>(null)
export function NotificationProvider({ children }: { children: ReactNode }) {
const router = useRouter()
const [unreadCount, setUnreadCount] = useState(0)
const [realtimeStatus, setRealtimeStatus] =
useState<RealtimeConnectionStatus>("disconnected")
const currentUserIdRef = useRef(readSession()?.user.id ?? 0)
const refreshUnreadCount = useCallback(async () => {
const result = await fetchNotificationUnreadCount()
setUnreadCount(result.unreadCount)
}, [])
const markReadAndNavigate = useCallback(
async (notification: NotificationItem) => {
if (!notification.readAt) {
await markNotificationRead(notification.id)
setUnreadCount((current) => Math.max(0, current - 1))
}
if (notification.actionUrl) {
router.push(notification.actionUrl)
}
},
[router]
)
useEffect(() => {
currentUserIdRef.current = readSession()?.user.id ?? 0
void refreshUnreadCount().catch(() => {
setUnreadCount(0)
})
}, [refreshUnreadCount])
useEffect(() => {
const realtime = createRealtimeConnectionManager({
createSocket: () => new WebSocket(createNotificationWebSocketUrl()),
canReconnect: () => Boolean(readSession()?.accessToken),
onStatusChange: setRealtimeStatus,
onOpen: () => {
void refreshUnreadCount().catch(() => undefined)
},
onMessage: (event, socket) => {
try {
const envelope = JSON.parse(event.data) as NotificationRealtimeEnvelope
const eventType = envelope.type ?? ""
const eventId = envelope.eventId?.trim() ?? ""
if (
eventType === "" ||
eventType === "connected" ||
eventType === "pong" ||
eventType === "subscribed" ||
eventType === "unsubscribed"
) {
return
}
if (eventId && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: "ack", eventId }))
}
if (eventType !== "notification.created") {
return
}
const notification = envelope.data?.notification
if (!notification || notification.recipientUserId !== currentUserIdRef.current) {
return
}
setUnreadCount((current) => current + 1)
toast(notification.title || "新通知", {
description: notification.content,
action: {
label: "查看",
onClick: () => {
void markReadAndNavigate(notification).catch((error) => {
toast.error(error instanceof Error ? error.message : "打开通知失败")
})
},
},
})
} catch {
// ignore invalid realtime payload
}
},
onConnectError: (error) => {
toast.error(error instanceof Error ? error.message : "连接通知服务失败")
},
})
realtime.connect()
return () => {
realtime.disconnect()
}
}, [markReadAndNavigate, refreshUnreadCount])
const value = useMemo<NotificationContextValue>(
() => ({
unreadCount,
realtimeStatus,
refreshUnreadCount,
markReadAndNavigate,
}),
[markReadAndNavigate, realtimeStatus, refreshUnreadCount, unreadCount]
)
return (
<NotificationContext.Provider value={value}>
{children}
</NotificationContext.Provider>
)
}
export function useNotifications() {
const context = useContext(NotificationContext)
if (!context) {
throw new Error("useNotifications must be used within NotificationProvider")
}
return context
}