diff --git a/surfsense_web/components/notifications/NotificationButton.tsx b/surfsense_web/components/notifications/NotificationButton.tsx index 8596c9148..62521774b 100644 --- a/surfsense_web/components/notifications/NotificationButton.tsx +++ b/surfsense_web/components/notifications/NotificationButton.tsx @@ -3,27 +3,61 @@ import { useAtomValue } from "jotai"; import { Bell } from "lucide-react"; import { useParams } from "next/navigation"; -import { useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { useNotifications } from "@/hooks/use-notifications"; +import { useNotifications, type NotificationTypeEnum } from "@/hooks/use-notifications"; import { cn } from "@/lib/utils"; import { NotificationPopup } from "./NotificationPopup"; +const NOTIFICATION_FILTER_STORAGE_KEY = "surfsense_notification_filter"; + export function NotificationButton() { const [open, setOpen] = useState(false); const { data: user } = useAtomValue(currentUserAtom); const params = useParams(); + // Filter state - null means show all, otherwise filter by type + const [activeFilter, setActiveFilter] = useState(null); + + // Load filter from localStorage on mount + useEffect(() => { + try { + const stored = localStorage.getItem(NOTIFICATION_FILTER_STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + if (parsed === null || ["new_mention", "connector_indexing", "document_processing"].includes(parsed)) { + setActiveFilter(parsed); + } + } + } catch { + // Ignore localStorage errors + } + }, []); + + // Handle filter toggle - clicking same pill again shows all + const handleFilterChange = useCallback((filter: NotificationTypeEnum | null) => { + setActiveFilter((current) => { + const newFilter = current === filter ? null : filter; + try { + localStorage.setItem(NOTIFICATION_FILTER_STORAGE_KEY, JSON.stringify(newFilter)); + } catch { + // Ignore localStorage errors + } + return newFilter; + }); + }, []); + const userId = user?.id ? String(user.id) : null; // Get searchSpaceId from URL params - the component is rendered within /dashboard/[search_space_id]/ const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null; const { notifications, unreadCount, loading, markAsRead, markAllAsRead } = useNotifications( userId, - searchSpaceId + searchSpaceId, + activeFilter ); return ( @@ -57,6 +91,8 @@ export function NotificationButton() { markAsRead={markAsRead} markAllAsRead={markAllAsRead} onClose={() => setOpen(false)} + activeFilter={activeFilter} + onFilterChange={handleFilterChange} /> diff --git a/surfsense_web/components/notifications/NotificationPopup.tsx b/surfsense_web/components/notifications/NotificationPopup.tsx index dff89ff6a..fd16602ae 100644 --- a/surfsense_web/components/notifications/NotificationPopup.tsx +++ b/surfsense_web/components/notifications/NotificationPopup.tsx @@ -1,16 +1,25 @@ "use client"; import { formatDistanceToNow } from "date-fns"; -import { AlertCircle, Bell, CheckCheck, CheckCircle2, Loader2 } from "lucide-react"; +import { AlertCircle, AtSign, Bell, Cable, CheckCheck, CheckCircle2, FileText, Loader2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; -import type { Notification } from "@/hooks/use-notifications"; +import type { Notification, NotificationTypeEnum } from "@/hooks/use-notifications"; import { cn } from "@/lib/utils"; +/** + * Filter configuration for notification types + */ +const NOTIFICATION_FILTERS = { + new_mention: { label: "Mentions", icon: AtSign }, + connector_indexing: { label: "Connectors", icon: Cable }, + document_processing: { label: "Documents", icon: FileText }, +} as const; + /** * Get initials from name or email for avatar fallback */ @@ -37,6 +46,8 @@ interface NotificationPopupProps { markAsRead: (id: number) => Promise; markAllAsRead: () => Promise; onClose?: () => void; + activeFilter: NotificationTypeEnum | null; + onFilterChange: (filter: NotificationTypeEnum | null) => void; } export function NotificationPopup({ @@ -46,6 +57,8 @@ export function NotificationPopup({ markAsRead, markAllAsRead, onClose, + activeFilter, + onFilterChange, }: NotificationPopupProps) { const router = useRouter(); @@ -125,7 +138,7 @@ export function NotificationPopup({ return (
{/* Header */} -
+

Notifications

@@ -137,6 +150,32 @@ export function NotificationPopup({ )}
+ {/* Filter Pills */} +
+ {(Object.entries(NOTIFICATION_FILTERS) as [NotificationTypeEnum, typeof NOTIFICATION_FILTERS[keyof typeof NOTIFICATION_FILTERS]][]).map( + ([key, { label, icon: Icon }]) => { + const isActive = activeFilter === key; + return ( + + ); + } + )} +
+ {/* Notifications List */} {loading ? ( @@ -160,8 +199,8 @@ export function NotificationPopup({ !notification.read && "bg-accent/50" )} > -
-
{getStatusIcon(notification)}
+
+
{getStatusIcon(notification)}

([]); + const [totalUnreadCount, setTotalUnreadCount] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const syncHandleRef = useRef(null); const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null); + const unreadCountLiveQueryRef = useRef<{ unsubscribe: () => void } | null>(null); // Track user-level sync key to prevent duplicate sync subscriptions const userSyncKeyRef = useRef(null); @@ -108,7 +115,7 @@ export function useNotifications(userId: string | null, searchSpaceId: number | }; }, [userId, electricClient]); - // EFFECT 2: Search-space-level query - updates when searchSpaceId changes + // EFFECT 2: Search-space-level query - updates when searchSpaceId or typeFilter changes // This runs independently of sync, allowing smooth transitions between search spaces useEffect(() => { if (!userId || !electricClient) { @@ -125,16 +132,19 @@ export function useNotifications(userId: string | null, searchSpaceId: number | } try { - console.log("[useNotifications] Updating query for searchSpace:", searchSpaceId); + console.log("[useNotifications] Updating query for searchSpace:", searchSpaceId, "typeFilter:", typeFilter); + + // Build query with optional type filter + const baseQuery = `SELECT * FROM notifications + WHERE user_id = $1 + AND (search_space_id = $2 OR search_space_id IS NULL)`; + const typeClause = typeFilter ? ` AND type = $3` : ""; + const orderClause = ` ORDER BY created_at DESC`; + const fullQuery = baseQuery + typeClause + orderClause; + const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId]; // Fetch notifications for current search space immediately - const result = await electricClient.db.query( - `SELECT * FROM notifications - WHERE user_id = $1 - AND (search_space_id = $2 OR search_space_id IS NULL) - ORDER BY created_at DESC`, - [userId, searchSpaceId] - ); + const result = await electricClient.db.query(fullQuery, params); if (mounted) { setNotifications(result.rows || []); @@ -145,13 +155,7 @@ export function useNotifications(userId: string | null, searchSpaceId: number | const db = electricClient.db as any; if (db.live?.query && typeof db.live.query === "function") { - const liveQuery = await db.live.query( - `SELECT * FROM notifications - WHERE user_id = $1 - AND (search_space_id = $2 OR search_space_id IS NULL) - ORDER BY created_at DESC`, - [userId, searchSpaceId] - ); + const liveQuery = await db.live.query(fullQuery, params); if (!mounted) { liveQuery.unsubscribe?.(); @@ -192,6 +196,83 @@ export function useNotifications(userId: string | null, searchSpaceId: number | liveQueryRef.current = null; } }; + }, [userId, searchSpaceId, typeFilter, electricClient]); + + // EFFECT 3: Total unread count - independent of type filter + // This ensures the badge count stays consistent regardless of active filter + useEffect(() => { + if (!userId || !electricClient) { + return; + } + + let mounted = true; + + async function updateUnreadCount() { + // Clean up previous live query + if (unreadCountLiveQueryRef.current) { + unreadCountLiveQueryRef.current.unsubscribe(); + unreadCountLiveQueryRef.current = null; + } + + try { + const countQuery = `SELECT COUNT(*) as count FROM notifications + WHERE user_id = $1 + AND (search_space_id = $2 OR search_space_id IS NULL) + AND read = false`; + + // Fetch initial count + const result = await electricClient.db.query<{ count: number }>(countQuery, [userId, searchSpaceId]); + + if (mounted && result.rows?.[0]) { + setTotalUnreadCount(Number(result.rows[0].count) || 0); + } + + // Set up live query for real-time updates + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const db = electricClient.db as any; + + if (db.live?.query && typeof db.live.query === "function") { + const liveQuery = await db.live.query(countQuery, [userId, searchSpaceId]); + + if (!mounted) { + liveQuery.unsubscribe?.(); + return; + } + + // Set initial results from live query + if (liveQuery.initialResults?.rows?.[0]) { + setTotalUnreadCount(Number(liveQuery.initialResults.rows[0].count) || 0); + } else if (liveQuery.rows?.[0]) { + setTotalUnreadCount(Number(liveQuery.rows[0].count) || 0); + } + + // Subscribe to changes + if (typeof liveQuery.subscribe === "function") { + liveQuery.subscribe((result: { rows: { count: number }[] }) => { + if (mounted && result.rows?.[0]) { + setTotalUnreadCount(Number(result.rows[0].count) || 0); + } + }); + } + + if (typeof liveQuery.unsubscribe === "function") { + unreadCountLiveQueryRef.current = liveQuery; + } + } + } catch (err) { + console.error("[useNotifications] Failed to update unread count:", err); + } + } + + updateUnreadCount(); + + return () => { + mounted = false; + if (unreadCountLiveQueryRef.current) { + unreadCountLiveQueryRef.current.unsubscribe(); + unreadCountLiveQueryRef.current = null; + } + }; }, [userId, searchSpaceId, electricClient]); // Mark notification as read via backend API @@ -234,12 +315,9 @@ export function useNotifications(userId: string | null, searchSpaceId: number | } }, []); - // Get unread count - const unreadCount = notifications.filter((n) => !n.read).length; - return { notifications, - unreadCount, + unreadCount: totalUnreadCount, markAsRead, markAllAsRead, loading,