165 lines
4.8 KiB
TypeScript
165 lines
4.8 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"
|
|
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<void>
|
|
markReadAndNavigate: (notification: NotificationItem) => Promise<void>
|
|
}
|
|
|
|
const NotificationContext = createContext<NotificationContextValue | null>(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<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
|
|
}
|
|
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<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
|
|
}
|