From b5891f1ccc17a70514d357a370aef5eed71eba99 Mon Sep 17 00:00:00 2001 From: Jay Date: Mon, 23 Feb 2026 10:23:48 -0600 Subject: [PATCH] feat(notifications): implement /me/notifications with global unread sync --- app/(landing)/notifications/page.tsx | 153 +-------------- app/me/notifications/page.tsx | 179 ++++++++++++++++++ app/providers.tsx | 17 +- components/app-sidebar.tsx | 14 +- components/nav-main.tsx | 9 +- components/notifications/NotificationBell.tsx | 26 ++- .../notifications/NotificationDropdown.tsx | 34 +--- components/notifications/NotificationItem.tsx | 47 +++-- components/notifications/NotificationList.tsx | 174 +++++++++++------ .../providers/notification-provider.tsx | 45 +++++ hooks/use-notification-polling.ts | 11 +- hooks/useNotifications.ts | 63 ++++-- 12 files changed, 469 insertions(+), 303 deletions(-) create mode 100644 app/me/notifications/page.tsx create mode 100644 components/providers/notification-provider.tsx diff --git a/app/(landing)/notifications/page.tsx b/app/(landing)/notifications/page.tsx index 067a7187..c1d1ee0b 100644 --- a/app/(landing)/notifications/page.tsx +++ b/app/(landing)/notifications/page.tsx @@ -1,152 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -import { useState } from 'react'; -import { useNotifications } from '@/hooks/useNotifications'; -import { useNotificationPolling } from '@/hooks/use-notification-polling'; -import { NotificationList } from '@/components/notifications/NotificationList'; -import { Button } from '@/components/ui/button'; -import { Skeleton } from '@/components/ui/skeleton'; -import { toast } from 'sonner'; -import { ChevronLeft, ChevronRight } from 'lucide-react'; -import { AuthGuard } from '@/components/auth'; -import Loading from '@/components/Loading'; - -export default function NotificationsPage() { - const [page, setPage] = useState(1); - const limit = 20; - - const notificationsHook = useNotifications({ - page, - limit, - autoFetch: true, - }); - - const { - notifications, - loading, - error, - total, - unreadCount, - markAllAsRead, - markNotificationAsRead, - setCurrentPage, - } = notificationsHook; - - // Enable polling for real-time updates - useNotificationPolling(notificationsHook, { - interval: 30000, - enabled: true, - }); - - const totalPages = Math.ceil(total / limit); - - const handleMarkAllAsRead = async () => { - try { - await markAllAsRead(); - toast.success('All notifications marked as read'); - } catch { - toast.error('Failed to mark all as read'); - } - }; - - const handlePageChange = (newPage: number) => { - setCurrentPage(newPage); - setPage(newPage); - window.scrollTo({ top: 0, behavior: 'smooth' }); - }; - - if (error) { - return ( -
-
-

- Error loading notifications -

-

{error.message}

- -
-
- ); - } - - return ( - }> -
-
-
-

Notifications

