diff --git a/surfsense_backend/app/routes/notifications_routes.py b/surfsense_backend/app/routes/notifications_routes.py index 0eaa50250..1bd186c3e 100644 --- a/surfsense_backend/app/routes/notifications_routes.py +++ b/surfsense_backend/app/routes/notifications_routes.py @@ -23,9 +23,17 @@ SYNC_WINDOW_DAYS = 14 # Valid notification types - must match frontend InboxItemTypeEnum NotificationType = Literal[ - "connector_indexing", "document_processing", "new_mention", "page_limit_exceeded" + "connector_indexing", "connector_deletion", "document_processing", + "new_mention", "comment_reply", "page_limit_exceeded", ] +# Category-to-types mapping for filtering by tab +NotificationCategory = Literal["comments", "status"] +CATEGORY_TYPES: dict[str, tuple[str, ...]] = { + "comments": ("new_mention", "comment_reply"), + "status": ("connector_indexing", "connector_deletion", "document_processing", "page_limit_exceeded"), +} + class NotificationResponse(BaseModel): """Response model for a single notification.""" @@ -165,6 +173,9 @@ async def get_unread_count( type_filter: NotificationType | None = Query( None, alias="type", description="Filter by notification type" ), + category: NotificationCategory | None = Query( + None, description="Filter by category: 'comments' or 'status'" + ), user: User = Depends(current_active_user), session: AsyncSession = Depends(get_async_session), ) -> UnreadCountResponse: @@ -199,6 +210,10 @@ async def get_unread_count( if type_filter: base_filter.append(Notification.type == type_filter) + # Filter by category (maps to multiple types) + if category: + base_filter.append(Notification.type.in_(CATEGORY_TYPES[category])) + # Total unread count (all time) total_query = select(func.count(Notification.id)).where(*base_filter) total_result = await session.execute(total_query) @@ -224,6 +239,9 @@ async def list_notifications( type_filter: NotificationType | None = Query( None, alias="type", description="Filter by notification type" ), + category: NotificationCategory | None = Query( + None, description="Filter by category: 'comments' or 'status'" + ), source_type: str | None = Query( None, description="Filter by source type, e.g. 'connector:GITHUB_CONNECTOR' or 'doctype:FILE'", @@ -273,6 +291,12 @@ async def list_notifications( query = query.where(Notification.type == type_filter) count_query = count_query.where(Notification.type == type_filter) + # Filter by category (maps to multiple types) + if category: + cat_types = CATEGORY_TYPES[category] + query = query.where(Notification.type.in_(cat_types)) + count_query = count_query.where(Notification.type.in_(cat_types)) + # Filter by source type (connector or document type from JSONB metadata) if source_type: if source_type.startswith("connector:"): diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx index 43f0d6e4b..4decbf615 100644 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -4,7 +4,7 @@ import { useAtomValue } from "jotai"; import { AlertTriangle, Cable, Settings } from "lucide-react"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; -import type { FC } from "react"; +import { type FC, useMemo } from "react"; import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; import { globalNewLLMConfigsAtom, @@ -66,11 +66,15 @@ export const ConnectorIndicator: FC = () => { const { data: documentTypeCounts, isFetching: documentTypesLoading } = useAtomValue(documentTypeCountsAtom); - // Fetch notifications to detect indexing failures - const { inboxItems = [] } = useInbox( + // Fetch status notifications to detect indexing failures + const { inboxItems: statusInboxItems = [] } = useInbox( currentUser?.id ?? null, searchSpaceId ? Number(searchSpaceId) : null, - "connector_indexing" + "status" + ); + const inboxItems = useMemo( + () => statusInboxItems.filter((item) => item.type === "connector_indexing"), + [statusInboxItems] ); // Check if YouTube view is active diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index fc971e200..f1caff27e 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -121,19 +121,15 @@ export function LayoutDataProvider({ // Search space dialog state const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false); - // Single inbox hook - API-first with Electric real-time deltas + // Per-tab inbox hooks — each has independent API loading, pagination, + // and Electric live queries. The Electric sync shape is shared (client-level cache). const userId = user?.id ? String(user.id) : null; + const numericSpaceId = Number(searchSpaceId) || null; - const { - inboxItems, - unreadCount: totalUnreadCount, - loading: inboxLoading, - loadingMore: inboxLoadingMore, - hasMore: inboxHasMore, - loadMore: inboxLoadMore, - markAsRead, - markAllAsRead, - } = useInbox(userId, Number(searchSpaceId) || null); + const commentsInbox = useInbox(userId, numericSpaceId, "comments"); + const statusInbox = useInbox(userId, numericSpaceId, "status"); + + const totalUnreadCount = commentsInbox.unreadCount + statusInbox.unreadCount; // Track seen notification IDs to detect new page_limit_exceeded notifications const seenPageLimitNotifications = useRef>(new Set()); @@ -141,9 +137,9 @@ export function LayoutDataProvider({ // Effect to show toast for new page_limit_exceeded notifications useEffect(() => { - if (inboxLoading) return; + if (statusInbox.loading) return; - const pageLimitNotifications = inboxItems.filter( + const pageLimitNotifications = statusInbox.inboxItems.filter( (item) => item.type === "page_limit_exceeded" ); @@ -176,7 +172,7 @@ export function LayoutDataProvider({ }, }); } - }, [inboxItems, inboxLoading, searchSpaceId, router]); + }, [statusInbox.inboxItems, statusInbox.loading, searchSpaceId, router]); // Delete dialogs state @@ -607,14 +603,27 @@ export function LayoutDataProvider({ inbox={{ isOpen: isInboxSidebarOpen, onOpenChange: setIsInboxSidebarOpen, - items: inboxItems, totalUnreadCount, - loading: inboxLoading, - loadingMore: inboxLoadingMore, - hasMore: inboxHasMore, - loadMore: inboxLoadMore, - markAsRead, - markAllAsRead, + comments: { + items: commentsInbox.inboxItems, + unreadCount: commentsInbox.unreadCount, + loading: commentsInbox.loading, + loadingMore: commentsInbox.loadingMore, + hasMore: commentsInbox.hasMore, + loadMore: commentsInbox.loadMore, + markAsRead: commentsInbox.markAsRead, + markAllAsRead: commentsInbox.markAllAsRead, + }, + status: { + items: statusInbox.inboxItems, + unreadCount: statusInbox.unreadCount, + loading: statusInbox.loading, + loadingMore: statusInbox.loadingMore, + hasMore: statusInbox.hasMore, + loadMore: statusInbox.loadMore, + markAsRead: statusInbox.markAsRead, + markAllAsRead: statusInbox.markAllAsRead, + }, isDocked: isInboxDocked, onDockedChange: setIsInboxDocked, }} diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index e84721bfd..55c45d30d 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -20,21 +20,26 @@ import { Sidebar, } from "../sidebar"; -// Inbox-related props — single data source, tab split done in InboxSidebar +// Per-tab data source +interface TabDataSource { + items: InboxItem[]; + unreadCount: number; + loading: boolean; + loadingMore: boolean; + hasMore: boolean; + loadMore: () => void; + markAsRead: (id: number) => Promise; + markAllAsRead: () => Promise; +} + +// Inbox-related props — per-tab data sources with independent loading/pagination interface InboxProps { isOpen: boolean; onOpenChange: (open: boolean) => void; - items: InboxItem[]; totalUnreadCount: number; - loading: boolean; - loadingMore?: boolean; - hasMore?: boolean; - loadMore?: () => void; - markAsRead: (id: number) => Promise; - markAllAsRead: () => Promise; - /** Whether the inbox is docked (permanent) */ + comments: TabDataSource; + status: TabDataSource; isDocked?: boolean; - /** Callback to change docked state */ onDockedChange?: (docked: boolean) => void; } @@ -198,11 +203,9 @@ export function LayoutShell({ setMobileMenuOpen(false)} /> )} @@ -296,14 +299,9 @@ export function LayoutShell({ @@ -322,14 +320,9 @@ export function LayoutShell({ diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index 7d9837761..abb6f4daa 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -130,20 +130,23 @@ function getConnectorTypeDisplayName(connectorType: string): string { type InboxTab = "comments" | "status"; type InboxFilter = "all" | "unread" | "errors"; -const COMMENT_TYPES = new Set(["new_mention", "comment_reply"]); -const STATUS_TYPES = new Set(["connector_indexing", "document_processing", "page_limit_exceeded", "connector_deletion"]); +interface TabDataSource { + items: InboxItem[]; + unreadCount: number; + loading: boolean; + loadingMore: boolean; + hasMore: boolean; + loadMore: () => void; + markAsRead: (id: number) => Promise; + markAllAsRead: () => Promise; +} interface InboxSidebarProps { open: boolean; onOpenChange: (open: boolean) => void; - items: InboxItem[]; + comments: TabDataSource; + status: TabDataSource; totalUnreadCount: number; - loading: boolean; - loadingMore?: boolean; - hasMore?: boolean; - loadMore?: () => void; - markAsRead: (id: number) => Promise; - markAllAsRead: () => Promise; onCloseMobileSidebar?: () => void; isDocked?: boolean; onDockedChange?: (docked: boolean) => void; @@ -152,14 +155,9 @@ interface InboxSidebarProps { export function InboxSidebar({ open, onOpenChange, - items, + comments, + status, totalUnreadCount, - loading, - loadingMore: loadingMoreProp = false, - hasMore: hasMoreProp = false, - loadMore, - markAsRead, - markAllAsRead, onCloseMobileSidebar, isDocked = false, onDockedChange, @@ -239,26 +237,8 @@ export function InboxSidebar({ } }, [activeTab]); - // Split items by tab type (client-side from single data source) - const commentsItems = useMemo( - () => items.filter((item) => COMMENT_TYPES.has(item.type)), - [items] - ); - - const statusItems = useMemo( - () => items.filter((item) => STATUS_TYPES.has(item.type)), - [items] - ); - - // 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] - ); + // Active tab's data source — fully independent loading, pagination, and counts + const activeSource = activeTab === "comments" ? comments : status; // Fetch source types for the status tab filter const { data: sourceTypesData } = useQuery({ @@ -321,22 +301,16 @@ export function InboxSidebar({ [activeFilter] ); - // Two data paths: search mode (API) or default (client-side filtered) + // Two data paths: search mode (API) or default (per-tab data source) const filteredItems = useMemo(() => { let tabItems: InboxItem[]; if (isSearchMode) { tabItems = searchResponse?.items ?? []; - if (activeTab === "status") { - tabItems = tabItems.filter((item) => STATUS_TYPES.has(item.type)); - } else { - tabItems = tabItems.filter((item) => COMMENT_TYPES.has(item.type)); - } } else { - tabItems = activeTab === "comments" ? commentsItems : statusItems; + tabItems = activeSource.items; } - // Apply filters let result = tabItems; if (activeFilter !== "all") { result = result.filter(matchesActiveFilter); @@ -349,23 +323,22 @@ export function InboxSidebar({ }, [ isSearchMode, searchResponse, + activeSource.items, activeTab, - commentsItems, - statusItems, activeFilter, selectedSource, matchesActiveFilter, matchesSourceFilter, ]); - // Infinite scroll + // Infinite scroll — uses active tab's pagination useEffect(() => { - if (!loadMore || !hasMoreProp || loadingMoreProp || !open || isSearchMode) return; + if (!activeSource.hasMore || activeSource.loadingMore || !open || isSearchMode) return; const observer = new IntersectionObserver( (entries) => { if (entries[0]?.isIntersecting) { - loadMore(); + activeSource.loadMore(); } }, { @@ -380,13 +353,13 @@ export function InboxSidebar({ } return () => observer.disconnect(); - }, [loadMore, hasMoreProp, loadingMoreProp, open, isSearchMode]); + }, [activeSource.hasMore, activeSource.loadingMore, activeSource.loadMore, open, isSearchMode]); const handleItemClick = useCallback( async (item: InboxItem) => { if (!item.read) { setMarkingAsReadId(item.id); - await markAsRead(item.id); + await activeSource.markAsRead(item.id); setMarkingAsReadId(null); } @@ -437,12 +410,12 @@ export function InboxSidebar({ } } }, - [markAsRead, router, onOpenChange, onCloseMobileSidebar, setTargetCommentId] + [activeSource.markAsRead, router, onOpenChange, onCloseMobileSidebar, setTargetCommentId] ); const handleMarkAllAsRead = useCallback(async () => { - await markAllAsRead(); - }, [markAllAsRead]); + await Promise.all([comments.markAllAsRead(), status.markAllAsRead()]); + }, [comments.markAllAsRead, status.markAllAsRead]); const handleClearSearch = useCallback(() => { setSearchQuery(""); @@ -553,7 +526,7 @@ export function InboxSidebar({ if (!mounted) return null; - const isLoading = isSearchMode ? isSearchLoading : loading; + const isLoading = isSearchMode ? isSearchLoading : activeSource.loading; const inboxContent = ( <> @@ -925,7 +898,7 @@ export function InboxSidebar({ {t("comments") || "Comments"} - {formatInboxCount(unreadCommentsCount)} + {formatInboxCount(comments.unreadCount)} @@ -937,7 +910,7 @@ export function InboxSidebar({ {t("status") || "Status"} - {formatInboxCount(unreadStatusCount)} + {formatInboxCount(status.unreadCount)} @@ -983,7 +956,7 @@ export function InboxSidebar({ {filteredItems.map((item, index) => { const isMarkingAsRead = markingAsReadId === item.id; const isPrefetchTrigger = - !isSearchMode && hasMoreProp && index === filteredItems.length - 5; + !isSearchMode && activeSource.hasMore && index === filteredItems.length - 5; return (
); })} - {!isSearchMode && filteredItems.length < 5 && hasMoreProp && ( + {!isSearchMode && filteredItems.length < 5 && activeSource.hasMore && (
)} - {loadingMoreProp && + {activeSource.loadingMore && (activeTab === "comments" ? [80, 60, 90].map((titleWidth, i) => (
; + /** * Request schema for getting notifications */ @@ -204,6 +210,7 @@ export const getNotificationsRequest = z.object({ queryParams: z.object({ search_space_id: z.number().optional(), type: inboxItemTypeEnum.optional(), + category: notificationCategory.optional(), source_type: z.string().optional(), filter: z.enum(["unread", "errors"]).optional(), before_date: z.string().optional(), diff --git a/surfsense_web/hooks/use-inbox.ts b/surfsense_web/hooks/use-inbox.ts index f0b2250b9..97cadd579 100644 --- a/surfsense_web/hooks/use-inbox.ts +++ b/surfsense_web/hooks/use-inbox.ts @@ -1,17 +1,21 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import type { InboxItem } from "@/contracts/types/inbox.types"; +import type { InboxItem, NotificationCategory } from "@/contracts/types/inbox.types"; import { notificationsApiService } from "@/lib/apis/notifications-api.service"; -import type { SyncHandle } from "@/lib/electric/client"; import { useElectricClient } from "@/lib/electric/context"; -export type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types"; +export type { InboxItem, InboxItemTypeEnum, NotificationCategory } from "@/contracts/types/inbox.types"; const INITIAL_PAGE_SIZE = 50; const SCROLL_PAGE_SIZE = 30; const SYNC_WINDOW_DAYS = 4; +const CATEGORY_TYPE_SQL: Record = { + comments: "AND type IN ('new_mention', 'comment_reply')", + status: "AND type IN ('connector_indexing', 'connector_deletion', 'document_processing', 'page_limit_exceeded')", +}; + /** * Calculate the cutoff date for sync window. * Rounds to the start of the day (midnight UTC) to ensure stable values @@ -27,24 +31,27 @@ function getSyncCutoffDate(): string { /** * Hook for managing inbox items with API-first architecture + Electric real-time deltas. * - * Architecture (Documents pattern): - * 1. API is the PRIMARY data source — fetches first page on mount + * Architecture (Documents pattern, per-tab): + * 1. API is the PRIMARY data source — fetches first page on mount with category filter * 2. Electric provides REAL-TIME updates (new items, status changes, read state) * 3. Baseline pattern prevents duplicates between API and Electric - * 4. Single instance serves both Comments and Status tabs + * 4. Electric sync shape is SHARED across instances (client-level caching) + * — each instance creates its own type-filtered live queries * * Unread count strategy: - * - API provides the total on mount (ground truth across all time) - * - Electric live query counts unread within SYNC_WINDOW_DAYS + * - API provides the category-filtered total on mount (ground truth across all time) + * - Electric live query counts unread within SYNC_WINDOW_DAYS (filtered by type) * - olderUnreadOffsetRef bridges the gap: total = offset + recent * - Optimistic updates adjust both the count and the offset (for old items) * * @param userId - The user ID to fetch inbox items for * @param searchSpaceId - The search space ID to filter inbox items + * @param category - Which tab: "comments" or "status" */ export function useInbox( userId: string | null, searchSpaceId: number | null, + category: NotificationCategory, ) { const electricClient = useElectricClient(); @@ -57,17 +64,13 @@ export function useInbox( const initialLoadDoneRef = useRef(false); const electricBaselineIdsRef = useRef | null>(null); - const syncHandleRef = useRef(null); const liveQueryRef = useRef<{ unsubscribe?: () => void } | null>(null); const unreadLiveQueryRef = useRef<{ unsubscribe?: () => void } | null>(null); - // Unread count offset: number of unread items OLDER than the sync window. - // Computed once from (API total - first Electric recent count), then adjusted - // when the user marks old items as read. const olderUnreadOffsetRef = useRef(null); const apiUnreadTotalRef = useRef(0); - // EFFECT 1: Fetch first page + unread count from API when params change + // EFFECT 1: Fetch first page + unread count from API with category filter useEffect(() => { if (!userId || !searchSpaceId) return; @@ -87,10 +90,11 @@ export function useInbox( notificationsApiService.getNotifications({ queryParams: { search_space_id: searchSpaceId, + category, limit: INITIAL_PAGE_SIZE, }, }), - notificationsApiService.getUnreadCount(searchSpaceId), + notificationsApiService.getUnreadCount(searchSpaceId, undefined, category), ]); if (cancelled) return; @@ -103,7 +107,7 @@ export function useInbox( initialLoadDoneRef.current = true; } catch (err) { if (cancelled) return; - console.error("[useInbox] Initial load failed:", err); + console.error(`[useInbox:${category}] Initial load failed:`, err); setError(err instanceof Error ? err : new Error("Failed to load notifications")); } finally { if (!cancelled) setLoading(false); @@ -112,22 +116,20 @@ export function useInbox( fetchInitialData(); return () => { cancelled = true; }; - }, [userId, searchSpaceId]); + }, [userId, searchSpaceId, category]); - // EFFECT 2: Electric sync + live query for real-time updates + // EFFECT 2: Electric sync (shared shape) + per-instance type-filtered live queries useEffect(() => { if (!userId || !searchSpaceId || !electricClient) return; const uid = userId; const spaceId = searchSpaceId; const client = electricClient; + const typeFilter = CATEGORY_TYPE_SQL[category]; let mounted = true; async function setupElectricRealtime() { - if (syncHandleRef.current) { - try { syncHandleRef.current.unsubscribe(); } catch { /* PGlite may be closed */ } - syncHandleRef.current = null; - } + // Clean up previous live queries (NOT the sync shape — it's shared) if (liveQueryRef.current) { try { liveQueryRef.current.unsubscribe?.(); } catch { /* PGlite may be closed */ } liveQueryRef.current = null; @@ -140,18 +142,15 @@ export function useInbox( try { const cutoffDate = getSyncCutoffDate(); + // Sync shape is cached by the Electric client — multiple hook instances + // calling syncShape with the same params get the same handle. const handle = await client.syncShape({ table: "notifications", where: `user_id = '${uid}' AND created_at > '${cutoffDate}'`, primaryKey: ["id"], }); - if (!mounted) { - handle.unsubscribe(); - return; - } - - syncHandleRef.current = handle; + if (!mounted) return; if (!handle.isUpToDate && handle.initialSyncPromise) { await Promise.race([ @@ -176,10 +175,12 @@ export function useInbox( if (!db.live?.query) return; + // Per-instance live query filtered by category types const itemsQuery = `SELECT * FROM notifications WHERE user_id = $1 AND (search_space_id = $2 OR search_space_id IS NULL) AND created_at > '${cutoffDate}' + ${typeFilter} ORDER BY created_at DESC`; const liveQuery = await db.live.query(itemsQuery, [uid, spaceId]); @@ -193,10 +194,8 @@ export function useInbox( if (!mounted || !result.rows || !initialLoadDoneRef.current) return; const validItems = result.rows.filter((item) => item.id != null && item.title != null); - const isFullySynced = syncHandleRef.current?.isUpToDate ?? false; const cutoff = new Date(getSyncCutoffDate()); - // Build a Map for O(1) lookups instead of .find() inside .map() const liveItemMap = new Map(validItems.map((d) => [d.id, d])); const liveIds = new Set(liveItemMap.keys()); @@ -208,12 +207,11 @@ export function useInbox( } const baseline = electricBaselineIdsRef.current; - const newItems = validItems - .filter((item) => { - if (prevIds.has(item.id)) return false; - if (baseline.has(item.id)) return false; - return true; - }); + const newItems = validItems.filter((item) => { + if (prevIds.has(item.id)) return false; + if (baseline.has(item.id)) return false; + return true; + }); for (const item of newItems) { baseline.add(item.id); @@ -225,6 +223,7 @@ export function useInbox( return item; }); + const isFullySynced = handle.isUpToDate; if (isFullySynced) { updated = updated.filter((item) => { if (new Date(item.created_at) < cutoff) return true; @@ -242,13 +241,13 @@ export function useInbox( liveQueryRef.current = liveQuery; - // Unread count live query — only covers the sync window. - // Combined with olderUnreadOffsetRef to produce the full count. + // Per-instance unread count live query filtered by category types const countQuery = `SELECT COUNT(*) as count FROM notifications WHERE user_id = $1 AND (search_space_id = $2 OR search_space_id IS NULL) AND created_at > '${cutoffDate}' - AND read = false`; + AND read = false + ${typeFilter}`; const countLiveQuery = await db.live.query<{ count: number | string }>(countQuery, [uid, spaceId]); @@ -261,7 +260,6 @@ export function useInbox( if (!mounted || !result.rows?.[0] || !initialLoadDoneRef.current) return; const liveRecentUnread = Number(result.rows[0].count) || 0; - // First callback: compute how many unread are outside the sync window if (olderUnreadOffsetRef.current === null) { olderUnreadOffsetRef.current = Math.max( 0, @@ -274,7 +272,7 @@ export function useInbox( unreadLiveQueryRef.current = countLiveQuery; } catch (err) { - console.error("[useInbox] Electric setup failed:", err); + console.error(`[useInbox:${category}] Electric setup failed:`, err); } } @@ -282,10 +280,7 @@ export function useInbox( return () => { mounted = false; - if (syncHandleRef.current) { - try { syncHandleRef.current.unsubscribe(); } catch { /* PGlite may be closed */ } - syncHandleRef.current = null; - } + // Only clean up live queries — sync shape is shared across instances if (liveQueryRef.current) { try { liveQueryRef.current.unsubscribe?.(); } catch { /* PGlite may be closed */ } liveQueryRef.current = null; @@ -295,7 +290,7 @@ export function useInbox( unreadLiveQueryRef.current = null; } }; - }, [userId, searchSpaceId, electricClient]); + }, [userId, searchSpaceId, electricClient, category]); // Load more pages via API (cursor-based using before_date) const loadMore = useCallback(async () => { @@ -309,6 +304,7 @@ export function useInbox( const response = await notificationsApiService.getNotifications({ queryParams: { search_space_id: searchSpaceId, + category, before_date: beforeDate, limit: SCROLL_PAGE_SIZE, }, @@ -323,11 +319,11 @@ export function useInbox( }); setHasMore(response.has_more); } catch (err) { - console.error("[useInbox] Load more failed:", err); + console.error(`[useInbox:${category}] Load more failed:`, err); } finally { setLoadingMore(false); } - }, [loadingMore, hasMore, userId, searchSpaceId, inboxItems]); + }, [loadingMore, hasMore, userId, searchSpaceId, inboxItems, category]); // Mark single item as read with optimistic update const markAsRead = useCallback( @@ -341,7 +337,6 @@ export function useInbox( setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: true } : i))); setUnreadCount((prev) => Math.max(0, prev - 1)); - // Adjust older offset so the next live query callback stays consistent if (isOlderItem && olderUnreadOffsetRef.current !== null) { olderUnreadOffsetRef.current = Math.max(0, olderUnreadOffsetRef.current - 1); } @@ -371,6 +366,7 @@ export function useInbox( // Mark all as read with optimistic update const markAllAsRead = useCallback(async () => { + const prevItems = inboxItems; const prevCount = unreadCount; const prevOffset = olderUnreadOffsetRef.current; @@ -381,17 +377,19 @@ export function useInbox( try { const result = await notificationsApiService.markAllAsRead(); if (!result.success) { + setInboxItems(prevItems); setUnreadCount(prevCount); olderUnreadOffsetRef.current = prevOffset; } return result.success; } catch (err) { console.error("Failed to mark all as read:", err); + setInboxItems(prevItems); setUnreadCount(prevCount); olderUnreadOffsetRef.current = prevOffset; return false; } - }, [unreadCount]); + }, [inboxItems, unreadCount]); return { inboxItems, diff --git a/surfsense_web/lib/apis/notifications-api.service.ts b/surfsense_web/lib/apis/notifications-api.service.ts index 00593c395..2b766a61f 100644 --- a/surfsense_web/lib/apis/notifications-api.service.ts +++ b/surfsense_web/lib/apis/notifications-api.service.ts @@ -3,6 +3,7 @@ import { type GetNotificationsResponse, type GetSourceTypesResponse, type GetUnreadCountResponse, + type NotificationCategory, getNotificationsRequest, getNotificationsResponse, getSourceTypesResponse, @@ -44,6 +45,9 @@ class NotificationsApiService { if (queryParams.type) { params.append("type", queryParams.type); } + if (queryParams.category) { + params.append("category", queryParams.category); + } if (queryParams.source_type) { params.append("source_type", queryParams.source_type); } @@ -119,14 +123,14 @@ class NotificationsApiService { /** * Get unread notification count with split between total and recent - * - total_unread: All unread notifications - * - recent_unread: Unread within sync window (last 14 days) * @param searchSpaceId - Optional search space ID to filter by * @param type - Optional notification type to filter by (type-safe enum) + * @param category - Optional category filter ('comments' or 'status') */ getUnreadCount = async ( searchSpaceId?: number, - type?: InboxItemTypeEnum + type?: InboxItemTypeEnum, + category?: NotificationCategory ): Promise => { const params = new URLSearchParams(); if (searchSpaceId !== undefined) { @@ -135,6 +139,9 @@ class NotificationsApiService { if (type) { params.append("type", type); } + if (category) { + params.append("category", category); + } const queryString = params.toString(); return baseApiService.get(