diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index a0a0d1f5e..48553cc85 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -9,6 +9,7 @@ import { CheckCircle2, History, Inbox, + LayoutGrid, ListFilter, Search, X, @@ -95,7 +96,13 @@ function getConnectorTypeDisplayName(connectorType: string): string { BAIDU_SEARCH_API: "Baidu", }; - return displayNames[connectorType] || connectorType.replace(/_/g, " ").replace(/CONNECTOR|API/gi, "").trim(); + return ( + displayNames[connectorType] || + connectorType + .replace(/_/g, " ") + .replace(/CONNECTOR|API/gi, "") + .trim() + ); } type InboxTab = "mentions" | "status"; @@ -142,7 +149,7 @@ export function InboxSidebar({ // Drawer state for filter menu (mobile only) const [filterDrawerOpen, setFilterDrawerOpen] = useState(false); const [markingAsReadId, setMarkingAsReadId] = useState(null); - + // Prefetch trigger ref - placed on item near the end const prefetchTriggerRef = useRef(null); @@ -239,8 +246,7 @@ export function InboxSidebar({ const query = searchQuery.toLowerCase(); items = items.filter( (item) => - item.title.toLowerCase().includes(query) || - item.message.toLowerCase().includes(query) + item.title.toLowerCase().includes(query) || item.message.toLowerCase().includes(query) ); } @@ -453,15 +459,14 @@ export function InboxSidebar({ {t("filter") || "Filter"} - - {t("filter") || "Filter"} - + {t("filter") || "Filter"} - - + + @@ -484,7 +489,9 @@ export function InboxSidebar({ }} className={cn( "flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors", - activeFilter === "all" ? "bg-primary/10 text-primary" : "hover:bg-muted" + activeFilter === "all" + ? "bg-primary/10 text-primary" + : "hover:bg-muted" )} > @@ -501,7 +508,9 @@ export function InboxSidebar({ }} className={cn( "flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors", - activeFilter === "unread" ? "bg-primary/10 text-primary" : "hover:bg-muted" + activeFilter === "unread" + ? "bg-primary/10 text-primary" + : "hover:bg-muted" )} > @@ -527,10 +536,15 @@ export function InboxSidebar({ }} className={cn( "flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors", - selectedConnector === null ? "bg-primary/10 text-primary" : "hover:bg-muted" + selectedConnector === null + ? "bg-primary/10 text-primary" + : "hover:bg-muted" )} > - {t("all_connectors") || "All connectors"} + + + {t("all_connectors") || "All connectors"} + {selectedConnector === null && } {uniqueConnectorTypes.map((connector) => ( @@ -543,14 +557,18 @@ export function InboxSidebar({ }} 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" + selectedConnector === connector.type + ? "bg-primary/10 text-primary" + : "hover:bg-muted" )} > {getConnectorIcon(connector.type, "h-4 w-4")} {connector.displayName} - {selectedConnector === connector.type && } + {selectedConnector === connector.type && ( + + )} ))} @@ -569,21 +587,18 @@ export function InboxSidebar({ - - - {t("filter") || "Filter"} - + {t("filter") || "Filter"} - + {t("filter") || "Filter"} @@ -616,7 +631,10 @@ export function InboxSidebar({ onClick={() => setSelectedConnector(null)} className="flex items-center justify-between" > - {t("all_connectors") || "All connectors"} + + + {t("all_connectors") || "All connectors"} + {selectedConnector === null && } {uniqueConnectorTypes.map((connector) => ( @@ -629,7 +647,9 @@ export function InboxSidebar({ {getConnectorIcon(connector.type, "h-4 w-4")} {connector.displayName} - {selectedConnector === connector.type && } + {selectedConnector === connector.type && ( + + )} ))} @@ -723,7 +743,8 @@ export function InboxSidebar({ {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; + const isPrefetchTrigger = + !searchQuery && hasMore && index === filteredItems.length - 5; return (
)} -

- {getEmptyStateMessage().title} -

+

{getEmptyStateMessage().title}

{getEmptyStateMessage().hint}

