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:
+51
-15
@@ -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">
|
||||
|
||||
@@ -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