Add infinite scroll pagination to trends feeds (hot, rising, controversial)
This commit is contained in:
@@ -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<NostrEvent[], Error>({
|
||||
queryKey: ['infinite-sorted-posts', sort],
|
||||
queryFn: async ({ pageParam, signal }) => {
|
||||
const ditto = nostr.relay(DITTO_RELAY);
|
||||
const filter: Record<string, unknown> = {
|
||||
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();
|
||||
|
||||
+46
-11
@@ -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<string>();
|
||||
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() {
|
||||
<SortTabButton icon={<Swords className="size-4" />} label="Controversial" active={trendSort === 'controversial'} onClick={() => setTrendSort('controversial')} />
|
||||
</div>
|
||||
|
||||
{/* Sorted posts */}
|
||||
{sortedLoading ? (
|
||||
{/* Sorted posts — infinite scroll */}
|
||||
{(sortedPending || sortedLoading) && sortedPosts.length === 0 ? (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<PostSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : sortedPosts && sortedPosts.length > 0 ? (
|
||||
) : sortedPosts.length > 0 ? (
|
||||
<div>
|
||||
{sortedPosts.slice(0, 5).map((event) => (
|
||||
{sortedPosts.map((event) => (
|
||||
<NoteCard key={event.id} event={event} />
|
||||
))}
|
||||
{hasNextSorted && (
|
||||
<div ref={sortedScrollRef} className="py-4">
|
||||
{isFetchingNextSorted && (
|
||||
<div className="flex justify-center">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState message={`No ${trendSort} posts right now.`} />
|
||||
|
||||
Reference in New Issue
Block a user