diff --git a/src/hooks/useTrending.ts b/src/hooks/useTrending.ts index 833150dc..5903e16c 100644 --- a/src/hooks/useTrending.ts +++ b/src/hooks/useTrending.ts @@ -1,5 +1,5 @@ import { useNostr } from '@nostrify/react'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useInfiniteQuery } from '@tanstack/react-query'; import type { NostrEvent } from '@nostrify/nostrify'; import { useAppContext } from '@/hooks/useAppContext'; import { useNip85EventStats } from '@/hooks/useNip85Stats'; @@ -118,6 +118,48 @@ export function useSortedPosts(sort: SortMode, limit = 5, enabled = true) { }); } +const SORTED_PAGE_SIZE = 20; + +/** + * Fetches sorted posts with infinite scroll pagination. + * Uses NIP-50 search extensions with `until`-based cursor pagination + * against relay.ditto.pub. + */ +export function useInfiniteSortedPosts(sort: SortMode, enabled = true) { + const { nostr } = useNostr(); + + return useInfiniteQuery({ + queryKey: ['infinite-sorted-posts', sort], + queryFn: async ({ pageParam, signal }) => { + const ditto = nostr.relay(DITTO_RELAY); + const filter: Record = { + kinds: [1], + search: `sort:${sort}`, + limit: SORTED_PAGE_SIZE, + }; + if (pageParam) { + filter.until = pageParam; + } + + const events = await ditto.query( + [filter as { kinds: number[]; search: string; limit: number; until?: number }], + { signal: AbortSignal.any([signal, AbortSignal.timeout(10000)]) }, + ); + return events; + }, + getNextPageParam: (lastPage) => { + if (lastPage.length === 0) return undefined; + const oldest = lastPage[lastPage.length - 1].created_at; + return oldest - 1; + }, + initialPageParam: undefined as number | undefined, + enabled, + staleTime: 5 * 60 * 1000, + gcTime: 30 * 60 * 1000, + placeholderData: (prev) => prev, + }); +} + /** Fetches the latest kind 0 profiles seen on the relay. */ export function useLatestAccounts(enabled = true) { const { nostr } = useNostr(); diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index a4986838..884d182c 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -1,6 +1,7 @@ import { useSeoMeta } from '@unhead/react'; -import { ChevronUp, ChevronDown, Search as SearchIcon, Flame, TrendingUp, Swords, Image, Video, Film, Languages, UserRoundCheck } from 'lucide-react'; +import { ChevronUp, ChevronDown, Search as SearchIcon, Flame, TrendingUp, Swords, Image, Video, Film, Languages, UserRoundCheck, Loader2 } from 'lucide-react'; import { useState, useMemo, useEffect, useCallback } from 'react'; +import { useInView } from 'react-intersection-observer'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { NoteCard } from '@/components/NoteCard'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; @@ -14,7 +15,7 @@ import { Checkbox } from '@/components/ui/checkbox'; import { EmojifiedText } from '@/components/CustomEmoji'; import { useSearchProfiles } from '@/hooks/useSearchProfiles'; import { useStreamPosts } from '@/hooks/useStreamPosts'; -import { useTrendingTags, useSortedPosts, type SortMode } from '@/hooks/useTrending'; +import { useTrendingTags, useInfiniteSortedPosts, type SortMode } from '@/hooks/useTrending'; import { useMuteList } from '@/hooks/useMuteList'; import { isEventMuted } from '@/lib/muteHelpers'; import { genUserName } from '@/lib/genUserName'; @@ -106,13 +107,38 @@ export function SearchPage() { const { data: profiles, isLoading: profilesLoading, followedPubkeys } = useSearchProfiles(activeTab === 'accounts' ? searchQuery : ''); const isTrendsTab = activeTab === 'trends'; const { data: trends, isLoading: trendsLoading } = useTrendingTags(isTrendsTab); - const { data: rawSortedPosts, isLoading: sortedLoading } = useSortedPosts(trendSort, 5, isTrendsTab); + const { + data: sortedData, + isPending: sortedPending, + isLoading: sortedLoading, + fetchNextPage: fetchNextSorted, + hasNextPage: hasNextSorted, + isFetchingNextPage: isFetchingNextSorted, + } = useInfiniteSortedPosts(trendSort, isTrendsTab); const { muteItems } = useMuteList(); + // Flatten, deduplicate, and filter muted posts from paginated sorted results const sortedPosts = useMemo(() => { - if (!rawSortedPosts || muteItems.length === 0) return rawSortedPosts; - return rawSortedPosts.filter((e) => !isEventMuted(e, muteItems)); - }, [rawSortedPosts, muteItems]); + const seen = new Set(); + return sortedData?.pages.flat().filter((event) => { + if (seen.has(event.id)) return false; + seen.add(event.id); + if (muteItems.length > 0 && isEventMuted(event, muteItems)) return false; + return true; + }) ?? []; + }, [sortedData?.pages, muteItems]); + + // Intersection observer for infinite scroll on sorted posts + const { ref: sortedScrollRef, inView: sortedInView } = useInView({ + threshold: 0, + rootMargin: '400px', + }); + + useEffect(() => { + if (sortedInView && hasNextSorted && !isFetchingNextSorted) { + fetchNextSorted(); + } + }, [sortedInView, hasNextSorted, isFetchingNextSorted, fetchNextSorted]); // Filter by platform (Nostr/Mastodon) client-side const posts = useMemo(() => { @@ -313,18 +339,27 @@ export function SearchPage() { } label="Controversial" active={trendSort === 'controversial'} onClick={() => setTrendSort('controversial')} /> - {/* Sorted posts */} - {sortedLoading ? ( + {/* Sorted posts — infinite scroll */} + {(sortedPending || sortedLoading) && sortedPosts.length === 0 ? (
- {Array.from({ length: 3 }).map((_, i) => ( + {Array.from({ length: 5 }).map((_, i) => ( ))}
- ) : sortedPosts && sortedPosts.length > 0 ? ( + ) : sortedPosts.length > 0 ? (
- {sortedPosts.slice(0, 5).map((event) => ( + {sortedPosts.map((event) => ( ))} + {hasNextSorted && ( +
+ {isFetchingNextSorted && ( +
+ +
+ )} +
+ )}
) : (