diff --git a/surfsense_web/hooks/use-inbox.ts b/surfsense_web/hooks/use-inbox.ts index 7ce33ac9a..7c421c341 100644 --- a/surfsense_web/hooks/use-inbox.ts +++ b/surfsense_web/hooks/use-inbox.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types"; import { authenticatedFetch } from "@/lib/auth-utils"; import type { SyncHandle } from "@/lib/electric/client"; @@ -79,7 +79,6 @@ export function useInbox( const electricClient = useElectricClient(); const [inboxItems, setInboxItems] = useState([]); - const [totalUnreadCount, setTotalUnreadCount] = useState(0); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [hasMore, setHasMore] = useState(true); @@ -87,9 +86,14 @@ export function useInbox( const syncHandleRef = useRef(null); const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null); - const unreadCountLiveQueryRef = useRef<{ unsubscribe: () => void } | null>(null); const userSyncKeyRef = useRef(null); + // Calculate unread count from inboxItems (includes both recent and older when loaded) + // This ensures the count is always in sync with what's displayed + const totalUnreadCount = useMemo(() => { + return inboxItems.filter((item) => !item.read).length; + }, [inboxItems]); + // EFFECT 1: Electric SQL sync for real-time updates useEffect(() => { if (!userId || !electricClient) { @@ -287,69 +291,6 @@ export function useInbox( }; }, [userId, searchSpaceId, typeFilter, electricClient]); - // EFFECT 3: Unread count with live updates - useEffect(() => { - if (!userId || !electricClient) return; - - const client = electricClient; - let mounted = true; - - async function updateUnreadCount() { - if (unreadCountLiveQueryRef.current) { - unreadCountLiveQueryRef.current.unsubscribe(); - unreadCountLiveQueryRef.current = null; - } - - try { - const cutoff = getSyncCutoffDate(); - const query = `SELECT COUNT(*) as count FROM notifications - WHERE user_id = $1 - AND (search_space_id = $2 OR search_space_id IS NULL) - AND read = false - AND created_at > '${cutoff}'`; - - const result = await client.db.query<{ count: number }>(query, [userId, searchSpaceId]); - if (mounted && result.rows?.[0]) { - setTotalUnreadCount(Number(result.rows[0].count) || 0); - } - - const db = client.db as any; - if (db.live?.query) { - const liveQuery = await db.live.query(query, [userId, searchSpaceId]); - - if (!mounted) { - liveQuery.unsubscribe?.(); - return; - } - - if (liveQuery.subscribe) { - liveQuery.subscribe((result: { rows: { count: number }[] }) => { - if (mounted && result.rows?.[0]) { - setTotalUnreadCount(Number(result.rows[0].count) || 0); - } - }); - } - - if (liveQuery.unsubscribe) { - unreadCountLiveQueryRef.current = liveQuery; - } - } - } catch (err) { - console.error("[useInbox] Unread count error:", err); - } - } - - updateUnreadCount(); - - return () => { - mounted = false; - if (unreadCountLiveQueryRef.current) { - unreadCountLiveQueryRef.current.unsubscribe(); - unreadCountLiveQueryRef.current = null; - } - }; - }, [userId, searchSpaceId, electricClient]); - // loadMore - Pure cursor-based pagination, no race conditions // Cursor is computed from current state, not stored in refs const loadMore = useCallback(async () => { @@ -408,30 +349,58 @@ export function useInbox( } }, [userId, searchSpaceId, typeFilter, loadingMore, hasMore, inboxItems]); - // Mark inbox item as read + // Mark inbox item as read with optimistic update const markAsRead = useCallback(async (itemId: number) => { + // Optimistic update: mark as read immediately for instant UI feedback + setInboxItems((prev) => + prev.map((item) => (item.id === itemId ? { ...item, read: true } : item)) + ); + try { const response = await authenticatedFetch( `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${itemId}/read`, { method: "PATCH" } ); + + if (!response.ok) { + // Rollback on error + setInboxItems((prev) => + prev.map((item) => (item.id === itemId ? { ...item, read: false } : item)) + ); + } + // If successful, Electric SQL will sync the change and live query will update + // This ensures eventual consistency even if optimistic update was wrong return response.ok; } catch (err) { console.error("Failed to mark as read:", err); + // Rollback on error + setInboxItems((prev) => + prev.map((item) => (item.id === itemId ? { ...item, read: false } : item)) + ); return false; } }, []); - // Mark all inbox items as read + // Mark all inbox items as read with optimistic update const markAllAsRead = useCallback(async () => { + // Optimistic update: mark all as read immediately for instant UI feedback + setInboxItems((prev) => prev.map((item) => ({ ...item, read: true }))); + try { const response = await authenticatedFetch( `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/read-all`, { method: "PATCH" } ); + + if (!response.ok) { + console.error("Failed to mark all as read"); + // On error, let Electric SQL sync correct the state + } + // Electric SQL will sync and live query will ensure consistency return response.ok; } catch (err) { console.error("Failed to mark all as read:", err); + // On error, let Electric SQL sync correct the state return false; } }, []);