import { BellIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { showToast } from "../../../../utils/toast"; import { useSocketContext } from "../../../../contexts"; import { usePageUrlGenerator } from "../../../../hooks"; import { Card, CardHeader, CardTitle, Popover, PopoverContent, PopoverTrigger, ScrollArea, Separator, SidebarMenuButton, } from "../../../../shadcnui"; import { useNotificationContext } from "../../contexts/NotificationContext"; import { NotificationInterface } from "../../data"; import { NotificationErrorBoundary } from "../common"; interface NotificationModalProps { isOpen: boolean; setIsOpen: (open: boolean) => void; } function NotificationModalContent({ isOpen, setIsOpen }: NotificationModalProps) { const _instanceId = useRef(Math.random().toString(36).substr(2, 9)); const { notifications, addNotification, generateNotification, generateToastNotification, markNotificationsAsRead, isLoading, error, loadNotifications, shouldRefresh, } = useNotificationContext(); const { socketNotifications, removeSocketNotification: _removeSocketNotification, clearSocketNotifications, } = useSocketContext(); const t = useTranslations(); const generateUrl = usePageUrlGenerator(); const [newNotifications, setNewNotifications] = useState(false); const preventAutoClose = useRef(false); const circuitBreakerRef = useRef({ count: 0, resetTime: 0, isOpen: false, }); const checkCircuitBreaker = useCallback(() => { const now = Date.now(); const breaker = circuitBreakerRef.current; // Reset counter every 10 seconds if (now > breaker.resetTime) { breaker.count = 0; breaker.resetTime = now + 10000; // 10 seconds breaker.isOpen = false; } // Trip breaker if more than 20 notifications in 10 seconds breaker.count++; if (breaker.count > 20) { breaker.isOpen = true; return false; } return !breaker.isOpen; }, []); const { unreadCount, unreadIds } = useMemo(() => { const unreadNotifications = notifications.filter((notif) => !notif.isRead); return { unreadCount: unreadNotifications.length, unreadIds: unreadNotifications.map((notif) => notif.id), }; }, [notifications]); useEffect(() => { setNewNotifications(unreadCount > 0); }, [unreadCount]); // The initial load is owned by NotificationContextProvider's mount effect. // The bell still refreshes on open when the cache is stale (see handleOpenChange). const processSocketNotificationsRef = useRef(null); const processSocketNotifications = useCallback(() => { if (socketNotifications.length === 0) { return; } if (!checkCircuitBreaker()) { clearSocketNotifications(); // Still clear to prevent memory leaks return; } const currentSocketNotifications = [...socketNotifications]; clearSocketNotifications(); // Process notifications in smaller batches to prevent UI freeze const batchSize = 3; const batches = []; for (let i = 0; i < currentSocketNotifications.length; i += batchSize) { batches.push(currentSocketNotifications.slice(i, i + batchSize)); } batches.forEach((batch, batchIndex) => { setTimeout(() => { batch.forEach((notification) => { addNotification(notification); const toastNotification = generateToastNotification(notification, t, generateUrl); showToast(toastNotification.title, { description: toastNotification.description, action: toastNotification.action, }); }); // Only set newNotifications on the last batch if (batchIndex === batches.length - 1) { setNewNotifications(true); } }, batchIndex * 100); // 100ms delay between batches }); }, [ socketNotifications, clearSocketNotifications, addNotification, generateToastNotification, t, generateUrl, checkCircuitBreaker, ]); // 🔗 SOCKET: Throttled processing with 300ms delay useEffect(() => { if (processSocketNotificationsRef.current) { clearTimeout(processSocketNotificationsRef.current); } processSocketNotificationsRef.current = setTimeout(() => { processSocketNotifications(); }, 300); // 300ms throttle return () => { if (processSocketNotificationsRef.current) { clearTimeout(processSocketNotificationsRef.current); } }; }, [processSocketNotifications]); const handleOpenChange = (newlyRequestedOpenState: boolean) => { if (!newlyRequestedOpenState && preventAutoClose.current) { return; } setIsOpen(newlyRequestedOpenState); if (newlyRequestedOpenState) { // Refresh notifications from API if cache is stale if (shouldRefresh) { loadNotifications(); } preventAutoClose.current = true; if (unreadIds.length > 0) { markNotificationsAsRead(unreadIds) .catch((error) => { console.error("❌ [NotificationModal] Failed to mark notifications as read:", error); }) .finally(() => { preventAutoClose.current = false; // Workaround: re-open if it was open before setIsOpen(true); }); } else { preventAutoClose.current = false; } setNewNotifications(false); } }; const unreadNotifications = newNotifications && unreadCount > 0; return ( } className="text-muted-foreground h-6" disabled={isLoading}> {t(`entities.notifications`, { count: 2 })} {t(`entities.notifications`, { count: 2 })} {isLoading &&
Loading...
} {error &&
Error: {error}
}
{notifications.length > 0 ? ( notifications.map((notification: NotificationInterface) => ( {generateNotification(notification, () => setIsOpen(false))} )) ) : (
{t(`notification.empty`)}
)}
); } export function NotificationModal(props: NotificationModalProps) { return ( ); }