From 8c3b65bac238608c9ed7474def2ac3a717ed539d Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 6 Feb 2026 16:45:54 +0530
Subject: [PATCH] feat: add search functionality to notifications and update
related components
---
.../app/routes/notifications_routes.py | 13 ++
.../(manage)/components/DocumentsFilters.tsx | 2 +-
.../layout/ui/sidebar/InboxSidebar.tsx | 202 +++++++++++-------
surfsense_web/contracts/types/inbox.types.ts | 1 +
.../lib/apis/notifications-api.service.ts | 3 +
surfsense_web/lib/query-client/cache-keys.ts | 4 +
6 files changed, 148 insertions(+), 77 deletions(-)
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,
+ },
};