diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index aa7a06c81..16e6da4cc 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -87,7 +87,16 @@ export function LayoutDataProvider({ // Inbox hook const userId = user?.id ? String(user.id) : null; - const { inboxItems, unreadCount, loading: inboxLoading, markAsRead, markAllAsRead } = useInbox( + const { + inboxItems, + unreadCount, + loading: inboxLoading, + loadingMore: inboxLoadingMore, + hasMore: inboxHasMore, + loadMore: inboxLoadMore, + markAsRead, + markAllAsRead + } = useInbox( userId, Number(searchSpaceId) || null, null @@ -549,6 +558,9 @@ export function LayoutDataProvider({ inboxItems={inboxItems} unreadCount={unreadCount} loading={inboxLoading} + loadingMore={inboxLoadingMore} + hasMore={inboxHasMore} + loadMore={inboxLoadMore} markAsRead={markAsRead} markAllAsRead={markAllAsRead} /> diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index ebe537869..a0a0d1f5e 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -16,7 +16,7 @@ import { import { AnimatePresence, motion } from "motion/react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; @@ -107,6 +107,9 @@ interface InboxSidebarProps { inboxItems: InboxItem[]; unreadCount: number; loading: boolean; + loadingMore?: boolean; + hasMore?: boolean; + loadMore?: () => void; markAsRead: (id: number) => Promise; markAllAsRead: () => Promise; onCloseMobileSidebar?: () => void; @@ -118,6 +121,9 @@ export function InboxSidebar({ inboxItems, unreadCount, loading, + loadingMore = false, + hasMore = false, + loadMore, markAsRead, markAllAsRead, onCloseMobileSidebar, @@ -136,6 +142,9 @@ 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); useEffect(() => { setMounted(true); @@ -238,6 +247,32 @@ export function InboxSidebar({ return items; }, [currentTabItems, activeFilter, activeTab, selectedConnector, searchQuery]); + // Intersection Observer for infinite scroll with prefetching + // Only active when not searching (search results are client-side filtered) + useEffect(() => { + if (!loadMore || !hasMore || loadingMore || !open || searchQuery.trim()) return; + + const observer = new IntersectionObserver( + (entries) => { + // When trigger element is visible, load more + if (entries[0]?.isIntersecting) { + loadMore(); + } + }, + { + root: null, // viewport + rootMargin: "100px", // Start loading 100px before visible + threshold: 0, + } + ); + + if (prefetchTriggerRef.current) { + observer.observe(prefetchTriggerRef.current); + } + + return () => observer.disconnect(); + }, [loadMore, hasMore, loadingMore, open, searchQuery, filteredItems.length]); + // Count unread items per tab const unreadMentionsCount = useMemo(() => { return mentionItems.filter((item) => !item.read).length; @@ -685,12 +720,15 @@ export function InboxSidebar({ ) : filteredItems.length > 0 ? (
- {filteredItems.map((item) => { + {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 (
); })} + {/* Fallback trigger at the very end if less than 5 items and not searching */} + {!searchQuery && filteredItems.length < 5 && hasMore && ( +
+ )}
) : searchQuery ? (
diff --git a/surfsense_web/hooks/use-inbox.ts b/surfsense_web/hooks/use-inbox.ts index aa3de559d..c1541f71c 100644 --- a/surfsense_web/hooks/use-inbox.ts +++ b/surfsense_web/hooks/use-inbox.ts @@ -8,6 +8,8 @@ import { useElectricClient } from "@/lib/electric/context"; export type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types"; +const PAGE_SIZE = 50; // Items per batch + /** * Hook for managing inbox items with Electric SQL real-time sync * @@ -17,6 +19,7 @@ export type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types * Architecture: * - User-level sync: Syncs ALL inbox items for a user (runs once per user) * - Search-space-level query: Filters inbox items by searchSpaceId (updates on search space change) + * - Pagination: Loads items in batches for better performance with large datasets * * This separation ensures smooth transitions when switching search spaces (no flash). * @@ -35,10 +38,13 @@ export function useInbox( const [inboxItems, setInboxItems] = useState([]); const [totalUnreadCount, setTotalUnreadCount] = useState(0); const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = 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); + const offsetRef = useRef(0); // Track user-level sync key to prevent duplicate sync subscriptions const userSyncKeyRef = useRef(null); @@ -118,6 +124,13 @@ export function useInbox( }; }, [userId, electricClient]); + // Reset pagination when filters change + useEffect(() => { + offsetRef.current = 0; + setHasMore(true); + setInboxItems([]); + }, [userId, searchSpaceId, typeFilter]); + // EFFECT 2: Search-space-level query - updates when searchSpaceId or typeFilter changes // This runs independently of sync, allowing smooth transitions between search spaces useEffect(() => { @@ -144,24 +157,28 @@ export function useInbox( typeFilter ); - // Build query with optional type filter + // Build query with optional type filter and LIMIT for pagination // Note: Backend table is still named "notifications" 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 limitClause = ` LIMIT ${PAGE_SIZE}`; + const fullQuery = baseQuery + typeClause + orderClause + limitClause; const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId]; // Fetch inbox items for current search space immediately const result = await client.db.query(fullQuery, params); if (mounted) { - setInboxItems(result.rows || []); + const items = result.rows || []; + setInboxItems(items); + setHasMore(items.length === PAGE_SIZE); + offsetRef.current = items.length; } - // Set up live query for real-time updates + // Set up live query for real-time updates (first page only) const db = client.db as any; if (db.live?.query && typeof db.live.query === "function") { @@ -174,16 +191,36 @@ export function useInbox( // Set initial results from live query if (liveQuery.initialResults?.rows) { - setInboxItems(liveQuery.initialResults.rows); + const items = liveQuery.initialResults.rows; + setInboxItems(items); + setHasMore(items.length === PAGE_SIZE); + offsetRef.current = items.length; } else if (liveQuery.rows) { - setInboxItems(liveQuery.rows); + const items = liveQuery.rows; + setInboxItems(items); + setHasMore(items.length === PAGE_SIZE); + offsetRef.current = items.length; } // Subscribe to changes if (typeof liveQuery.subscribe === "function") { liveQuery.subscribe((result: { rows: InboxItem[] }) => { if (mounted && result.rows) { - setInboxItems(result.rows); + // Only update first page from live query + // Keep any additionally loaded items + setInboxItems(prev => { + if (prev.length <= PAGE_SIZE) { + const items = result.rows; + setHasMore(items.length === PAGE_SIZE); + offsetRef.current = items.length; + return items; + } + // Merge: new first page + existing extra items + const newFirstPage = result.rows; + const existingExtra = prev.slice(PAGE_SIZE); + offsetRef.current = newFirstPage.length + existingExtra.length; + return [...newFirstPage, ...existingExtra]; + }); } }); } @@ -290,6 +327,38 @@ export function useInbox( }; }, [userId, searchSpaceId, electricClient]); + // Load more items (for infinite scroll) + const loadMore = useCallback(async () => { + if (!userId || !electricClient || loadingMore || !hasMore) { + return; + } + + setLoadingMore(true); + const client = electricClient; + + try { + 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 limitOffsetClause = ` LIMIT ${PAGE_SIZE} OFFSET ${offsetRef.current}`; + const fullQuery = baseQuery + typeClause + orderClause + limitOffsetClause; + const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId]; + + const result = await client.db.query(fullQuery, params); + const newItems = result.rows || []; + + setInboxItems(prev => [...prev, ...newItems]); + setHasMore(newItems.length === PAGE_SIZE); + offsetRef.current += newItems.length; + } catch (err) { + console.error("[useInbox] Failed to load more:", err); + } finally { + setLoadingMore(false); + } + }, [userId, searchSpaceId, typeFilter, electricClient, loadingMore, hasMore]); + // Mark inbox item as read via backend API const markAsRead = useCallback(async (itemId: number) => { try { @@ -338,6 +407,9 @@ export function useInbox( markAsRead, markAllAsRead, loading, + loadingMore, + hasMore, + loadMore, error, }; } diff --git a/surfsense_web/lib/electric/client.ts b/surfsense_web/lib/electric/client.ts index 52f46d96d..7f7eb7552 100644 --- a/surfsense_web/lib/electric/client.ts +++ b/surfsense_web/lib/electric/client.ts @@ -54,9 +54,7 @@ const pendingSyncs = new Map>(); // Version for sync state - increment this to force fresh sync when Electric config changes // v2: user-specific database architecture -// v3: added archived column to notifications -// v4: removed archived column from notifications -const SYNC_VERSION = 4; +const SYNC_VERSION = 2; // Database name prefix for identifying SurfSense databases const DB_PREFIX = "surfsense-";