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:
shakespeare.diy
2026-02-18 00:35:54 -06:00
parent 0f630fc9e8
commit ee69816dd9
6 changed files with 96 additions and 91 deletions
-1
View File
@@ -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
View File
@@ -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
View File
@@ -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,
});
}
+50
View File
@@ -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
View File
@@ -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,
});
}
+8 -17
View File
@@ -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" />
)}