From 335e37e1d3ebae5f418f89412df3ed770c1cbf22 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:48:16 +0530 Subject: [PATCH] feat(notifications): enhance notification handling with total counts and improved filtering - Added totalCount to comments and status notifications for better tracking. - Updated NotificationsDropdown to support new filters and improved loading behavior. - Refactored notification display logic to accommodate the new structure, enhancing user experience. --- .../layout/providers/LayoutDataProvider.tsx | 2 + .../ui/sidebar/NotificationsDropdown.tsx | 188 ++++++++++++------ surfsense_web/hooks/use-inbox.ts | 5 + 3 files changed, 130 insertions(+), 65 deletions(-) diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index bac179816..a70e8f59e 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -727,6 +727,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider comments: { items: commentsInbox.inboxItems, unreadCount: commentsInbox.unreadCount, + totalCount: commentsInbox.totalCount, loading: commentsInbox.loading, loadingMore: commentsInbox.loadingMore, hasMore: commentsInbox.hasMore, @@ -737,6 +738,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider status: { items: statusInbox.inboxItems, unreadCount: statusInbox.unreadCount, + totalCount: statusInbox.totalCount, loading: statusInbox.loading, loadingMore: statusInbox.loadingMore, hasMore: statusInbox.hasMore, diff --git a/surfsense_web/components/layout/ui/sidebar/NotificationsDropdown.tsx b/surfsense_web/components/layout/ui/sidebar/NotificationsDropdown.tsx index e432e077a..3ada5de46 100644 --- a/surfsense_web/components/layout/ui/sidebar/NotificationsDropdown.tsx +++ b/surfsense_web/components/layout/ui/sidebar/NotificationsDropdown.tsx @@ -3,7 +3,7 @@ import { useAtom } from "jotai"; import { Bell } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; @@ -21,6 +21,7 @@ import { cn } from "@/lib/utils"; export interface NotificationsDataSource { items: InboxItem[]; unreadCount: number; + totalCount?: number; loading: boolean; loadingMore?: boolean; hasMore?: boolean; @@ -40,7 +41,7 @@ interface NotificationsDropdownProps { onCloseMobileSidebar?: () => void; } -type NotificationFilter = "mentions" | "status" | null; +type NotificationFilter = "all" | "mentions" | "unread"; function formatNotificationCount(count: number): string { if (count <= 999) { @@ -80,37 +81,84 @@ export function NotificationsDropdown({ const router = useRouter(); const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom); const [open, setOpen] = useState(false); - const [activeFilter, setActiveFilter] = useState(null); + const [activeFilter, setActiveFilter] = useState("all"); const [markingAsReadId, setMarkingAsReadId] = useState(null); const [markingAllAsRead, setMarkingAllAsRead] = useState(false); + const scrollContainerRef = useRef(null); + const loadMoreTriggerRef = useRef(null); const unreadLabel = formatNotificationCount(notifications.totalUnreadCount); + const allCount = + (notifications.comments.totalCount ?? 0) + (notifications.status.totalCount ?? 0); + const mentionsCount = notifications.comments.totalCount ?? notifications.comments.items.length; const visibleUnreadCount = activeFilter === "mentions" ? notifications.comments.unreadCount - : activeFilter === "status" - ? notifications.status.unreadCount - : notifications.totalUnreadCount; - const visibleUnreadLabel = formatNotificationCount(visibleUnreadCount); + : notifications.totalUnreadCount; const isLoading = activeFilter === "mentions" ? notifications.comments.loading - : activeFilter === "status" - ? notifications.status.loading - : notifications.comments.loading || notifications.status.loading; + : notifications.comments.loading || notifications.status.loading; + const isLoadingMore = + activeFilter === "mentions" + ? !!notifications.comments.loadingMore + : !!notifications.comments.loadingMore || !!notifications.status.loadingMore; + const hasMore = + activeFilter === "mentions" + ? !!notifications.comments.hasMore + : !!notifications.comments.hasMore || !!notifications.status.hasMore; const items = useMemo(() => { const sourceItems = activeFilter === "mentions" ? notifications.comments.items - : activeFilter === "status" - ? notifications.status.items - : [...notifications.comments.items, ...notifications.status.items]; + : [...notifications.comments.items, ...notifications.status.items]; return sourceItems - .toSorted((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) - .slice(0, 8); + .filter((item) => activeFilter !== "unread" || !item.read) + .toSorted((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); }, [activeFilter, notifications.comments.items, notifications.status.items]); + const loadMoreForActiveFilter = useCallback(() => { + if (isLoadingMore) return; + + if (activeFilter === "mentions") { + if (notifications.comments.hasMore) { + notifications.comments.loadMore?.(); + } + return; + } + + if (notifications.comments.hasMore) { + notifications.comments.loadMore?.(); + } + if (notifications.status.hasMore) { + notifications.status.loadMore?.(); + } + }, [activeFilter, isLoadingMore, notifications.comments, notifications.status]); + + useEffect(() => { + if (!open || isLoading || isLoadingMore || !hasMore) return; + const root = scrollContainerRef.current; + const target = loadMoreTriggerRef.current; + if (!root || !target) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + loadMoreForActiveFilter(); + } + }, + { + root, + rootMargin: "120px", + threshold: 0, + } + ); + + observer.observe(target); + return () => observer.disconnect(); + }, [hasMore, isLoading, isLoadingMore, loadMoreForActiveFilter, open]); + const markItemAsRead = useCallback( async (item: InboxItem) => { if (item.read) return; @@ -179,8 +227,6 @@ export function NotificationsDropdown({ try { if (activeFilter === "mentions") { await notifications.comments.markAllAsRead(); - } else if (activeFilter === "status") { - await notifications.status.markAllAsRead(); } else { await Promise.all([ notifications.comments.markAllAsRead(), @@ -192,26 +238,28 @@ export function NotificationsDropdown({ } }, [activeFilter, markingAllAsRead, notifications, visibleUnreadCount]); - const handleFilterClick = useCallback((filter: Exclude) => { - setActiveFilter((current) => (current === filter ? null : filter)); - }, []); - const emptyStateCopy = activeFilter === "mentions" ? { title: "No mentions", description: "Mentions and replies will appear here.", } - : activeFilter === "status" + : activeFilter === "unread" ? { - title: "No status updates", - description: "Connector and document updates will appear here.", + title: "No unread notifications", + description: "New mentions and status updates will appear here.", } : { title: "No notifications", description: "Mentions, replies, and status updates will appear here.", }; + const tabs: { value: NotificationFilter; label: string; count: number }[] = [ + { value: "all", label: "All", count: allCount }, + { value: "mentions", label: "Mentions", count: mentionsCount }, + { value: "unread", label: "Unread", count: notifications.totalUnreadCount }, + ]; + return ( @@ -253,9 +301,6 @@ export function NotificationsDropdown({

Notifications

-

- {visibleUnreadCount > 0 ? `${visibleUnreadLabel} unread` : "You're all caught up"} -

-
- - +
+ {tabs.map((tab) => { + const isActive = activeFilter === tab.value; + return ( + + ); + })}
-
+
{isLoading ? (
{[82, 64, 74].map((width) => ( @@ -364,11 +401,32 @@ export function NotificationsDropdown({ ); })} + {hasMore ? ( +
+ {isLoadingMore ? : null} +
+ ) : null}
) : (

{emptyStateCopy.title}

{emptyStateCopy.description}

+ {hasMore ? ( + + ) : null}
)}
diff --git a/surfsense_web/hooks/use-inbox.ts b/surfsense_web/hooks/use-inbox.ts index 1caf73b1d..eb33f84f7 100644 --- a/surfsense_web/hooks/use-inbox.ts +++ b/surfsense_web/hooks/use-inbox.ts @@ -54,6 +54,7 @@ export function useInbox( const [hasMore, setHasMore] = useState(false); const [error, setError] = useState(null); const [unreadCount, setUnreadCount] = useState(0); + const [totalCount, setTotalCount] = useState(0); const initialLoadDoneRef = useRef(false); const olderUnreadOffsetRef = useRef(null); @@ -71,6 +72,7 @@ export function useInbox( setLoading(true); setInboxItems([]); setHasMore(false); + setTotalCount(0); initialLoadDoneRef.current = false; olderUnreadOffsetRef.current = null; apiUnreadTotalRef.current = 0; @@ -97,6 +99,7 @@ export function useInbox( if (cancelled) return; setInboxItems(notificationsResponse.items); + setTotalCount(notificationsResponse.total); setHasMore(notificationsResponse.has_more); setUnreadCount(unreadResponse.total_unread); apiUnreadTotalRef.current = unreadResponse.total_unread; @@ -222,6 +225,7 @@ export function useInbox( const deduped = newItems.filter((d) => !existingIds.has(d.id)); return [...prev, ...deduped]; }); + setTotalCount(response.total); setHasMore(response.has_more); } catch (err) { console.error(`[useInbox:${category}] Load more failed:`, err); @@ -299,6 +303,7 @@ export function useInbox( return { inboxItems, unreadCount, + totalCount, markAsRead, markAllAsRead, loading,