"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" import { useAppLocale, useI18n } from "@/i18n/provider" import { localizeNotificationItem } from "@/lib/notification-i18n" type NotificationRealtimeEnvelope = { eventId?: string type?: string data?: { notification?: NotificationItem } } type NotificationContextValue = { unreadCount: number realtimeStatus: RealtimeConnectionStatus refreshUnreadCount: () => Promise markReadAndNavigate: (notification: NotificationItem) => Promise } const NotificationContext = createContext(null) export function NotificationProvider({ children }: { children: ReactNode }) { const t = useI18n() const { locale } = useAppLocale() const router = useRouter() const [unreadCount, setUnreadCount] = useState(0) const [realtimeStatus, setRealtimeStatus] = useState("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 } const localizedNotification = localizeNotificationItem(notification, locale) setUnreadCount((current) => current + 1) toast(localizedNotification.title || t("notification.new"), { description: localizedNotification.content, action: { label: t("notification.view"), onClick: () => { void markReadAndNavigate(localizedNotification).catch((error) => { toast.error(error instanceof Error ? error.message : t("notification.openFailed")) }) }, }, }) } catch { // ignore invalid realtime payload } }, onConnectError: (error) => { toast.error(error instanceof Error ? error.message : t("notification.connectFailed")) }, }) realtime.connect() return () => { realtime.disconnect() } }, [locale, markReadAndNavigate, refreshUnreadCount, t]) const value = useMemo( () => ({ unreadCount, realtimeStatus, refreshUnreadCount, markReadAndNavigate, }), [markReadAndNavigate, realtimeStatus, refreshUnreadCount, unreadCount] ) return ( {children} ) } export function useNotifications() { const context = useContext(NotificationContext) if (!context) { throw new Error("useNotifications must be used within NotificationProvider") } return context }