mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-15 18:25:18 +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
|
// Inbox hook
|
||||||
const userId = user?.id ? String(user.id) : null;
|
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,
|
userId,
|
||||||
Number(searchSpaceId) || null,
|
Number(searchSpaceId) || null,
|
||||||
null
|
null
|
||||||
|
|
@ -549,6 +558,9 @@ export function LayoutDataProvider({
|
||||||
inboxItems={inboxItems}
|
inboxItems={inboxItems}
|
||||||
unreadCount={unreadCount}
|
unreadCount={unreadCount}
|
||||||
loading={inboxLoading}
|
loading={inboxLoading}
|
||||||
|
loadingMore={inboxLoadingMore}
|
||||||
|
hasMore={inboxHasMore}
|
||||||
|
loadMore={inboxLoadMore}
|
||||||
markAsRead={markAsRead}
|
markAsRead={markAsRead}
|
||||||
markAllAsRead={markAllAsRead}
|
markAllAsRead={markAllAsRead}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import {
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
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 { createPortal } from "react-dom";
|
||||||
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
|
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
|
@ -107,6 +107,9 @@ interface InboxSidebarProps {
|
||||||
inboxItems: InboxItem[];
|
inboxItems: InboxItem[];
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
loadingMore?: boolean;
|
||||||
|
hasMore?: boolean;
|
||||||
|
loadMore?: () => void;
|
||||||
markAsRead: (id: number) => Promise<boolean>;
|
markAsRead: (id: number) => Promise<boolean>;
|
||||||
markAllAsRead: () => Promise<boolean>;
|
markAllAsRead: () => Promise<boolean>;
|
||||||
onCloseMobileSidebar?: () => void;
|
onCloseMobileSidebar?: () => void;
|
||||||
|
|
@ -118,6 +121,9 @@ export function InboxSidebar({
|
||||||
inboxItems,
|
inboxItems,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
loading,
|
loading,
|
||||||
|
loadingMore = false,
|
||||||
|
hasMore = false,
|
||||||
|
loadMore,
|
||||||
markAsRead,
|
markAsRead,
|
||||||
markAllAsRead,
|
markAllAsRead,
|
||||||
onCloseMobileSidebar,
|
onCloseMobileSidebar,
|
||||||
|
|
@ -137,6 +143,9 @@ export function InboxSidebar({
|
||||||
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
||||||
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
|
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Prefetch trigger ref - placed on item near the end
|
||||||
|
const prefetchTriggerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -238,6 +247,32 @@ export function InboxSidebar({
|
||||||
return items;
|
return items;
|
||||||
}, [currentTabItems, activeFilter, activeTab, selectedConnector, searchQuery]);
|
}, [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
|
// Count unread items per tab
|
||||||
const unreadMentionsCount = useMemo(() => {
|
const unreadMentionsCount = useMemo(() => {
|
||||||
return mentionItems.filter((item) => !item.read).length;
|
return mentionItems.filter((item) => !item.read).length;
|
||||||
|
|
@ -685,12 +720,15 @@ export function InboxSidebar({
|
||||||
</div>
|
</div>
|
||||||
) : filteredItems.length > 0 ? (
|
) : filteredItems.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{filteredItems.map((item) => {
|
{filteredItems.map((item, index) => {
|
||||||
const isMarkingAsRead = markingAsReadId === item.id;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
ref={isPrefetchTrigger ? prefetchTriggerRef : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex items-center gap-3 rounded-lg px-3 py-3 text-sm h-[80px] overflow-hidden",
|
"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",
|
"hover:bg-accent hover:text-accent-foreground",
|
||||||
|
|
@ -742,6 +780,10 @@ export function InboxSidebar({
|
||||||
</div>
|
</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>
|
</div>
|
||||||
) : searchQuery ? (
|
) : searchQuery ? (
|
||||||
<div className="text-center py-8">
|
<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";
|
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
|
* 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:
|
* Architecture:
|
||||||
* - User-level sync: Syncs ALL inbox items for a user (runs once per user)
|
* - 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)
|
* - 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).
|
* This separation ensures smooth transitions when switching search spaces (no flash).
|
||||||
*
|
*
|
||||||
|
|
@ -35,10 +38,13 @@ export function useInbox(
|
||||||
const [inboxItems, setInboxItems] = useState<InboxItem[]>([]);
|
const [inboxItems, setInboxItems] = useState<InboxItem[]>([]);
|
||||||
const [totalUnreadCount, setTotalUnreadCount] = useState(0);
|
const [totalUnreadCount, setTotalUnreadCount] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
const syncHandleRef = useRef<SyncHandle | null>(null);
|
const syncHandleRef = useRef<SyncHandle | null>(null);
|
||||||
const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
|
const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
|
||||||
const unreadCountLiveQueryRef = 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
|
// Track user-level sync key to prevent duplicate sync subscriptions
|
||||||
const userSyncKeyRef = useRef<string | null>(null);
|
const userSyncKeyRef = useRef<string | null>(null);
|
||||||
|
|
@ -118,6 +124,13 @@ export function useInbox(
|
||||||
};
|
};
|
||||||
}, [userId, electricClient]);
|
}, [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
|
// EFFECT 2: Search-space-level query - updates when searchSpaceId or typeFilter changes
|
||||||
// This runs independently of sync, allowing smooth transitions between search spaces
|
// This runs independently of sync, allowing smooth transitions between search spaces
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -144,24 +157,28 @@ export function useInbox(
|
||||||
typeFilter
|
typeFilter
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build query with optional type filter
|
// Build query with optional type filter and LIMIT for pagination
|
||||||
// Note: Backend table is still named "notifications"
|
// Note: Backend table is still named "notifications"
|
||||||
const baseQuery = `SELECT * FROM notifications
|
const baseQuery = `SELECT * FROM notifications
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
AND (search_space_id = $2 OR search_space_id IS NULL)`;
|
AND (search_space_id = $2 OR search_space_id IS NULL)`;
|
||||||
const typeClause = typeFilter ? ` AND type = $3` : "";
|
const typeClause = typeFilter ? ` AND type = $3` : "";
|
||||||
const orderClause = ` ORDER BY created_at DESC`;
|
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];
|
const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
|
||||||
|
|
||||||
// Fetch inbox items for current search space immediately
|
// Fetch inbox items for current search space immediately
|
||||||
const result = await client.db.query<InboxItem>(fullQuery, params);
|
const result = await client.db.query<InboxItem>(fullQuery, params);
|
||||||
|
|
||||||
if (mounted) {
|
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;
|
const db = client.db as any;
|
||||||
|
|
||||||
if (db.live?.query && typeof db.live.query === "function") {
|
if (db.live?.query && typeof db.live.query === "function") {
|
||||||
|
|
@ -174,16 +191,36 @@ export function useInbox(
|
||||||
|
|
||||||
// Set initial results from live query
|
// Set initial results from live query
|
||||||
if (liveQuery.initialResults?.rows) {
|
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) {
|
} 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
|
// Subscribe to changes
|
||||||
if (typeof liveQuery.subscribe === "function") {
|
if (typeof liveQuery.subscribe === "function") {
|
||||||
liveQuery.subscribe((result: { rows: InboxItem[] }) => {
|
liveQuery.subscribe((result: { rows: InboxItem[] }) => {
|
||||||
if (mounted && result.rows) {
|
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]);
|
}, [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
|
// Mark inbox item as read via backend API
|
||||||
const markAsRead = useCallback(async (itemId: number) => {
|
const markAsRead = useCallback(async (itemId: number) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -338,6 +407,9 @@ export function useInbox(
|
||||||
markAsRead,
|
markAsRead,
|
||||||
markAllAsRead,
|
markAllAsRead,
|
||||||
loading,
|
loading,
|
||||||
|
loadingMore,
|
||||||
|
hasMore,
|
||||||
|
loadMore,
|
||||||
error,
|
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
|
// Version for sync state - increment this to force fresh sync when Electric config changes
|
||||||
// v2: user-specific database architecture
|
// v2: user-specific database architecture
|
||||||
// v3: added archived column to notifications
|
const SYNC_VERSION = 2;
|
||||||
// v4: removed archived column from notifications
|
|
||||||
const SYNC_VERSION = 4;
|
|
||||||
|
|
||||||
// Database name prefix for identifying SurfSense databases
|
// Database name prefix for identifying SurfSense databases
|
||||||
const DB_PREFIX = "surfsense-";
|
const DB_PREFIX = "surfsense-";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue