feat: implement infinite scroll and pagination for inbox items

- Enhanced the inbox functionality by adding infinite scroll support in the InboxSidebar, allowing users to load more items seamlessly as they scroll.
- Updated the useInbox hook to manage pagination, including loading states and item counts, improving performance with large datasets.
- Introduced new props in InboxSidebar for loading more items, handling loading states, and indicating if more items are available.
- Refactored the LayoutDataProvider to accommodate the new inbox loading logic, ensuring a smooth user experience.
This commit is contained in:
Anish Sarkar 2026-01-22 11:27:45 +05:30
parent 9c2a1766f6
commit 36f1d28632
4 changed files with 137 additions and 13 deletions

View file

@ -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}
/>

View file

@ -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<boolean>;
markAllAsRead: () => Promise<boolean>;
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<number | null>(null);
// Prefetch trigger ref - placed on item near the end
const prefetchTriggerRef = useRef<HTMLDivElement>(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({
</div>
) : filteredItems.length > 0 ? (
<div className="space-y-2">
{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 (
<div
key={item.id}
ref={isPrefetchTrigger ? prefetchTriggerRef : undefined}
className={cn(
"group flex items-center gap-3 rounded-lg px-3 py-3 text-sm h-[80px] overflow-hidden",
"hover:bg-accent hover:text-accent-foreground",
@ -742,6 +780,10 @@ export function InboxSidebar({
</div>
);
})}
{/* Fallback trigger at the very end if less than 5 items and not searching */}
{!searchQuery && filteredItems.length < 5 && hasMore && (
<div ref={prefetchTriggerRef} className="h-1" />
)}
</div>
) : searchQuery ? (
<div className="text-center py-8">

View file

@ -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<InboxItem[]>([]);
const [totalUnreadCount, setTotalUnreadCount] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<Error | null>(null);
const syncHandleRef = useRef<SyncHandle | null>(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<string | null>(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<InboxItem>(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<InboxItem>(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,
};
}

View file

@ -54,9 +54,7 @@ const pendingSyncs = new Map<string, Promise<SyncHandle>>();
// 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-";