mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-05 13:52:40 +02:00
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:
parent
9c2a1766f6
commit
36f1d28632
4 changed files with 137 additions and 13 deletions
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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-";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue