From eb775fea115aa33190b04fd41badbaa2304d63eb Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 6 Mar 2026 19:35:21 +0530 Subject: [PATCH] refactor: consolidate inbox data handling in LayoutDataProvider and related components, streamlining state management and improving performance by using a single data source for inbox items --- .../layout/providers/LayoutDataProvider.tsx | 84 +-- .../layout/ui/shell/LayoutShell.tsx | 34 +- .../layout/ui/sidebar/InboxSidebar.tsx | 275 ++------ surfsense_web/hooks/use-inbox.ts | 630 +++++++----------- surfsense_web/lib/query-client/cache-keys.ts | 4 - 5 files changed, 350 insertions(+), 677 deletions(-) diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 9eca67880..fc971e200 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -121,34 +121,19 @@ export function LayoutDataProvider({ // Search space dialog state const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false); - // Inbox hooks - separate data sources for mentions and status tabs - // This ensures each tab has independent pagination and data loading + // Single inbox hook - API-first with Electric real-time deltas const userId = user?.id ? String(user.id) : null; const { - inboxItems: mentionItems, - unreadCount: mentionUnreadCount, - loading: mentionLoading, - loadingMore: mentionLoadingMore, - hasMore: mentionHasMore, - loadMore: mentionLoadMore, - markAsRead: markMentionAsRead, - markAllAsRead: markAllMentionsAsRead, - } = useInbox(userId, Number(searchSpaceId) || null, "new_mention"); - - const { - inboxItems: statusItems, - unreadCount: allUnreadCount, - loading: statusLoading, - loadingMore: statusLoadingMore, - hasMore: statusHasMore, - loadMore: statusLoadMore, - markAsRead: markStatusAsRead, - markAllAsRead: markAllStatusAsRead, - } = useInbox(userId, Number(searchSpaceId) || null, null); - - const totalUnreadCount = allUnreadCount; - const statusOnlyUnreadCount = Math.max(0, allUnreadCount - mentionUnreadCount); + inboxItems, + unreadCount: totalUnreadCount, + loading: inboxLoading, + loadingMore: inboxLoadingMore, + hasMore: inboxHasMore, + loadMore: inboxLoadMore, + markAsRead, + markAllAsRead, + } = useInbox(userId, Number(searchSpaceId) || null); // Track seen notification IDs to detect new page_limit_exceeded notifications const seenPageLimitNotifications = useRef>(new Set()); @@ -156,14 +141,12 @@ export function LayoutDataProvider({ // Effect to show toast for new page_limit_exceeded notifications useEffect(() => { - if (statusLoading) return; + if (inboxLoading) return; - // Get page_limit_exceeded notifications - const pageLimitNotifications = statusItems.filter( + const pageLimitNotifications = inboxItems.filter( (item) => item.type === "page_limit_exceeded" ); - // On initial load, just mark all as seen without showing toasts if (isInitialLoad.current) { for (const notification of pageLimitNotifications) { seenPageLimitNotifications.current.add(notification.id); @@ -172,16 +155,13 @@ export function LayoutDataProvider({ return; } - // Find new notifications (not yet seen) const newNotifications = pageLimitNotifications.filter( (notification) => !seenPageLimitNotifications.current.has(notification.id) ); - // Show toast for each new page_limit_exceeded notification for (const notification of newNotifications) { seenPageLimitNotifications.current.add(notification.id); - // Extract metadata for navigation const actionUrl = isPageLimitExceededMetadata(notification.metadata) ? notification.metadata.action_url : `/dashboard/${searchSpaceId}/more-pages`; @@ -196,24 +176,8 @@ export function LayoutDataProvider({ }, }); } - }, [statusItems, statusLoading, searchSpaceId, router]); + }, [inboxItems, inboxLoading, searchSpaceId, router]); - // Unified mark as read that delegates to the correct hook - const markAsRead = useCallback( - async (id: number) => { - // Try both - one will succeed based on which list has the item - const mentionResult = await markMentionAsRead(id); - if (mentionResult) return true; - return markStatusAsRead(id); - }, - [markMentionAsRead, markStatusAsRead] - ); - - // Mark all as read for both types - const markAllAsRead = useCallback(async () => { - await Promise.all([markAllMentionsAsRead(), markAllStatusAsRead()]); - return true; - }, [markAllMentionsAsRead, markAllStatusAsRead]); // Delete dialogs state const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false); @@ -643,24 +607,12 @@ export function LayoutDataProvider({ inbox={{ isOpen: isInboxSidebarOpen, onOpenChange: setIsInboxSidebarOpen, - // Separate data sources for each tab - mentions: { - items: mentionItems, - unreadCount: mentionUnreadCount, - loading: mentionLoading, - loadingMore: mentionLoadingMore, - hasMore: mentionHasMore, - loadMore: mentionLoadMore, - }, - status: { - items: statusItems, - unreadCount: statusOnlyUnreadCount, - loading: statusLoading, - loadingMore: statusLoadingMore, - hasMore: statusHasMore, - loadMore: statusLoadMore, - }, + items: inboxItems, totalUnreadCount, + loading: inboxLoading, + loadingMore: inboxLoadingMore, + hasMore: inboxHasMore, + loadMore: inboxLoadMore, markAsRead, markAllAsRead, isDocked: isInboxDocked, diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index ce310816c..e84721bfd 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -20,26 +20,16 @@ import { Sidebar, } from "../sidebar"; -// Tab-specific data source props -interface TabDataSource { +// Inbox-related props — single data source, tab split done in InboxSidebar +interface InboxProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; items: InboxItem[]; - unreadCount: number; + totalUnreadCount: number; loading: boolean; loadingMore?: boolean; hasMore?: boolean; loadMore?: () => void; -} - -// Inbox-related props with separate data sources per tab -interface InboxProps { - isOpen: boolean; - onOpenChange: (open: boolean) => void; - /** Mentions tab data source with independent pagination */ - mentions: TabDataSource; - /** Status tab data source with independent pagination */ - status: TabDataSource; - /** Combined unread count for nav badge */ - totalUnreadCount: number; markAsRead: (id: number) => Promise; markAllAsRead: () => Promise; /** Whether the inbox is docked (permanent) */ @@ -306,9 +296,12 @@ export function LayoutShell({ = { GITHUB_CONNECTOR: "GitHub", @@ -139,40 +130,34 @@ function getConnectorTypeDisplayName(connectorType: string): string { type InboxTab = "comments" | "status"; type InboxFilter = "all" | "unread" | "errors"; -// Tab-specific data source with independent pagination -interface TabDataSource { - items: InboxItem[]; - unreadCount: number; - loading: boolean; - loadingMore?: boolean; - hasMore?: boolean; - loadMore?: () => void; -} +const COMMENT_TYPES = new Set(["new_mention", "comment_reply"]); +const STATUS_TYPES = new Set(["connector_indexing", "document_processing", "page_limit_exceeded", "connector_deletion"]); interface InboxSidebarProps { open: boolean; onOpenChange: (open: boolean) => void; - /** Mentions tab data source with independent pagination */ - mentions: TabDataSource; - /** Status tab data source with independent pagination */ - status: TabDataSource; - /** Combined unread count for mark all as read */ + items: InboxItem[]; totalUnreadCount: number; + loading: boolean; + loadingMore?: boolean; + hasMore?: boolean; + loadMore?: () => void; markAsRead: (id: number) => Promise; markAllAsRead: () => Promise; onCloseMobileSidebar?: () => void; - /** Whether the inbox is docked (permanent) or floating */ isDocked?: boolean; - /** Callback to toggle docked state */ onDockedChange?: (docked: boolean) => void; } export function InboxSidebar({ open, onOpenChange, - mentions, - status, + items, totalUnreadCount, + loading, + loadingMore: loadingMoreProp = false, + hasMore: hasMoreProp = false, + loadMore, markAsRead, markAllAsRead, onCloseMobileSidebar, @@ -185,9 +170,7 @@ export function InboxSidebar({ const isMobile = !useMediaQuery("(min-width: 640px)"); const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null; - // Comments collapsed state (desktop only, when docked) const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom); - // Target comment for navigation - also ensures comments panel is visible const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom); const [searchQuery, setSearchQuery] = useState(""); @@ -197,9 +180,7 @@ export function InboxSidebar({ const [activeFilter, setActiveFilter] = useState("all"); const [selectedSource, setSelectedSource] = useState(null); const [mounted, setMounted] = useState(false); - // Dropdown state for filter menu (desktop only) const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null); - // Scroll shadow state for connector list const [connectorScrollPos, setConnectorScrollPos] = useState<"top" | "middle" | "bottom">("top"); const handleConnectorScroll = useCallback((e: React.UIEvent) => { const el = e.currentTarget; @@ -207,15 +188,12 @@ export function InboxSidebar({ const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; setConnectorScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); }, []); - // Drawer state for filter menu (mobile only) const [filterDrawerOpen, setFilterDrawerOpen] = useState(false); const [markingAsReadId, setMarkingAsReadId] = useState(null); - // Prefetch trigger ref - placed on item near the end const prefetchTriggerRef = useRef(null); - // Server-side search query (enabled only when user is typing a search) - // Determines which notification types to search based on active tab + // Server-side search query const searchTypeFilter = activeTab === "comments" ? ("new_mention" as const) : undefined; const { data: searchResponse, isLoading: isSearchLoading } = useQuery({ queryKey: cacheKeys.notifications.search(searchSpaceId, debouncedSearch.trim(), activeTab), @@ -228,7 +206,7 @@ export function InboxSidebar({ limit: 50, }, }), - staleTime: 30 * 1000, // 30 seconds (search results don't need to be super fresh) + staleTime: 30 * 1000, enabled: isSearchMode && open, }); @@ -246,53 +224,43 @@ export function InboxSidebar({ return () => document.removeEventListener("keydown", handleEscape); }, [open, onOpenChange]); - // Only lock body scroll on mobile when inbox is open useEffect(() => { if (!open || !isMobile) return; - - // Store original overflow to restore on cleanup const originalOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; - return () => { document.body.style.overflow = originalOverflow; }; }, [open, isMobile]); - // Reset source filter when switching away from status tab useEffect(() => { if (activeTab !== "status") { setSelectedSource(null); } }, [activeTab]); - // Each tab uses its own data source for independent pagination - // Comments tab: uses mentions data source (fetches only mention/reply types from server) - const commentsItems = mentions.items; - - // Status tab: filters status data source (fetches all types) to status-specific types - const statusItems = useMemo( - () => - status.items.filter( - (item) => - item.type === "connector_indexing" || - item.type === "document_processing" || - item.type === "page_limit_exceeded" || - item.type === "connector_deletion" - ), - [status.items] + // Split items by tab type (client-side from single data source) + const commentsItems = useMemo( + () => items.filter((item) => COMMENT_TYPES.has(item.type)), + [items] ); - // Pagination switches based on active tab - const loading = activeTab === "comments" ? mentions.loading : status.loading; - const loadingMore = - activeTab === "comments" ? (mentions.loadingMore ?? false) : (status.loadingMore ?? false); - const hasMore = - activeTab === "comments" ? (mentions.hasMore ?? false) : (status.hasMore ?? false); - const loadMore = activeTab === "comments" ? mentions.loadMore : status.loadMore; + const statusItems = useMemo( + () => items.filter((item) => STATUS_TYPES.has(item.type)), + [items] + ); - // Fetch ALL source types from the backend so the filter shows every connector/document - // type the user has notifications for, regardless of how many items are loaded via pagination. + // Derive unread counts per tab from the items array + const unreadCommentsCount = useMemo( + () => commentsItems.filter((item) => !item.read).length, + [commentsItems] + ); + const unreadStatusCount = useMemo( + () => statusItems.filter((item) => !item.read).length, + [statusItems] + ); + + // Fetch source types for the status tab filter const { data: sourceTypesData } = useQuery({ queryKey: cacheKeys.notifications.sourceTypes(searchSpaceId), queryFn: () => notificationsApiService.getSourceTypes(searchSpaceId ?? undefined), @@ -314,45 +282,7 @@ export function InboxSidebar({ })); }, [sourceTypesData]); - // Get items for current tab - const displayItems = activeTab === "comments" ? commentsItems : statusItems; - - // When a non-default filter (unread/errors) is active on the status tab, - // fetch matching items from the API so older items beyond the Electric - // sync window are included. - const isActiveFilterMode = activeTab === "status" && (activeFilter === "unread" || activeFilter === "errors"); - const { data: activeFilterResponse, isLoading: isActiveFilterLoading } = useQuery({ - queryKey: cacheKeys.notifications.byFilter(searchSpaceId, activeFilter), - queryFn: () => - notificationsApiService.getNotifications({ - queryParams: { - search_space_id: searchSpaceId ?? undefined, - filter: activeFilter as "unread" | "errors", - limit: 100, - }, - }), - staleTime: 30 * 1000, - enabled: isActiveFilterMode && open && !isSearchMode, - }); - - // When a source filter is active, fetch matching items from the API so - // older items (outside the Electric sync window) are included. - const isSourceFilterMode = activeTab === "status" && !!selectedSource; - const { data: sourceFilterResponse, isLoading: isSourceFilterLoading } = useQuery({ - queryKey: cacheKeys.notifications.bySourceType(searchSpaceId, selectedSource ?? ""), - queryFn: () => - notificationsApiService.getNotifications({ - queryParams: { - search_space_id: searchSpaceId ?? undefined, - source_type: selectedSource ?? undefined, - limit: 50, - }, - }), - staleTime: 30 * 1000, - enabled: isSourceFilterMode && open && !isSearchMode, - }); - - // Client-side matcher: checks if an item matches the active source filter + // Client-side filter: source type const matchesSourceFilter = useCallback( (item: InboxItem): boolean => { if (!selectedSource) return true; @@ -377,7 +307,7 @@ export function InboxSidebar({ [selectedSource] ); - // Client-side matcher: checks if an item matches the active filter (unread/errors) + // Client-side filter: unread / errors const matchesActiveFilter = useCallback( (item: InboxItem): boolean => { if (activeFilter === "unread") return !item.read; @@ -391,93 +321,56 @@ export function InboxSidebar({ [activeFilter] ); - // Filter items based on filter type, connector filter, and search mode - // Four data paths: - // 1. Search mode → server-side search results (client-side filter applied after) - // 2. Active filter mode (unread/errors) → API results merged with real-time Electric items - // 3. Source filter mode → API results merged with real-time Electric items - // 4. Default → Electric real-time items (fast, local) + // Two data paths: search mode (API) or default (client-side filtered) const filteredItems = useMemo(() => { - let items: InboxItem[]; + let tabItems: InboxItem[]; if (isSearchMode) { - items = searchResponse?.items ?? []; + tabItems = searchResponse?.items ?? []; if (activeTab === "status") { - items = items.filter( - (item) => - item.type === "connector_indexing" || - item.type === "document_processing" || - item.type === "page_limit_exceeded" || - item.type === "connector_deletion" - ); + tabItems = tabItems.filter((item) => STATUS_TYPES.has(item.type)); + } else { + tabItems = tabItems.filter((item) => COMMENT_TYPES.has(item.type)); } - if (activeFilter === "unread") { - items = items.filter((item) => !item.read); - } else if (activeFilter === "errors") { - items = items.filter(matchesActiveFilter); - } - } else if (isActiveFilterMode) { - const apiItems = activeFilterResponse?.items ?? []; - const realtimeMatching = statusItems.filter(matchesActiveFilter); - const seen = new Set(apiItems.map((i) => i.id)); - const merged = [...apiItems]; - for (const item of realtimeMatching) { - if (!seen.has(item.id)) { - merged.push(item); - } - } - items = merged.sort( - (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() - ); - } else if (isSourceFilterMode) { - const apiItems = sourceFilterResponse?.items ?? []; - const realtimeMatching = statusItems.filter(matchesSourceFilter); - const seen = new Set(apiItems.map((i) => i.id)); - const merged = [...apiItems]; - for (const item of realtimeMatching) { - if (!seen.has(item.id)) { - merged.push(item); - } - } - items = merged.sort( - (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() - ); } else { - items = displayItems; + tabItems = activeTab === "comments" ? commentsItems : statusItems; } - return items; + // Apply filters + let result = tabItems; + if (activeFilter !== "all") { + result = result.filter(matchesActiveFilter); + } + if (activeTab === "status" && selectedSource) { + result = result.filter(matchesSourceFilter); + } + + return result; }, [ - displayItems, - statusItems, - searchResponse, - sourceFilterResponse, - activeFilterResponse, isSearchMode, - isActiveFilterMode, - isSourceFilterMode, - matchesSourceFilter, - matchesActiveFilter, - activeFilter, + searchResponse, activeTab, + commentsItems, + statusItems, + activeFilter, + selectedSource, + matchesActiveFilter, + matchesSourceFilter, ]); - // Intersection Observer for infinite scroll with prefetching - // Re-runs when active tab changes so each tab gets its own pagination - // Disabled during server-side search (search results are not paginated via infinite scroll) + // Infinite scroll useEffect(() => { - if (!loadMore || !hasMore || loadingMore || !open || isSearchMode) return; + if (!loadMore || !hasMoreProp || loadingMoreProp || !open || isSearchMode) return; const observer = new IntersectionObserver( (entries) => { - // When trigger element is visible, load more if (entries[0]?.isIntersecting) { loadMore(); } }, { - root: null, // viewport - rootMargin: "100px", // Start loading 100px before visible + root: null, + rootMargin: "100px", threshold: 0, } ); @@ -487,11 +380,7 @@ export function InboxSidebar({ } return () => observer.disconnect(); - }, [loadMore, hasMore, loadingMore, open, isSearchMode, activeTab]); - - // Unread counts from server-side accurate totals (passed via props) - const unreadCommentsCount = mentions.unreadCount; - const unreadStatusCount = status.unreadCount; + }, [loadMore, hasMoreProp, loadingMoreProp, open, isSearchMode]); const handleItemClick = useCallback( async (item: InboxItem) => { @@ -538,7 +427,6 @@ export function InboxSidebar({ } } } else if (item.type === "page_limit_exceeded") { - // Navigate to the upgrade/more-pages page if (isPageLimitExceededMetadata(item.metadata)) { const actionUrl = item.metadata.action_url; if (actionUrl) { @@ -580,7 +468,6 @@ export function InboxSidebar({ }; const getStatusIcon = (item: InboxItem) => { - // For mentions and comment replies, show the author's avatar if (item.type === "new_mention" || item.type === "comment_reply") { const metadata = item.type === "new_mention" @@ -612,7 +499,6 @@ export function InboxSidebar({ ); } - // For page limit exceeded, show a warning icon with amber/orange color if (item.type === "page_limit_exceeded") { return (
@@ -621,8 +507,6 @@ export function InboxSidebar({ ); } - // For status items (connector/document), show status icons - // Safely access status from metadata const metadata = item.metadata as Record; const status = typeof metadata?.status === "string" ? metadata.status : undefined; @@ -669,13 +553,13 @@ export function InboxSidebar({ if (!mounted) return null; - // Shared content component for both docked and floating modes + const isLoading = isSearchMode ? isSearchLoading : loading; + const inboxContent = ( <>
- {/* Back button - mobile only */} {isMobile && (
- {/* Mobile: Button that opens bottom drawer */} {isMobile ? ( <>