diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index c80dab01..a885495a 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -10,6 +10,7 @@ import { Loader2 } from 'lucide-react'; import LoginDialog from '@/components/auth/LoginDialog'; import { useOnboarding } from '@/components/InitialSyncGate'; import { useFeed } from '@/hooks/useFeed'; +import { useInfiniteSortedPosts } from '@/hooks/useTrending'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useMuteList } from '@/hooks/useMuteList'; import { isEventMuted } from '@/lib/muteHelpers'; @@ -69,28 +70,42 @@ export function Feed({ kinds, header, hideCompose, emptyMessage }: FeedProps = { } }, [user]); + // When logged out (and not on a kind-specific page), show the "hot" sorted + // feed instead of the noisy global feed so new visitors see quality content. + const useTopFeedForLoggedOut = !user && !kinds; + + // Standard feed query (used when logged in, or on kind-specific pages) const feedQuery = useFeed(activeTab, kinds ? { kinds } : undefined); - const queryKey = useMemo(() => ['feed', activeTab], [activeTab]); + + // "Hot" sorted feed query (used when logged out on the home page) + const topQuery = useInfiniteSortedPosts('hot', useTopFeedForLoggedOut); + + // Unify the two query shapes behind a single interface + const activeQuery = useTopFeedForLoggedOut ? topQuery : feedQuery; + const queryKey = useMemo( + () => useTopFeedForLoggedOut ? ['infinite-sorted-posts', 'hot'] : ['feed', activeTab], + [useTopFeedForLoggedOut, activeTab], + ); const handleRefresh = useCallback(async () => { await queryClient.invalidateQueries({ queryKey }); }, [queryClient, queryKey]); const { - data, + data: rawData, isPending, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage, - } = feedQuery; + } = activeQuery; // Auto-fetch page 2 as soon as page 1 arrives for smoother scrolling useEffect(() => { - if (hasNextPage && !isFetchingNextPage && data?.pages?.length === 1) { + if (hasNextPage && !isFetchingNextPage && rawData?.pages?.length === 1) { fetchNextPage(); } - }, [hasNextPage, isFetchingNextPage, data?.pages?.length, fetchNextPage]); + }, [hasNextPage, isFetchingNextPage, rawData?.pages?.length, fetchNextPage]); // Intersection observer for infinite scroll const { ref: scrollRef, inView } = useInView({ @@ -104,19 +119,40 @@ export function Feed({ kinds, header, hideCompose, emptyMessage }: FeedProps = { } }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); - // Flatten, deduplicate, and filter muted content + // Flatten, deduplicate, and filter muted content. + // The two query types have different page shapes: + // - useFeed returns { items: FeedItem[] } + // - useInfiniteSortedPosts returns NostrEvent[] const feedItems = useMemo(() => { + if (!rawData?.pages) return []; const seen = new Set(); - return data?.pages.flatMap(page => page.items).filter(item => { - const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id; - if (!key || seen.has(key)) return false; - seen.add(key); - if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false; - return true; - }) || []; - }, [data?.pages, muteItems]); - const showSkeleton = isPending || (isLoading && !data); + if (useTopFeedForLoggedOut) { + // Pages are NostrEvent[] + return (rawData.pages as unknown as import('@nostrify/nostrify').NostrEvent[][]) + .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; + }) + .map((event): FeedItem => ({ event, sortTimestamp: event.created_at })); + } + + // Pages are { items: FeedItem[] } + return (rawData.pages as unknown as { items: FeedItem[] }[]) + .flatMap((page) => page.items) + .filter((item) => { + const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id; + if (!key || seen.has(key)) return false; + seen.add(key); + if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false; + return true; + }); + }, [rawData?.pages, muteItems, useTopFeedForLoggedOut]); + + const showSkeleton = isPending || (isLoading && !rawData); return (
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 && ( +
+ +
+ )} +
+ )}
) : (