-
- {loading ? ( - - ) : ( - <> - {unreadCount} unread of {total} total - - )} -
-
- {unreadCount > 0 && !loading && ( - - )} -
- - { - if (!notification.read) { - markNotificationAsRead([notification.id]).catch(() => { - // Silently handle error - user feedback already provided - }); - } - }} - onMarkAsRead={id => { - markNotificationAsRead([id]).catch(() => { - // Silently handle error - user feedback already provided - }); - }} - /> - - {totalPages > 1 && !loading && ( -
- - - Page {page} of {totalPages} - - -
- )} -
-
- ); +export default function LegacyNotificationsPage() { + redirect('/me/notifications'); } diff --git a/app/me/notifications/page.tsx b/app/me/notifications/page.tsx new file mode 100644 index 00000000..08415021 --- /dev/null +++ b/app/me/notifications/page.tsx @@ -0,0 +1,179 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { toast } from 'sonner'; +import { NotificationList } from '@/components/notifications/NotificationList'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import BoundlessSheet from '@/components/sheet/boundless-sheet'; +import { useNotificationCenter } from '@/components/providers/notification-provider'; +import type { Notification } from '@/types/notifications'; + +const formatMetadataValue = (value: unknown): string => { + if (value === null || value === undefined) { + return ''; + } + if (typeof value === 'string' || typeof value === 'number') { + return String(value); + } + if (typeof value === 'boolean') { + return value ? 'Yes' : 'No'; + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +}; + +export default function MeNotificationsPage() { + const { + notifications, + unreadCount, + loading, + error, + markAllAsRead, + markNotificationAsRead, + refetch, + } = useNotificationCenter(); + + const [selectedNotification, setSelectedNotification] = + useState(null); + const [sheetOpen, setSheetOpen] = useState(false); + + const metadataEntries = useMemo(() => { + if (!selectedNotification) { + return []; + } + + return Object.entries(selectedNotification.data).filter( + ([, value]) => value !== undefined && value !== null && value !== '' + ); + }, [selectedNotification]); + + const handleMarkAllAsRead = async () => { + try { + await markAllAsRead(); + toast.success('All notifications marked as read'); + } catch { + toast.error('Failed to mark all as read'); + } + }; + + const handleOpenDetails = (notification: Notification) => { + setSelectedNotification(notification); + setSheetOpen(true); + + if (!notification.read) { + markNotificationAsRead([notification.id]).catch(() => { + toast.error('Failed to update notification state'); + }); + } + }; + + if (error) { + return ( +
+
+

+ Error loading notifications +

+

{error.message}

+ +
+
+ ); + } + + return ( + <> +
+
+
+

Notifications

+
+ {loading ? ( + + ) : ( + <> + {unreadCount} unread of {notifications.length} loaded + + )} +
+
+ + + {unreadCount > 0 && !loading && ( + + + + )} + +
+ + +
+ + + {selectedNotification ? ( +
+
+

+ {selectedNotification.message} +

+

+ {new Date(selectedNotification.createdAt).toLocaleString()} +

+

+ Type: {selectedNotification.type} +

+
+ +
+

Metadata

+ {metadataEntries.length === 0 ? ( +

+ No additional metadata provided. +

+ ) : ( +
+ {metadataEntries.map(([key, value]) => ( +
+
{key}
+
+ {formatMetadataValue(value)} +
+
+ ))} +
+ )} +
+
+ ) : null} +
+ + ); +} diff --git a/app/providers.tsx b/app/providers.tsx index b1630424..39e6e51b 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -4,6 +4,7 @@ import { ReactNode } from 'react'; import { AuthProvider } from '@/components/providers/auth-provider'; import { SocketProvider } from '@/components/providers/socket-provider'; import { WalletProvider } from '@/components/providers/wallet-provider'; +import { NotificationProvider } from '@/components/providers/notification-provider'; import { TrustlessWorkProvider } from '@/lib/providers/TrustlessWorkProvider'; import { EscrowProvider } from '@/lib/providers/EscrowProvider'; interface ProvidersProps { @@ -13,13 +14,15 @@ interface ProvidersProps { export function Providers({ children }: ProvidersProps) { return ( - - - - {children} - - - + + + + + {children} + + + + ); } diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index ad72176a..1b25a630 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -26,6 +26,7 @@ import { } from '@/components/ui/sidebar'; import Image from 'next/image'; import Link from 'next/link'; +import { useNotificationCenter } from '@/components/providers/notification-provider'; const navigationData = { main: [ @@ -88,7 +89,6 @@ const navigationData = { title: 'Notifications', url: '/me/notifications', icon: IconBell, - badge: '5', }, ], }; @@ -101,6 +101,13 @@ export function AppSidebar({ user, ...props }: { user: userData } & React.ComponentProps) { + const { unreadCount } = useNotificationCenter(); + const accountItems = navigationData.account.map(item => + item.title === 'Notifications' + ? { ...item, badge: unreadCount > 0 ? String(unreadCount) : undefined } + : item + ); + return (
@@ -115,7 +122,6 @@ export function AppSidebar({
- {/* Header with Logo */} @@ -140,15 +146,13 @@ export function AppSidebar({ - {/* Main Content */} - + - {/* Footer with User */} diff --git a/components/nav-main.tsx b/components/nav-main.tsx index 0bec9fcb..0d7256c6 100644 --- a/components/nav-main.tsx +++ b/components/nav-main.tsx @@ -23,7 +23,7 @@ export function NavMain({ title: string; url: string; icon?: Icon; - badge?: string; + badge?: string | number; badgeVariant?: 'default' | 'destructive' | 'outline'; }[]; label?: string; @@ -43,6 +43,11 @@ export function NavMain({ const isActive = pathname === item.url || pathname?.startsWith(`${item.url}/`); + const hasBadge = + item.badge !== undefined && + item.badge !== null && + Number(item.badge) > 0; + return ( )} {item.title} - {item.badge && ( + {hasBadge && ( { const [isOpen, setIsOpen] = useState(false); - - // Get userId from auth session const { data: session } = authClient.useSession(); const userId = session?.user?.id; - // Use WebSocket-based notifications hook (only connect if userId is available) - const { notifications, unreadCount, isConnected, loading, markAllAsRead } = - useNotifications(userId || undefined); + const { + notifications, + unreadCount, + isConnected, + loading, + markAllAsRead, + markNotificationAsRead, + } = useNotificationCenter(); - // Wrapper for markAllAsRead that uses WebSocket const handleMarkAllAsRead = async () => { markAllAsRead(); }; - const handleNotificationClick = () => { - // Notification click is handled in NotificationDropdown + const handleNotificationClick = ( + notification: (typeof notifications)[number] + ) => { + if (!notification.read) { + markNotificationAsRead([notification.id]).catch(() => {}); + } }; return ( diff --git a/components/notifications/NotificationDropdown.tsx b/components/notifications/NotificationDropdown.tsx index 12032373..3c8cf467 100644 --- a/components/notifications/NotificationDropdown.tsx +++ b/components/notifications/NotificationDropdown.tsx @@ -10,7 +10,6 @@ import { import { NotificationItem } from './NotificationItem'; import { Notification } from '@/types/notifications'; import { Skeleton } from '@/components/ui/skeleton'; -import { markAsRead } from '@/lib/api/notifications'; import { toast } from 'sonner'; interface NotificationDropdownProps { @@ -82,45 +81,24 @@ export const NotificationDropdown = ({ onNotificationClick(notification); } - // Auto-mark as read on click - if (!notification.read) { - try { - await markAsRead({ ids: [notification.id] }); - } catch { - // Silently handle error - user feedback already provided - } - } - - // Navigate to relevant page based on priority - // Organization notifications if (notification.data.organizationId) { router.push(`/organizations/${notification.data.organizationId}`); - } - // Hackathon notifications (prefer slug over ID) - else if (notification.data.hackathonId) { + } else if (notification.data.hackathonId) { if (notification.data.hackathonSlug) { router.push(`/hackathons/${notification.data.hackathonSlug}`); } else { router.push(`/hackathons/${notification.data.hackathonId}`); } - } - // Team invitation notifications (navigate to project if available) - else if ( + } else if ( notification.data.teamInvitationId && notification.data.projectId ) { router.push(`/projects/${notification.data.projectId}`); - } - // Project notifications - else if (notification.data.projectId) { + } else if (notification.data.projectId) { router.push(`/projects/${notification.data.projectId}`); - } - // Comment notifications - else if (notification.data.commentId) { + } else if (notification.data.commentId) { router.push(`/comments/${notification.data.commentId}`); - } - // Milestone notifications - else if (notification.data.milestoneId) { + } else if (notification.data.milestoneId) { router.push(`/milestones/${notification.data.milestoneId}`); } @@ -219,7 +197,7 @@ export const NotificationDropdown = ({ )} diff --git a/components/notifications/NotificationItem.tsx b/components/notifications/NotificationItem.tsx index 3fc8b549..41a72176 100644 --- a/components/notifications/NotificationItem.tsx +++ b/components/notifications/NotificationItem.tsx @@ -5,12 +5,14 @@ import Link from 'next/link'; import { Notification } from '@/types/notifications'; import { getNotificationIcon } from './NotificationIcon'; import { cn } from '@/lib/utils'; +import { AnimatePresence, motion } from 'framer-motion'; interface NotificationItemProps { notification: Notification; onMarkAsRead?: () => void; showUnreadIndicator?: boolean; className?: string; + disableNavigation?: boolean; } export const NotificationItem = ({ @@ -18,16 +20,15 @@ export const NotificationItem = ({ onMarkAsRead, showUnreadIndicator = true, className, + disableNavigation = false, }: NotificationItemProps) => { const Icon = getNotificationIcon(notification.type); const getNotificationLink = (): string => { - // Organization notifications if (notification.data.organizationId) { return `/organizations/${notification.data.organizationId}`; } - // Hackathon notifications (prefer slug over ID) if (notification.data.hackathonId) { if (notification.data.hackathonSlug) { return `/hackathons/${notification.data.hackathonSlug}`; @@ -35,22 +36,18 @@ export const NotificationItem = ({ return `/hackathons/${notification.data.hackathonId}`; } - // Team invitation notifications (navigate to project if available) if (notification.data.teamInvitationId && notification.data.projectId) { return `/projects/${notification.data.projectId}`; } - // Project notifications if (notification.data.projectId) { return `/projects/${notification.data.projectId}`; } - // Comment notifications if (notification.data.commentId) { return `/comments/${notification.data.commentId}`; } - // Milestone notifications if (notification.data.milestoneId) { return `/milestones/${notification.data.milestoneId}`; } @@ -59,7 +56,7 @@ export const NotificationItem = ({ }; const handleClick = () => { - if (!notification.read && onMarkAsRead) { + if (onMarkAsRead) { onMarkAsRead(); } }; @@ -68,7 +65,11 @@ export const NotificationItem = ({ const isClickable = link !== '#'; const content = ( -
- {!notification.read && showUnreadIndicator && ( -
- )} -
+ + {!notification.read && showUnreadIndicator && ( + + )} + + ); - if (isClickable) { + if (isClickable && !disableNavigation) { return ( {content} @@ -138,5 +148,14 @@ export const NotificationItem = ({ ); } - return
{content}
; + return ( + + ); }; diff --git a/components/notifications/NotificationList.tsx b/components/notifications/NotificationList.tsx index fbad14be..e2a66760 100644 --- a/components/notifications/NotificationList.tsx +++ b/components/notifications/NotificationList.tsx @@ -1,6 +1,7 @@ 'use client'; -import { format, isToday, isYesterday, isThisWeek } from 'date-fns'; +import { isBefore, subDays } from 'date-fns'; +import { AnimatePresence, motion } from 'framer-motion'; import { NotificationItem } from './NotificationItem'; import { Notification } from '@/types/notifications'; import { Skeleton } from '@/components/ui/skeleton'; @@ -12,34 +13,68 @@ interface NotificationListProps { onMarkAsRead?: (id: string) => void; } -const groupNotificationsByDate = ( - notifications: Notification[] -): Record => { - const groups: Record = {}; +const ACTIVE_RETENTION_DAYS = 30; + +const isArchivedNotification = (notification: Notification): boolean => { + const archivedFlag = Boolean( + notification.data.archivedBy || + notification.data['archived'] || + notification.data['isArchived'] + ); + + if (archivedFlag) { + return true; + } + + const cutoffDate = subDays(new Date(), ACTIVE_RETENTION_DAYS); + return isBefore(new Date(notification.createdAt), cutoffDate); +}; + +const groupNotifications = (notifications: Notification[]) => { + const groups = { + new: [] as Notification[], + earlier: [] as Notification[], + archived: [] as Notification[], + }; notifications.forEach(notification => { - const date = new Date(notification.createdAt); - let groupKey: string; - - if (isToday(date)) { - groupKey = 'Today'; - } else if (isYesterday(date)) { - groupKey = 'Yesterday'; - } else if (isThisWeek(date)) { - groupKey = format(date, 'EEEE'); // Day name - } else { - groupKey = format(date, 'MMMM d, yyyy'); // Full date + if (isArchivedNotification(notification)) { + groups.archived.push(notification); + return; } - if (!groups[groupKey]) { - groups[groupKey] = []; + if (!notification.read) { + groups.new.push(notification); + return; } - groups[groupKey].push(notification); + + groups.earlier.push(notification); }); return groups; }; +const SECTION_CONFIG = [ + { + key: 'new', + title: 'New', + emptyTitle: 'No new notifications', + emptyDescription: 'You are all caught up for now.', + }, + { + key: 'earlier', + title: 'Earlier', + emptyTitle: 'No earlier notifications', + emptyDescription: 'Read notifications from this cycle will appear here.', + }, + { + key: 'archived', + title: 'Archived', + emptyTitle: 'No archived notifications', + emptyDescription: 'Older or dismissed notifications will collect here.', + }, +] as const; + export const NotificationList = ({ notifications, loading = false, @@ -56,47 +91,70 @@ export const NotificationList = ({ ); } - if (notifications.length === 0) { - return ( -
-

No notifications

-

- You're all caught up! New notifications will appear here. -

-
- ); - } - - const groupedNotifications = groupNotificationsByDate(notifications); + const groupedNotifications = groupNotifications(notifications); return ( -
- {Object.entries(groupedNotifications).map(([date, dateNotifications]) => ( -
-
-

- {date} -

-
-
- {dateNotifications.map(notification => ( - { - if (onMarkAsRead) { - onMarkAsRead(notification.id); - } - if (onNotificationClick) { - onNotificationClick(notification); - } - }} - showUnreadIndicator={true} - /> - ))} -
-
- ))} +
+ {SECTION_CONFIG.map(section => { + const sectionItems = groupedNotifications[section.key]; + + return ( +
+
+

+ {section.title} +

+
+ + {sectionItems.length === 0 ? ( +
+

+ {section.emptyTitle} +

+

+ {section.emptyDescription} +

+
+ ) : ( + +
+ {sectionItems.map(notification => ( + + { + if (onMarkAsRead) { + onMarkAsRead(notification.id); + } + if (onNotificationClick) { + onNotificationClick(notification); + } + }} + showUnreadIndicator={true} + disableNavigation={true} + /> + + ))} +
+
+ )} +
+ ); + })}
); }; diff --git a/components/providers/notification-provider.tsx b/components/providers/notification-provider.tsx new file mode 100644 index 00000000..bea01341 --- /dev/null +++ b/components/providers/notification-provider.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { createContext, useContext, useMemo, type ReactNode } from 'react'; +import { authClient } from '@/lib/auth-client'; +import { + useNotifications, + type UseNotificationsReturn, +} from '@/hooks/useNotifications'; +import { useNotificationPolling } from '@/hooks/use-notification-polling'; + +const NotificationContext = createContext(null); + +export function NotificationProvider({ children }: { children: ReactNode }) { + const { data: session } = authClient.useSession(); + const userId = session?.user?.id; + + const notificationsHook = useNotifications({ + userId, + limit: 50, + autoFetch: Boolean(userId), + }); + + useNotificationPolling(notificationsHook, { + interval: 30000, + enabled: Boolean(userId), + }); + + const value = useMemo(() => notificationsHook, [notificationsHook]); + + return ( + + {children} + + ); +} + +export function useNotificationCenter(): UseNotificationsReturn { + const context = useContext(NotificationContext); + if (!context) { + throw new Error( + 'useNotificationCenter must be used within a NotificationProvider' + ); + } + return context; +} diff --git a/hooks/use-notification-polling.ts b/hooks/use-notification-polling.ts index 210c1294..68ac2052 100644 --- a/hooks/use-notification-polling.ts +++ b/hooks/use-notification-polling.ts @@ -6,15 +6,11 @@ interface UseNotificationPollingOptions { enabled?: boolean; } -/** - * Hook to poll for new notifications at a specified interval - * Only polls when component is mounted and enabled is true - */ export const useNotificationPolling = ( notificationsHook: UseNotificationsReturn, options: UseNotificationPollingOptions = {} ): void => { - const { interval = 900000000000000, enabled = false } = options; + const { interval = 30000, enabled = false } = options; const intervalRef = useRef | null>(null); const { refetch } = notificationsHook; @@ -23,11 +19,8 @@ export const useNotificationPolling = ( return; } - // Poll for new notifications at specified interval intervalRef.current = setInterval(() => { - refetch().catch(() => { - // Silently fail polling errors to avoid disrupting UX - }); + refetch().catch(() => {}); }, interval); return () => { diff --git a/hooks/useNotifications.ts b/hooks/useNotifications.ts index e9eb1812..8542a355 100644 --- a/hooks/useNotifications.ts +++ b/hooks/useNotifications.ts @@ -4,6 +4,7 @@ import { getNotifications } from '@/lib/api/notifications'; import { Notification } from '@/types/notifications'; interface UseNotificationsOptions { + userId?: string; page?: number; limit?: number; autoFetch?: boolean; @@ -28,14 +29,16 @@ export interface UseNotificationsReturn { export function useNotifications( input?: string | UseNotificationsOptions ): UseNotificationsReturn { - // Handle overloaded arguments - const userId = typeof input === 'string' ? input : undefined; + const userId = + typeof input === 'string' ? input : (input?.userId as string | undefined); const options = typeof input === 'object' ? input : {}; const { page: initialPage = 1, limit = 10, autoFetch = true } = options; + const shouldConnectSocket = Boolean(userId); const { socket, isConnected } = useSocket({ namespace: '/notifications', userId, + autoConnect: shouldConnectSocket, }); const [notifications, setNotifications] = useState([]); @@ -45,7 +48,6 @@ export function useNotifications( const [total, setTotal] = useState(0); const [currentPage, setCurrentPage] = useState(initialPage); - // Fetch notifications with pagination const fetchNotifications = useCallback(async () => { try { setLoading(true); @@ -53,7 +55,6 @@ export function useNotifications( const response = await getNotifications(currentPage, limit); if (response && Array.isArray(response.notifications)) { - // Sort notifications by createdAt desc to ensure correct order const sorted = [...response.notifications].sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() @@ -71,27 +72,33 @@ export function useNotifications( } }, [currentPage, limit]); - // Initial fetch useEffect(() => { if (autoFetch) { fetchNotifications(); } }, [fetchNotifications, autoFetch]); - // Request initial unread count when socket connects + useEffect(() => { + if (!userId) { + setNotifications([]); + setUnreadCount(0); + setTotal(0); + setError(null); + setLoading(false); + } + }, [userId]); + useEffect(() => { if (socket && isConnected) { socket.emit('get-unread-count'); } }, [socket, isConnected]); - // Set up event listeners useEffect(() => { if (!socket) { return; } - // Listen for new notifications const handleNotification = (notification: any) => { const normalizedNotification: Notification = { ...notification, @@ -103,15 +110,11 @@ export function useNotifications( }; setNotifications(prev => { - // Avoid duplicates const exists = prev.some(n => n.id === normalizedNotification.id); if (exists) { return prev; } - // Add new notification and resort - // Note: For pagination, real-time updates might be tricky. - // We typically add it to the top if we are on page 1. if (currentPage === 1) { return [normalizedNotification, ...prev]; } @@ -120,12 +123,10 @@ export function useNotifications( setUnreadCount(prev => prev + 1); }; - // Listen for unread count updates const handleUnreadCount = (data: { count: number }) => { setUnreadCount(data.count); }; - // Listen for notification updates const handleNotificationUpdated = (data: any) => { const id = data.notificationId || data.id || data._id; if (id) { @@ -135,7 +136,6 @@ export function useNotifications( } }; - // Listen for all notifications read const handleAllRead = () => { setNotifications(prev => prev.map(notif => ({ ...notif, read: true }))); setUnreadCount(0); @@ -161,10 +161,20 @@ export function useNotifications( }, [socket, currentPage]); const markAsRead = (notificationId: string) => { + const didChangeUnread = notifications.some( + notification => notification.id === notificationId && !notification.read + ); setNotifications(prev => - prev.map(n => (n.id === notificationId ? { ...n, read: true } : n)) + prev.map(n => { + if (n.id === notificationId) { + return { ...n, read: true }; + } + return n; + }) ); - setUnreadCount(prev => Math.max(0, prev - 1)); + if (didChangeUnread) { + setUnreadCount(prev => Math.max(0, prev - 1)); + } if (socket && isConnected) { socket.emit('mark-read', { notificationId }); @@ -172,11 +182,24 @@ export function useNotifications( }; const markNotificationAsRead = async (ids: string[]) => { - // Optimistic + const unreadNotificationIds = new Set( + notifications.filter(notification => !notification.read).map(n => n.id) + ); + const changedUnreadCount = ids.filter(id => + unreadNotificationIds.has(id) + ).length; + setNotifications(prev => - prev.map(n => (ids.includes(n.id) ? { ...n, read: true } : n)) + prev.map(n => { + if (ids.includes(n.id)) { + return { ...n, read: true }; + } + return n; + }) ); - // Note: unread count update is approximate here, ideally we wait for socket update + if (changedUnreadCount > 0) { + setUnreadCount(prev => Math.max(0, prev - changedUnreadCount)); + } if (socket && isConnected) { ids.forEach(id => socket.emit('mark-read', { notificationId: id }));