'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; import { getNotifications, getUnread, markAllAsRead as apiMarkAllAsRead, markAsRead, type Notification, } from '@/api/notifications'; type WsMessage = | { type: 'connection_established' } | { type: 'unread_count'; count: number } | { type: 'new_notification'; notification: Notification; unread_count?: number } | { type: 'notification_read'; notification_id: number } | { type: 'all_notifications_read'; count: number } | { type: 'nav_badges_updated' } | { type: 'error'; message?: string } | { type: string; [k: string]: unknown }; function dedupeById(items: Notification[]): Notification[] { const seen = new Set(); return items.filter((n) => { if (seen.has(n.id)) return false; seen.add(n.id); return true; }); } export function useNotifications(options?: { wsEnabled?: boolean; onNavBadgesUpdate?: () => void }) { const wsEnabled = options?.wsEnabled ?? true; const onNavBadgesUpdateRef = useRef(options?.onNavBadgesUpdate); onNavBadgesUpdateRef.current = options?.onNavBadgesUpdate; const PAGE_SIZE = 20; const [list, setList] = useState([]); const [unreadCount, setUnreadCount] = useState(0); const [loading, setLoading] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const [hasMore, setHasMore] = useState(true); const [nextPage, setNextPage] = useState(2); const wsRef = useRef(null); const reconnectTimeoutRef = useRef | null>(null); const fetchList = useCallback(async () => { setLoading(true); setHasMore(true); setNextPage(2); try { const res = await getNotifications({ page: 1, page_size: PAGE_SIZE }); const results = res.results ?? []; setList(dedupeById(results)); setHasMore(Boolean(res.next) || results.length >= PAGE_SIZE); } catch { setList([]); setHasMore(false); } finally { setLoading(false); } }, []); const fetchMore = useCallback(async () => { if (loadingMore || !hasMore) return; setLoadingMore(true); try { const res = await getNotifications({ page: nextPage, page_size: PAGE_SIZE }); const results = res.results ?? []; setList((prev) => { const existingIds = new Set(prev.map((n) => n.id)); const newItems = results.filter((n) => !existingIds.has(n.id)); return newItems.length ? [...prev, ...newItems] : prev; }); setHasMore(Boolean(res.next) || results.length >= PAGE_SIZE); setNextPage((p) => p + 1); } catch { setHasMore(false); } finally { setLoadingMore(false); } }, [loadingMore, hasMore, nextPage]); const fetchUnreadCount = useCallback(async () => { try { const { count } = await getUnread(); setUnreadCount(count); } catch { setUnreadCount(0); } }, []); const markOneAsRead = useCallback(async (id: number) => { try { await markAsRead(id); setList((prev) => prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)) ); setUnreadCount((c) => Math.max(0, c - 1)); } catch { // ignore } }, []); const markAllAsRead = useCallback(async () => { try { await apiMarkAllAsRead(); setUnreadCount(0); setList((prev) => prev.map((n) => ({ ...n, is_read: true }))); } catch { // ignore } }, []); useEffect(() => { fetchUnreadCount(); }, [fetchUnreadCount]); useEffect(() => { if (!wsEnabled || typeof window === 'undefined') return; const token = localStorage.getItem('access_token'); if (!token) return; let baseUrl = process.env.NEXT_PUBLIC_API_URL || window.location.origin; baseUrl = baseUrl.replace(/\/api\/?$/, '').replace(/\/$/, ''); const wsProtocol = baseUrl.startsWith('https') ? 'wss:' : 'ws:'; const wsHost = baseUrl.replace(/^https?:\/\//, ''); const wsUrl = `${wsProtocol}//${wsHost}/ws/notifications/?token=${token}`; const connect = () => { if (wsRef.current?.readyState === WebSocket.OPEN) return; const ws = new WebSocket(wsUrl); wsRef.current = ws; ws.onmessage = (ev) => { try { const data = JSON.parse(ev.data) as WsMessage; switch (data.type) { case 'unread_count': setUnreadCount((data as { count: number }).count); break; case 'new_notification': { const payload = data as { notification: Notification; unread_count?: number }; if (payload.unread_count !== undefined) setUnreadCount(payload.unread_count); else setUnreadCount((c) => c + 1); setList((prev) => { const has = prev.some((n) => n.id === payload.notification.id); if (has) return prev; return [payload.notification, ...prev]; }); break; } case 'notification_read': { const id = (data as { notification_id: number }).notification_id; setList((prev) => prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)) ); setUnreadCount((c) => Math.max(0, c - 1)); break; } case 'all_notifications_read': setUnreadCount(0); setList((prev) => prev.map((n) => ({ ...n, is_read: true }))); break; case 'nav_badges_updated': onNavBadgesUpdateRef.current?.(); break; default: break; } } catch { // ignore } }; ws.onclose = () => { wsRef.current = null; reconnectTimeoutRef.current = setTimeout(connect, 3000); }; ws.onerror = () => {}; }; connect(); return () => { if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current); wsRef.current?.close(); wsRef.current = null; }; }, [wsEnabled]); return { list, unreadCount, loading, loadingMore, hasMore, fetchList, fetchMore, fetchUnreadCount, markOneAsRead, markAllAsRead, }; }