diff --git a/package.json b/package.json index ebffae6a..a746191d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index efd75949..5b3ec991 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -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(); for (const item of feedItems) { @@ -133,7 +129,7 @@ export function Feed() { {/* Infinite scroll sentinel */} {hasNextPage && ( -
+
{isFetchingNextPage && ( )} diff --git a/src/hooks/useFeed.ts b/src/hooks/useFeed.ts index 1c6eca35..723f0396 100644 --- a/src/hooks/useFeed.ts +++ b/src/hooks/useFeed.ts @@ -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({ 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 = { 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 = { 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, }); } diff --git a/src/hooks/useInfiniteScroll.ts b/src/hooks/useInfiniteScroll.ts new file mode 100644 index 00000000..a71b05d2 --- /dev/null +++ b/src/hooks/useInfiniteScroll.ts @@ -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(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; +} diff --git a/src/hooks/useProfileFeed.ts b/src/hooks/useProfileFeed.ts index 3c91efef..56360791 100644 --- a/src/hooks/useProfileFeed.ts +++ b/src/hooks/useProfileFeed.ts @@ -41,27 +41,19 @@ export function useProfileFeed(pubkey: string | undefined, tab: ProfileTab) { return useInfiniteQuery({ 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 = { - 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({ 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 = { - 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, }); } diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index aca0cd1a..4a4c3e6e 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -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 && ( -
+
{isFetchingMore && ( )}