diff --git a/surfsense_backend/app/routes/notifications_routes.py b/surfsense_backend/app/routes/notifications_routes.py index e8e89e6c4..b77a39249 100644 --- a/surfsense_backend/app/routes/notifications_routes.py +++ b/surfsense_backend/app/routes/notifications_routes.py @@ -144,6 +144,9 @@ async def list_notifications( before_date: str | None = Query( None, description="Get notifications before this ISO date (for pagination)" ), + search: str | None = Query( + None, description="Search notifications by title or message (case-insensitive)" + ), limit: int = Query(50, ge=1, le=100, description="Number of items to return"), offset: int = Query(0, ge=0, description="Number of items to skip"), user: User = Depends(current_active_user), @@ -191,6 +194,16 @@ async def list_notifications( detail="Invalid date format. Use ISO format (e.g., 2024-01-15T00:00:00Z)", ) from None + # Filter by search query (case-insensitive title/message search) + if search: + search_term = f"%{search}%" + search_filter = ( + Notification.title.ilike(search_term) + | Notification.message.ilike(search_term) + ) + query = query.where(search_filter) + count_query = count_query.where(search_filter) + # Get total count total_result = await session.execute(count_query) total = total_result.scalar() or 0 diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx index 6bd5f8460..bde346c96 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx @@ -178,7 +178,7 @@ export function DocumentsFilters({
setTypeSearchQuery(e.target.value)} className="h-6 pl-6 text-sm bg-transparent border-0 focus-visible:ring-0" diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index f313dd6f9..8f7ca38c4 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -1,5 +1,6 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import { useAtom } from "jotai"; import { AlertCircle, @@ -14,12 +15,13 @@ import { Inbox, LayoutGrid, ListFilter, + Loader2, MessageSquare, Search, X, } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; -import { useRouter } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { setCommentsCollapsedAtom, setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; @@ -52,7 +54,10 @@ import { isPageLimitExceededMetadata, } from "@/contracts/types/inbox.types"; import type { InboxItem } from "@/hooks/use-inbox"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useMediaQuery } from "@/hooks/use-media-query"; +import { notificationsApiService } from "@/lib/apis/notifications-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cn } from "@/lib/utils"; import { useSidebarContextSafe } from "../../hooks"; @@ -179,7 +184,9 @@ export function InboxSidebar({ }: InboxSidebarProps) { const t = useTranslations("sidebar"); const router = useRouter(); + const params = useParams(); 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); @@ -187,12 +194,22 @@ export function InboxSidebar({ const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom); const [searchQuery, setSearchQuery] = useState(""); + const debouncedSearch = useDebouncedValue(searchQuery, 300); + const isSearchMode = !!debouncedSearch.trim(); const [activeTab, setActiveTab] = useState("comments"); const [activeFilter, setActiveFilter] = useState("all"); const [selectedConnector, setSelectedConnector] = 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; + const atTop = el.scrollTop <= 2; + 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); @@ -200,6 +217,24 @@ export function InboxSidebar({ // 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 + const searchTypeFilter = activeTab === "comments" ? "new_mention" as const : undefined; + const { data: searchResponse, isLoading: isSearchLoading } = useQuery({ + queryKey: cacheKeys.notifications.search(searchSpaceId, debouncedSearch.trim(), activeTab), + queryFn: () => + notificationsApiService.getNotifications({ + queryParams: { + search_space_id: searchSpaceId ?? undefined, + type: searchTypeFilter, + search: debouncedSearch.trim(), + limit: 50, + }, + }), + staleTime: 30 * 1000, // 30 seconds (search results don't need to be super fresh) + enabled: isSearchMode && open, + }); + useEffect(() => { setMounted(true); }, []); @@ -234,17 +269,11 @@ export function InboxSidebar({ } }, [activeTab]); - // Both tabs now derive items from status (all types), so use status for pagination - const { loading, loadingMore = false, hasMore = false, loadMore } = status; + // 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; - // Comments tab: mentions and comment replies - const commentsItems = useMemo( - () => - status.items.filter((item) => item.type === "new_mention" || item.type === "comment_reply"), - [status.items] - ); - - // Status tab: connector indexing, document processing, page limit exceeded, connector deletion + // Status tab: filters status data source (fetches all types) to status-specific types const statusItems = useMemo( () => status.items.filter( @@ -257,6 +286,12 @@ export function InboxSidebar({ [status.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; + // Get unique connector types from status items for filtering const uniqueConnectorTypes = useMemo(() => { const connectorTypes = new Set(); @@ -279,9 +314,25 @@ export function InboxSidebar({ // Get items for current tab const displayItems = activeTab === "comments" ? commentsItems : statusItems; - // Filter items based on filter type, connector filter, and search query + // Filter items based on filter type, connector filter, and search mode + // When searching: use server-side API results (searches ALL notifications) + // When not searching: use Electric real-time items (fast, local) const filteredItems = useMemo(() => { - let items = displayItems; + // In search mode, use API results + let items: InboxItem[] = isSearchMode + ? (searchResponse?.items ?? []) + : displayItems; + + // For status tab search results, filter to status-specific types + if (isSearchMode && activeTab === "status") { + items = items.filter( + (item) => + item.type === "connector_indexing" || + item.type === "document_processing" || + item.type === "page_limit_exceeded" || + item.type === "connector_deletion" + ); + } // Apply read/unread filter if (activeFilter === "unread") { @@ -302,22 +353,14 @@ export function InboxSidebar({ }); } - // Apply search query - if (searchQuery.trim()) { - const query = searchQuery.toLowerCase(); - items = items.filter( - (item) => - item.title.toLowerCase().includes(query) || item.message.toLowerCase().includes(query) - ); - } - return items; - }, [displayItems, activeFilter, activeTab, selectedConnector, searchQuery]); + }, [displayItems, searchResponse, isSearchMode, activeFilter, activeTab, selectedConnector]); // Intersection Observer for infinite scroll with prefetching - // Only active when not searching (search results are client-side filtered) + // 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) useEffect(() => { - if (!loadMore || !hasMore || loadingMore || !open || searchQuery.trim()) return; + if (!loadMore || !hasMore || loadingMore || !open || isSearchMode) return; const observer = new IntersectionObserver( (entries) => { @@ -338,17 +381,11 @@ export function InboxSidebar({ } return () => observer.disconnect(); - }, [loadMore, hasMore, loadingMore, open, searchQuery]); + }, [loadMore, hasMore, loadingMore, open, isSearchMode, activeTab]); - // Unread counts derived from filtered items - const unreadCommentsCount = useMemo( - () => commentsItems.filter((item) => !item.read).length, - [commentsItems] - ); - const unreadStatusCount = useMemo( - () => statusItems.filter((item) => !item.read).length, - [statusItems] - ); + // Unread counts from server-side accurate totals (passed via props) + const unreadCommentsCount = mentions.unreadCount; + const unreadStatusCount = status.unreadCount; const handleItemClick = useCallback( async (item: InboxItem) => { @@ -725,29 +762,38 @@ export function InboxSidebar({ {t("connectors") || "Connectors"} - setSelectedConnector(null)} - className="flex items-center justify-between" +
- - - {t("all_connectors") || "All connectors"} - - {selectedConnector === null && } - - {uniqueConnectorTypes.map((connector) => ( setSelectedConnector(connector.type)} + onClick={() => setSelectedConnector(null)} className="flex items-center justify-between" > - {getConnectorIcon(connector.type, "h-4 w-4")} - {connector.displayName} + + {t("all_connectors") || "All connectors"} - {selectedConnector === connector.type && } + {selectedConnector === null && } - ))} + {uniqueConnectorTypes.map((connector) => ( + setSelectedConnector(connector.type)} + className="flex items-center justify-between" + > + + {getConnectorIcon(connector.type, "h-4 w-4")} + {connector.displayName} + + {selectedConnector === connector.type && } + + ))} +
)} @@ -880,18 +926,22 @@ export function InboxSidebar({ -
- {loading ? ( -
+
+ {(isSearchMode ? isSearchLoading : loading) ? ( +
+ {isSearchMode ? ( + + ) : ( -
- ) : 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; + )} +
+ ) : filteredItems.length > 0 ? ( +
+ {filteredItems.map((item, index) => { + const isMarkingAsRead = markingAsReadId === item.id; + // Place prefetch trigger on 5th item from end (only when not searching) + const isPrefetchTrigger = + !isSearchMode && hasMore && index === filteredItems.length - 5; return (
); })} - {/* 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"} -

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

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

+

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

+
) : (
{activeTab === "comments" ? ( diff --git a/surfsense_web/contracts/types/inbox.types.ts b/surfsense_web/contracts/types/inbox.types.ts index ebf1889a1..bd04eefd1 100644 --- a/surfsense_web/contracts/types/inbox.types.ts +++ b/surfsense_web/contracts/types/inbox.types.ts @@ -205,6 +205,7 @@ export const getNotificationsRequest = z.object({ search_space_id: z.number().optional(), type: inboxItemTypeEnum.optional(), before_date: z.string().optional(), + search: z.string().optional(), limit: z.number().min(1).max(100).optional(), offset: z.number().min(0).optional(), }), diff --git a/surfsense_web/lib/apis/notifications-api.service.ts b/surfsense_web/lib/apis/notifications-api.service.ts index bd590dcd2..086633d81 100644 --- a/surfsense_web/lib/apis/notifications-api.service.ts +++ b/surfsense_web/lib/apis/notifications-api.service.ts @@ -51,6 +51,9 @@ class NotificationsApiService { if (queryParams.offset !== undefined) { params.append("offset", String(queryParams.offset)); } + if (queryParams.search) { + params.append("search", queryParams.search); + } const queryString = params.toString(); diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index c6981b28a..7dc6c54b6 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -92,4 +92,8 @@ export const cacheKeys = { bySearchSpace: (searchSpaceId: number) => ["public-chat-snapshots", "search-space", searchSpaceId] as const, }, + notifications: { + search: (searchSpaceId: number | null, search: string, tab: string) => + ["notifications", "search", searchSpaceId, search, tab] as const, + }, };