Adopt Agora's faster feed loading pattern
- Add useInfiniteScroll hook using native IntersectionObserver (400px rootMargin) instead of react-intersection-observer library - Rewrite useFeed: 5s timeout (was 8s), AbortSignal.any pattern, placeholderData to prevent flicker, cleaner filter construction with spread - Rewrite Feed.tsx to use useInfiniteScroll ref instead of useInView - Update ProfilePage.tsx to use useInfiniteScroll for both feed and likes tabs - Update useProfileFeed: 5s timeout, placeholderData, cleaner filter construction - Remove react-intersection-observer dependency (replaced by native hook) Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
@@ -59,7 +59,6 @@
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-intersection-observer": "^9.16.0",
|
||||
"react-resizable-panels": "^2.1.3",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"recharts": "^2.12.7",
|
||||
|
||||
+9
-13
@@ -1,5 +1,4 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { ComposeBox } from '@/components/ComposeBox';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
@@ -10,6 +9,7 @@ import SignupDialog from '@/components/auth/SignupDialog';
|
||||
import { useFeed } from '@/hooks/useFeed';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthors } from '@/hooks/useAuthors';
|
||||
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { FeedItem } from '@/hooks/useFeed';
|
||||
|
||||
@@ -34,13 +34,12 @@ export function Feed() {
|
||||
isFetchingNextPage,
|
||||
} = useFeed(activeTab);
|
||||
|
||||
const { ref, inView } = useInView();
|
||||
|
||||
useEffect(() => {
|
||||
if (inView && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
// Agora-style infinite scroll with native IntersectionObserver
|
||||
const observerTarget = useInfiniteScroll({
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
});
|
||||
|
||||
// Flatten pages and deduplicate by event id
|
||||
const feedItems = useMemo(() => {
|
||||
@@ -59,10 +58,7 @@ export function Feed() {
|
||||
return items;
|
||||
}, [data?.pages]);
|
||||
|
||||
// Batch-prefetch all author profiles in a single relay query instead of
|
||||
// firing N individual useAuthor() calls from each NoteCard. The results
|
||||
// are seeded into the ['author', pubkey] cache so NoteCard's own
|
||||
// useAuthor() resolves instantly from cache.
|
||||
// Batch-prefetch all author profiles
|
||||
const feedPubkeys = useMemo(() => {
|
||||
const keys = new Set<string>();
|
||||
for (const item of feedItems) {
|
||||
@@ -133,7 +129,7 @@ export function Feed() {
|
||||
|
||||
{/* Infinite scroll sentinel */}
|
||||
{hasNextPage && (
|
||||
<div ref={ref} className="flex justify-center py-6">
|
||||
<div ref={observerTarget} className="flex justify-center py-6">
|
||||
{isFetchingNextPage && (
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
|
||||
+14
-31
@@ -6,7 +6,7 @@ import { useFollowList } from './useFollowActions';
|
||||
import { getEnabledFeedKinds } from '@/lib/extraKinds';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
/** The base kinds always included in every feed query. */
|
||||
const BASE_FEED_KINDS = [1, 6];
|
||||
@@ -46,34 +46,26 @@ export function useFeed(tab: 'follows' | 'global') {
|
||||
const followList = followData?.pubkeys;
|
||||
const { feedSettings } = useFeedSettings();
|
||||
|
||||
// Build the full kinds list: base kinds + user-selected extra kinds.
|
||||
const extraKinds = getEnabledFeedKinds(feedSettings);
|
||||
const allKinds = [...BASE_FEED_KINDS, ...extraKinds];
|
||||
|
||||
// Stable key for the extra kinds so queries re-run when settings change.
|
||||
const extraKindsKey = extraKinds.sort().join(',');
|
||||
|
||||
// For the follows tab, wait until the follow list is loaded before running any query.
|
||||
// Without this guard, the query falls through to the global branch while followList is still loading.
|
||||
const followsReady = tab !== 'follows' || (!!user && !!followList && followList.length > 0);
|
||||
|
||||
return useInfiniteQuery<FeedItem[], Error>({
|
||||
queryKey: ['feed', tab, user?.pubkey ?? '', followList?.length ?? 0, extraKindsKey],
|
||||
queryFn: async ({ pageParam, signal }) => {
|
||||
const querySignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
|
||||
queryFn: async ({ pageParam, signal: querySignal }) => {
|
||||
const signal = AbortSignal.any([querySignal, AbortSignal.timeout(5000)]);
|
||||
const until = pageParam as number | undefined;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (tab === 'follows' && user && followList && followList.length > 0) {
|
||||
// Follows feed — posts, reposts, and extra kinds from people you follow
|
||||
// Follows feed
|
||||
const authors = [...followList, user.pubkey];
|
||||
const filter: Record<string, unknown> = { kinds: allKinds, authors, limit: PAGE_SIZE };
|
||||
if (pageParam) {
|
||||
filter.until = pageParam;
|
||||
}
|
||||
|
||||
const events = await nostr.query(
|
||||
[filter as { kinds: number[]; authors: string[]; limit: number; until?: number }],
|
||||
{ signal: querySignal },
|
||||
[{ kinds: allKinds, authors, limit: PAGE_SIZE, ...(until && { until }) }],
|
||||
{ signal },
|
||||
);
|
||||
|
||||
const items: FeedItem[] = [];
|
||||
@@ -84,7 +76,6 @@ export function useFeed(tab: 'follows' | 'global') {
|
||||
if (ev.created_at > now) continue;
|
||||
|
||||
if (ev.kind === 6) {
|
||||
// Handle reposts
|
||||
const embedded = parseRepostContent(ev);
|
||||
if (embedded && embedded.kind === 1 && embedded.created_at <= now) {
|
||||
items.push({ event: embedded, repostedBy: ev.pubkey, sortTimestamp: ev.created_at });
|
||||
@@ -96,7 +87,6 @@ export function useFeed(tab: 'follows' | 'global') {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Kind 1, 1068, 3367, 34236, 37516, etc. — direct post / extra kinds
|
||||
items.push({ event: ev, sortTimestamp: ev.created_at });
|
||||
}
|
||||
}
|
||||
@@ -106,7 +96,7 @@ export function useFeed(tab: 'follows' | 'global') {
|
||||
try {
|
||||
const originals = await nostr.query(
|
||||
[{ ids: repostMissingIds, limit: repostMissingIds.length }],
|
||||
{ signal: querySignal },
|
||||
{ signal },
|
||||
);
|
||||
for (const original of originals) {
|
||||
const repost = repostMap.get(original.id);
|
||||
@@ -115,7 +105,7 @@ export function useFeed(tab: 'follows' | 'global') {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// timeout or abort — just skip the missing reposts
|
||||
// timeout or abort — skip missing reposts
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,16 +122,11 @@ export function useFeed(tab: 'follows' | 'global') {
|
||||
|
||||
return Array.from(seen.values()).sort((a, b) => b.sortTimestamp - a.sortTimestamp);
|
||||
} else {
|
||||
// Global feed — kind 1 notes + user-selected extra kinds
|
||||
// Global feed
|
||||
const globalKinds = [1, ...extraKinds];
|
||||
const filter: Record<string, unknown> = { kinds: globalKinds, limit: PAGE_SIZE };
|
||||
if (pageParam) {
|
||||
filter.until = pageParam;
|
||||
}
|
||||
|
||||
const events = await nostr.query(
|
||||
[filter as { kinds: number[]; limit: number; until?: number }],
|
||||
{ signal: querySignal },
|
||||
[{ kinds: globalKinds, limit: PAGE_SIZE, ...(until && { until }) }],
|
||||
{ signal },
|
||||
);
|
||||
|
||||
return events
|
||||
@@ -152,14 +137,12 @@ export function useFeed(tab: 'follows' | 'global') {
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (lastPage.length === 0) return undefined;
|
||||
// Use the oldest item's sortTimestamp minus 1 (since `until` is inclusive)
|
||||
const oldest = lastPage[lastPage.length - 1];
|
||||
return oldest.sortTimestamp - 1;
|
||||
},
|
||||
initialPageParam: undefined as number | undefined,
|
||||
enabled: followsReady,
|
||||
staleTime: 30 * 1000,
|
||||
refetchInterval: 60 * 1000,
|
||||
placeholderData: (previousData) => previousData, // Keep showing previous data while refetching (avoids flicker)
|
||||
staleTime: 30_000,
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface UseInfiniteScrollOptions {
|
||||
hasNextPage?: boolean;
|
||||
isFetchingNextPage?: boolean;
|
||||
fetchNextPage?: () => void;
|
||||
rootMargin?: string;
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to trigger infinite scroll when user reaches the bottom of the page.
|
||||
* Uses Intersection Observer API for efficient scroll detection.
|
||||
*/
|
||||
export function useInfiniteScroll({
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
rootMargin = '400px',
|
||||
threshold = 0.1,
|
||||
}: UseInfiniteScrollOptions) {
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const target = observerTarget.current;
|
||||
if (!target || !hasNextPage || isFetchingNextPage || !fetchNextPage) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin,
|
||||
threshold,
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(target);
|
||||
|
||||
return () => {
|
||||
if (target) {
|
||||
observer.unobserve(target);
|
||||
}
|
||||
};
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage, rootMargin, threshold]);
|
||||
|
||||
return observerTarget;
|
||||
}
|
||||
+15
-29
@@ -41,27 +41,19 @@ export function useProfileFeed(pubkey: string | undefined, tab: ProfileTab) {
|
||||
|
||||
return useInfiniteQuery<NostrEvent[], Error>({
|
||||
queryKey: ['profile-feed', pubkey ?? '', tab, kindsKey],
|
||||
queryFn: async ({ pageParam, signal }) => {
|
||||
queryFn: async ({ pageParam, signal: querySignal }) => {
|
||||
if (!pubkey) return [];
|
||||
|
||||
const querySignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
|
||||
const signal = AbortSignal.any([querySignal, AbortSignal.timeout(5000)]);
|
||||
|
||||
// Fetch more than PAGE_SIZE because client-side filtering (e.g. "posts only"
|
||||
// excludes replies, "media" excludes non-media) can discard many events.
|
||||
const fetchLimit = tab === 'replies' ? PAGE_SIZE : PAGE_SIZE * 3;
|
||||
|
||||
const filter: Record<string, unknown> = {
|
||||
kinds: profileKinds,
|
||||
authors: [pubkey],
|
||||
limit: fetchLimit,
|
||||
};
|
||||
if (pageParam) {
|
||||
filter.until = pageParam;
|
||||
}
|
||||
|
||||
const until = pageParam as number | undefined;
|
||||
const events = await nostr.query(
|
||||
[filter as { kinds: number[]; authors: string[]; limit: number; until?: number }],
|
||||
{ signal: querySignal },
|
||||
[{ kinds: profileKinds, authors: [pubkey], limit: fetchLimit, ...(until && { until }) }],
|
||||
{ signal },
|
||||
);
|
||||
|
||||
const sorted = events.sort((a, b) => b.created_at - a.created_at);
|
||||
@@ -74,7 +66,8 @@ export function useProfileFeed(pubkey: string | undefined, tab: ProfileTab) {
|
||||
},
|
||||
initialPageParam: undefined as number | undefined,
|
||||
enabled: !!pubkey && tab !== 'likes',
|
||||
staleTime: 30 * 1000,
|
||||
staleTime: 30_000,
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -94,23 +87,15 @@ export function useProfileLikes(pubkey: string | undefined, active: boolean) {
|
||||
|
||||
return useInfiniteQuery<LikesPage, Error>({
|
||||
queryKey: ['profile-likes-infinite', pubkey ?? ''],
|
||||
queryFn: async ({ pageParam, signal }) => {
|
||||
queryFn: async ({ pageParam, signal: querySignal }) => {
|
||||
if (!pubkey) return { events: [], oldestReactionTimestamp: undefined };
|
||||
|
||||
const querySignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
|
||||
|
||||
const filter: Record<string, unknown> = {
|
||||
kinds: [7],
|
||||
authors: [pubkey],
|
||||
limit: PAGE_SIZE,
|
||||
};
|
||||
if (pageParam) {
|
||||
filter.until = pageParam;
|
||||
}
|
||||
const signal = AbortSignal.any([querySignal, AbortSignal.timeout(5000)]);
|
||||
const until = pageParam as number | undefined;
|
||||
|
||||
const reactions = await nostr.query(
|
||||
[filter as { kinds: number[]; authors: string[]; limit: number; until?: number }],
|
||||
{ signal: querySignal },
|
||||
[{ kinds: [7], authors: [pubkey], limit: PAGE_SIZE, ...(until && { until }) }],
|
||||
{ signal },
|
||||
);
|
||||
|
||||
if (reactions.length === 0) return { events: [], oldestReactionTimestamp: undefined };
|
||||
@@ -133,7 +118,7 @@ export function useProfileLikes(pubkey: string | undefined, active: boolean) {
|
||||
// Fetch the original events
|
||||
const events = await nostr.query(
|
||||
[{ ids: likedIds, limit: likedIds.length }],
|
||||
{ signal: querySignal },
|
||||
{ signal },
|
||||
);
|
||||
|
||||
// Sort by the reaction order (preserves the "liked at" timeline)
|
||||
@@ -151,6 +136,7 @@ export function useProfileLikes(pubkey: string | undefined, active: boolean) {
|
||||
},
|
||||
initialPageParam: undefined as number | undefined,
|
||||
enabled: !!pubkey && active,
|
||||
staleTime: 30 * 1000,
|
||||
staleTime: 30_000,
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Zap, Flame, MoreHorizontal, ClipboardCopy, ExternalLink, VolumeX, Flag, Bitcoin, Users, Pin, X, QrCode, Check, Copy, Loader2 } from 'lucide-react';
|
||||
@@ -21,6 +20,7 @@ import { useProfileFollowing } from '@/hooks/useProfileFollowing';
|
||||
import { usePinnedNotes } from '@/hooks/usePinnedNotes';
|
||||
import { useFollowList, useFollowActions } from '@/hooks/useFollowActions';
|
||||
import { useProfileFeed, useProfileLikes as useProfileLikesInfinite } from '@/hooks/useProfileFeed';
|
||||
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
|
||||
import type { ProfileTab } from '@/hooks/useProfileFeed';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { cn, STICKY_HEADER_CLASS } from '@/lib/utils';
|
||||
@@ -608,21 +608,12 @@ export function ProfilePage() {
|
||||
|
||||
const streak = useMemo(() => calculateStreak(feedItems), [feedItems]);
|
||||
|
||||
// Infinite scroll sentinel
|
||||
const { ref: scrollRef, inView } = useInView();
|
||||
|
||||
useEffect(() => {
|
||||
if (!inView) return;
|
||||
if (activeTab === 'likes') {
|
||||
if (hasNextLikesPage && !isFetchingNextLikesPage) {
|
||||
fetchNextLikesPage();
|
||||
}
|
||||
} else {
|
||||
if (hasNextFeedPage && !isFetchingNextFeedPage) {
|
||||
fetchNextFeedPage();
|
||||
}
|
||||
}
|
||||
}, [inView, activeTab, hasNextFeedPage, isFetchingNextFeedPage, fetchNextFeedPage, hasNextLikesPage, isFetchingNextLikesPage, fetchNextLikesPage]);
|
||||
// Infinite scroll — uses native IntersectionObserver (Agora pattern)
|
||||
const observerTarget = useInfiniteScroll({
|
||||
hasNextPage: activeTab === 'likes' ? hasNextLikesPage : hasNextFeedPage,
|
||||
isFetchingNextPage: activeTab === 'likes' ? isFetchingNextLikesPage : isFetchingNextFeedPage,
|
||||
fetchNextPage: activeTab === 'likes' ? fetchNextLikesPage : fetchNextFeedPage,
|
||||
});
|
||||
|
||||
if (!pubkey) {
|
||||
return (
|
||||
@@ -799,7 +790,7 @@ export function ProfilePage() {
|
||||
|
||||
{/* Infinite scroll sentinel */}
|
||||
{hasMore && (
|
||||
<div ref={scrollRef} className="flex justify-center py-6">
|
||||
<div ref={observerTarget} className="flex justify-center py-6">
|
||||
{isFetchingMore && (
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user