diff --git a/surfsense_backend/app/routes/notifications_routes.py b/surfsense_backend/app/routes/notifications_routes.py index 6bc945643..84591f001 100644 --- a/surfsense_backend/app/routes/notifications_routes.py +++ b/surfsense_backend/app/routes/notifications_routes.py @@ -6,6 +6,7 @@ For older items (beyond the sync window), use the list endpoint. """ from datetime import UTC, datetime, timedelta +from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel @@ -20,6 +21,9 @@ router = APIRouter(prefix="/notifications", tags=["notifications"]) # Must match frontend SYNC_WINDOW_DAYS in use-inbox.ts SYNC_WINDOW_DAYS = 14 +# Valid notification types - must match frontend InboxItemTypeEnum +NotificationType = Literal["connector_indexing", "document_processing", "new_mention"] + class NotificationResponse(BaseModel): """Response model for a single notification.""" @@ -73,6 +77,9 @@ class UnreadCountResponse(BaseModel): @router.get("/unread-count", response_model=UnreadCountResponse) async def get_unread_count( search_space_id: int | None = Query(None, description="Filter by search space ID"), + type_filter: NotificationType | None = Query( + None, alias="type", description="Filter by notification type" + ), user: User = Depends(current_active_user), session: AsyncSession = Depends(get_async_session), ) -> UnreadCountResponse: @@ -103,6 +110,10 @@ async def get_unread_count( | (Notification.search_space_id.is_(None)) ) + # Filter by notification type if provided + if type_filter: + base_filter.append(Notification.type == type_filter) + # Total unread count (all time) total_query = select(func.count(Notification.id)).where(*base_filter) total_result = await session.execute(total_query) @@ -125,7 +136,7 @@ async def get_unread_count( @router.get("", response_model=NotificationListResponse) async def list_notifications( search_space_id: int | None = Query(None, description="Filter by search space ID"), - type_filter: str | None = Query( + type_filter: NotificationType | None = Query( None, alias="type", description="Filter by notification type" ), before_date: str | None = Query( diff --git a/surfsense_web/atoms/chat/current-thread.atom.ts b/surfsense_web/atoms/chat/current-thread.atom.ts index 18afb8ff3..5de11eb92 100644 --- a/surfsense_web/atoms/chat/current-thread.atom.ts +++ b/surfsense_web/atoms/chat/current-thread.atom.ts @@ -62,9 +62,7 @@ export const resetCurrentThreadAtom = atom(null, (_, set) => { }); /** Atom to read whether comments panel is collapsed */ -export const commentsCollapsedAtom = atom( - (get) => get(currentThreadAtom).commentsCollapsed -); +export const commentsCollapsedAtom = atom((get) => get(currentThreadAtom).commentsCollapsed); /** Atom to toggle the comments collapsed state */ export const toggleCommentsCollapsedAtom = atom(null, (get, set) => { diff --git a/surfsense_web/components/assistant-ui/markdown-text.tsx b/surfsense_web/components/assistant-ui/markdown-text.tsx index dd4ce6b75..cc8cec5d9 100644 --- a/surfsense_web/components/assistant-ui/markdown-text.tsx +++ b/surfsense_web/components/assistant-ui/markdown-text.tsx @@ -254,10 +254,7 @@ const defaultComponents = memoizeMarkdownComponents({ table: ({ className, ...props }) => (
diff --git a/surfsense_web/components/chat-comments/comment-item/comment-item.tsx b/surfsense_web/components/chat-comments/comment-item/comment-item.tsx index 847886fc8..4996fe01b 100644 --- a/surfsense_web/components/chat-comments/comment-item/comment-item.tsx +++ b/surfsense_web/components/chat-comments/comment-item/comment-item.tsx @@ -3,10 +3,7 @@ import { useAtomValue, useSetAtom } from "jotai"; import { MessageSquare } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import { - clearTargetCommentIdAtom, - targetCommentIdAtom, -} from "@/atoms/chat/current-thread.atom"; +import { clearTargetCommentIdAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -82,10 +79,9 @@ function renderMentions(content: string): React.ReactNode { const mentionPattern = /@\{([^}]+)\}/g; const parts: React.ReactNode[] = []; let lastIndex = 0; - let match: RegExpExecArray | null; - while ((match = mentionPattern.exec(content)) !== null) { - if (match.index > lastIndex) { + for (const match of content.matchAll(mentionPattern)) { + if (match.index !== undefined && match.index > lastIndex) { parts.push(content.slice(lastIndex, match.index)); } @@ -96,7 +92,7 @@ function renderMentions(content: string): React.ReactNode { ); - lastIndex = match.index + match[0].length; + lastIndex = (match.index ?? 0) + match[0].length; } if (lastIndex < content.length) { diff --git a/surfsense_web/components/layout/hooks/SidebarContext.tsx b/surfsense_web/components/layout/hooks/SidebarContext.tsx index 70e9311f9..7aa24d5d0 100644 --- a/surfsense_web/components/layout/hooks/SidebarContext.tsx +++ b/surfsense_web/components/layout/hooks/SidebarContext.tsx @@ -34,4 +34,3 @@ export function useSidebarContext(): SidebarContextValue { export function useSidebarContextSafe(): SidebarContextValue | null { return useContext(SidebarContext); } - diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 8710fdb79..ed8f28916 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -104,19 +104,55 @@ export function LayoutDataProvider({ // Search space dialog state const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false); - // Inbox hook + // Inbox hooks - separate data sources for mentions and status tabs + // This ensures each tab has independent pagination and data loading const userId = user?.id ? String(user.id) : null; + + // Mentions: Only fetch "new_mention" type notifications const { - inboxItems, - unreadCount, - loading: inboxLoading, - loadingMore: inboxLoadingMore, - hasMore: inboxHasMore, - loadMore: inboxLoadMore, - markAsRead, - markAllAsRead, + inboxItems: mentionItems, + unreadCount: mentionUnreadCount, + loading: mentionLoading, + loadingMore: mentionLoadingMore, + hasMore: mentionHasMore, + loadMore: mentionLoadMore, + markAsRead: markMentionAsRead, + markAllAsRead: markAllMentionsAsRead, + } = useInbox(userId, Number(searchSpaceId) || null, "new_mention"); + + // Status: Fetch all types (will be filtered client-side to status types) + // We pass null to get all, then InboxSidebar filters to status types + const { + inboxItems: statusItems, + unreadCount: statusUnreadCount, + loading: statusLoading, + loadingMore: statusLoadingMore, + hasMore: statusHasMore, + loadMore: statusLoadMore, + markAsRead: markStatusAsRead, + markAllAsRead: markAllStatusAsRead, } = useInbox(userId, Number(searchSpaceId) || null, null); + // Combined unread count for nav badge (mentions take priority for visibility) + const totalUnreadCount = mentionUnreadCount + statusUnreadCount; + + // 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); const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null); @@ -197,7 +233,7 @@ export function LayoutDataProvider({ url: "#inbox", // Special URL to indicate this is handled differently icon: Inbox, isActive: isInboxSidebarOpen, - badge: unreadCount > 0 ? formatInboxCount(unreadCount) : undefined, + badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined, }, { title: "Documents", @@ -206,7 +242,7 @@ export function LayoutDataProvider({ isActive: pathname?.includes("/documents"), }, ], - [searchSpaceId, pathname, isInboxSidebarOpen, unreadCount] + [searchSpaceId, pathname, isInboxSidebarOpen, totalUnreadCount] ); // Handlers @@ -465,12 +501,24 @@ export function LayoutDataProvider({ inbox={{ isOpen: isInboxSidebarOpen, onOpenChange: setIsInboxSidebarOpen, - items: inboxItems, - unreadCount, - loading: inboxLoading, - loadingMore: inboxLoadingMore, - hasMore: inboxHasMore, - loadMore: inboxLoadMore, + // Separate data sources for each tab + mentions: { + items: mentionItems, + unreadCount: mentionUnreadCount, + loading: mentionLoading, + loadingMore: mentionLoadingMore, + hasMore: mentionHasMore, + loadMore: mentionLoadMore, + }, + status: { + items: statusItems, + unreadCount: statusUnreadCount, + loading: statusLoading, + loadingMore: statusLoadingMore, + hasMore: statusHasMore, + loadMore: statusLoadMore, + }, + totalUnreadCount, 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 d84b9cdce..3624c90a3 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -11,16 +11,26 @@ import { Header } from "../header"; import { IconRail } from "../icon-rail"; import { InboxSidebar, MobileSidebar, MobileSidebarTrigger, Sidebar } from "../sidebar"; -// Inbox-related props -interface InboxProps { - isOpen: boolean; - onOpenChange: (open: boolean) => void; +// Tab-specific data source props +interface TabDataSource { items: InboxItem[]; unreadCount: 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) */ @@ -151,26 +161,23 @@ export function LayoutShell({ setTheme={setTheme} /> -
- {children} -
+
+ {children} +
- {/* Mobile Inbox Sidebar - only render when open to avoid scroll blocking */} - {inbox?.isOpen && ( - setMobileMenuOpen(false)} - /> - )} + {/* Mobile Inbox Sidebar - only render when open to avoid scroll blocking */} + {inbox?.isOpen && ( + setMobileMenuOpen(false)} + /> + )} @@ -181,7 +188,9 @@ export function LayoutShell({ return ( -
+
void; - inboxItems: InboxItem[]; +// Tab-specific data source with independent pagination +interface TabDataSource { + items: InboxItem[]; unreadCount: number; loading: boolean; loadingMore?: boolean; hasMore?: boolean; loadMore?: () => void; +} + +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 */ + totalUnreadCount: number; markAsRead: (id: number) => Promise; markAllAsRead: () => Promise; onCloseMobileSidebar?: () => void; @@ -157,12 +162,9 @@ interface InboxSidebarProps { export function InboxSidebar({ open, onOpenChange, - inboxItems, - unreadCount, - loading, - loadingMore = false, - hasMore = false, - loadMore, + mentions, + status, + totalUnreadCount, markAsRead, markAllAsRead, onCloseMobileSidebar, @@ -209,11 +211,11 @@ export function InboxSidebar({ // 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; }; @@ -226,18 +228,18 @@ export function InboxSidebar({ } }, [activeTab]); - // Split items by type - const mentionItems = useMemo( - () => inboxItems.filter((item) => item.type === "new_mention"), - [inboxItems] - ); + // Get current tab's data source - each tab has independent data and pagination + const currentDataSource = activeTab === "mentions" ? mentions : status; + const { loading, loadingMore = false, hasMore = false, loadMore } = currentDataSource; + // For status items, filter to only show status notification types + // (the status data source may include all types from API) const statusItems = useMemo( () => - inboxItems.filter( + status.items.filter( (item) => item.type === "connector_indexing" || item.type === "document_processing" ), - [inboxItems] + [status.items] ); // Get unique connector types from status items for filtering @@ -259,12 +261,12 @@ export function InboxSidebar({ })); }, [statusItems]); - // Get items for current tab - const currentTabItems = activeTab === "mentions" ? mentionItems : statusItems; + // Get items for current tab - mentions use their source directly, status uses filtered items + const displayItems = activeTab === "mentions" ? mentions.items : statusItems; // Filter items based on filter type, connector filter, and search query const filteredItems = useMemo(() => { - let items = currentTabItems; + let items = displayItems; // Apply read/unread filter if (activeFilter === "unread") { @@ -295,7 +297,7 @@ export function InboxSidebar({ } return items; - }, [currentTabItems, activeFilter, activeTab, selectedConnector, searchQuery]); + }, [displayItems, activeFilter, activeTab, selectedConnector, searchQuery]); // Intersection Observer for infinite scroll with prefetching // Only active when not searching (search results are client-side filtered) @@ -321,16 +323,11 @@ export function InboxSidebar({ } return () => observer.disconnect(); - }, [loadMore, hasMore, loadingMore, open, searchQuery, filteredItems.length]); + }, [loadMore, hasMore, loadingMore, open, searchQuery]); - // Count unread items per tab - const unreadMentionsCount = useMemo(() => { - return mentionItems.filter((item) => !item.read).length; - }, [mentionItems]); - - const unreadStatusCount = useMemo(() => { - return statusItems.filter((item) => !item.read).length; - }, [statusItems]); + // Use unread counts from data sources (more accurate than client-side counting) + const unreadMentionsCount = mentions.unreadCount; + const unreadStatusCount = status.unreadCount; const handleItemClick = useCallback( async (item: InboxItem) => { @@ -481,209 +478,128 @@ export function InboxSidebar({ const inboxContent = ( <>
-
-
-

{t("inbox") || "Inbox"}

-
-
- {/* Mobile: Button that opens bottom drawer */} - {isMobile ? ( - <> - - - - - {t("filter") || "Filter"} - - - - - - - - {t("filter") || "Filter"} - - -
- {/* Filter section */} -
-

- {t("filter") || "Filter"} -

-
- - -
-
- {/* Connectors section - only for status tab */} - {activeTab === "status" && uniqueConnectorTypes.length > 0 && ( -
-

- {t("connectors") || "Connectors"} -

-
- - {uniqueConnectorTypes.map((connector) => ( - - ))} -
-
- )} -
-
-
- - ) : ( - /* Desktop: Dropdown menu */ - setOpenDropdown(isOpen ? "filter" : null)} +
+
+

{t("inbox") || "Inbox"}

+
+
+ {/* Mobile: Button that opens bottom drawer */} + {isMobile ? ( + <> + + + - - - {t("filter") || "Filter"} - - - + + {t("filter") || "Filter"} + + + {t("filter") || "Filter"} + + + + + + + + {t("filter") || "Filter"} + + +
+ {/* Filter section */} +
+

{t("filter") || "Filter"} - - setActiveFilter("all")} - className="flex items-center justify-between" - > - - - {t("all") || "All"} - - {activeFilter === "all" && } - - setActiveFilter("unread")} - className="flex items-center justify-between" - > - - - {t("unread") || "Unread"} - - {activeFilter === "unread" && } - - {activeTab === "status" && uniqueConnectorTypes.length > 0 && ( - <> - - {t("connectors") || "Connectors"} - - setSelectedConnector(null)} - className="flex items-center justify-between" +

+
+ + +
+
+ {/* Connectors section - only for status tab */} + {activeTab === "status" && uniqueConnectorTypes.length > 0 && ( +
+

+ {t("connectors") || "Connectors"} +

+
+ {uniqueConnectorTypes.map((connector) => ( - setSelectedConnector(connector.type)} - className="flex items-center justify-between" + type="button" + onClick={() => { + setSelectedConnector(connector.type); + setFilterDrawerOpen(false); + }} + className={cn( + "flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors", + selectedConnector === connector.type + ? "bg-primary/10 text-primary" + : "hover:bg-muted" + )} > {getConnectorIcon(connector.type, "h-4 w-4")} @@ -692,240 +608,311 @@ export function InboxSidebar({ {selectedConnector === connector.type && ( )} - + ))} - - )} - - - )} - - -
+
+ )} +
+
+
+ + ) : ( + /* Desktop: Dropdown menu */ + setOpenDropdown(isOpen ? "filter" : null)} + > + + + + - - - {t("mark_all_read") || "Mark all as read"} - - - {/* Close button - mobile only */} - {isMobile && ( - - - - - - {t("close") || "Close"} - - - )} - {/* Dock/Undock button - desktop only */} - {!isMobile && onDockedChange && ( - - - - - - {isDocked ? "Collapse panel" : "Expand panel"} - - + {selectedConnector === connector.type && } + + ))} + )} -
-
- -
- - setSearchQuery(e.target.value)} - className="pl-9 pr-8 h-9" - /> - {searchQuery && ( + + + )} + + + + + + {t("mark_all_read") || "Mark all as read"} + + + {/* Close button - mobile only */} + {isMobile && ( + + - )} -
-
+ + {t("close") || "Close"} + + )} + {/* Dock/Undock button - desktop only */} + {!isMobile && onDockedChange && ( + + + + + + {isDocked ? "Collapse panel" : "Expand panel"} + + + )} +
+
- setActiveTab(value as InboxTab)} - className="shrink-0 mx-4" +
+ + setSearchQuery(e.target.value)} + className="pl-9 pr-8 h-9" + /> + {searchQuery && ( + + )} +
+
-
- {loading ? ( -
- -
- ) : filteredItems.length > 0 ? ( -
- {filteredItems.map((item, index) => { - const isMarkingAsRead = markingAsReadId === item.id; - // Place prefetch trigger on 5th item from end (only if not searching) - const isPrefetchTrigger = - !searchQuery && hasMore && index === filteredItems.length - 5; + setActiveTab(value as InboxTab)} + className="shrink-0 mx-4" + > + + + + + {t("mentions") || "Mentions"} + + {formatInboxCount(unreadMentionsCount)} + + + + + + + {t("status") || "Status"} + + {formatInboxCount(unreadStatusCount)} + + + + + - return ( -
+ {loading ? ( +
+ +
+ ) : filteredItems.length > 0 ? ( +
+ {filteredItems.map((item, index) => { + const isMarkingAsRead = markingAsReadId === item.id; + // Place prefetch trigger on 5th item from end (only if not searching) + const isPrefetchTrigger = + !searchQuery && hasMore && index === filteredItems.length - 5; + + return ( +
+ + + - - -

{item.title}

-

- {convertRenderedToDisplay(item.message)} -

-
-
- - {/* Time and unread dot - fixed width to prevent content shift */} -
- - {formatTime(item.created_at)} - - {!item.read && ( - - )} +
{getStatusIcon(item)}
+
+

+ {item.title} +

+

+ {convertRenderedToDisplay(item.message)} +

-
- ); - })} - {/* Fallback trigger at the very end if less than 5 items and not searching */} - {!searchQuery && filteredItems.length < 5 && hasMore && ( -
- )} + + + +

{item.title}

+

+ {convertRenderedToDisplay(item.message)} +

+
+ + + {/* Time and unread dot - fixed width to prevent content shift */} +
+ + {formatTime(item.created_at)} + + {!item.read && } +
- ) : searchQuery ? ( -
- -

- {t("no_results_found") || "No results found"} -

-

- {t("try_different_search") || "Try a different search term"} -

-
- ) : ( -
- {activeTab === "mentions" ? ( - - ) : ( - - )} -

{getEmptyStateMessage().title}

-

- {getEmptyStateMessage().hint} -

-
- )} + ); + })} + {/* Fallback trigger at the very end if less than 5 items and not searching */} + {!searchQuery && filteredItems.length < 5 && hasMore && ( +
+ )} +
+ ) : searchQuery ? ( +
+ +

+ {t("no_results_found") || "No results found"} +

+

+ {t("try_different_search") || "Try a different search term"} +

+
+ ) : ( +
+ {activeTab === "mentions" ? ( + + ) : ( + + )} +

{getEmptyStateMessage().title}

+

{getEmptyStateMessage().hint}

+
+ )}
); @@ -967,10 +954,7 @@ export function InboxSidebar({ left: isMobile ? 0 : sidebarWidth, width: isMobile ? "100%" : 360, }} - className={cn( - "absolute z-10 overflow-hidden pointer-events-none", - "inset-y-0" - )} + className={cn("absolute z-10 overflow-hidden pointer-events-none", "inset-y-0")} >

{title}

{description && ( -

{description}

+

+ {description} +

)}
@@ -243,7 +245,11 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN {/* Volume control */}
{/* Custom volume bar - visually distinct from progress slider */}
@@ -268,7 +274,12 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
{/* Download button */} - diff --git a/surfsense_web/components/tool-ui/generate-podcast.tsx b/surfsense_web/components/tool-ui/generate-podcast.tsx index 513853c1a..d40024b7c 100644 --- a/surfsense_web/components/tool-ui/generate-podcast.tsx +++ b/surfsense_web/components/tool-ui/generate-podcast.tsx @@ -96,10 +96,14 @@ function PodcastGeneratingState({ title }: { title: string }) {
-

{title}

+

+ {title} +

- Generating podcast. This may take a few minutes. + + Generating podcast. This may take a few minutes. +
@@ -123,7 +127,9 @@ function PodcastErrorState({ title, error }: { title: string; error: string }) {
-

{title}

+

+ {title} +

Failed to generate podcast

{error}

@@ -143,7 +149,9 @@ function AudioLoadingState({ title }: { title: string }) {
-

{title}

+

+ {title} +

Loading audio... diff --git a/surfsense_web/hooks/use-inbox.ts b/surfsense_web/hooks/use-inbox.ts index 4c26ddcb9..362feb747 100644 --- a/surfsense_web/hooks/use-inbox.ts +++ b/surfsense_web/hooks/use-inbox.ts @@ -318,9 +318,13 @@ export function useInbox( try { // STEP 1: Fetch server counts (total and recent) - guaranteed accurate - console.log("[useInbox] Fetching unread count from server"); + console.log( + "[useInbox] Fetching unread count from server", + typeFilter ? `for type: ${typeFilter}` : "for all types" + ); const serverCounts = await notificationsApiService.getUnreadCount( - searchSpaceId ?? undefined + searchSpaceId ?? undefined, + typeFilter ?? undefined ); if (mounted) { diff --git a/surfsense_web/lib/apis/notifications-api.service.ts b/surfsense_web/lib/apis/notifications-api.service.ts index a9e81a81f..941a347db 100644 --- a/surfsense_web/lib/apis/notifications-api.service.ts +++ b/surfsense_web/lib/apis/notifications-api.service.ts @@ -2,6 +2,7 @@ import { type GetNotificationsRequest, type GetNotificationsResponse, type GetUnreadCountResponse, + type InboxItemTypeEnum, getNotificationsRequest, getNotificationsResponse, getUnreadCountResponse, @@ -92,12 +93,20 @@ 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) */ - getUnreadCount = async (searchSpaceId?: number): Promise => { + getUnreadCount = async ( + searchSpaceId?: number, + type?: InboxItemTypeEnum + ): Promise => { const params = new URLSearchParams(); if (searchSpaceId !== undefined) { params.append("search_space_id", String(searchSpaceId)); } + if (type) { + params.append("type", type); + } const queryString = params.toString(); return baseApiService.get(