Merge branch 'trends-infinite-scroll' into 'main'

Home page use Hot feed

See merge request soapbox-pub/ditto-mew!8
This commit is contained in:
Alex Gleason
2026-02-23 21:33:36 +00:00
3 changed files with 140 additions and 27 deletions
+51 -15
View File
@@ -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<string>();
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 (
<main className="flex-1 min-w-0 sidebar:max-w-[600px] sidebar:border-l xl:border-r border-border min-h-screen">
+43 -1
View File
@@ -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
View File
@@ -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.`} />