diff --git a/src/hooks/useProfileFeed.ts b/src/hooks/useProfileFeed.ts new file mode 100644 index 00000000..3c91efef --- /dev/null +++ b/src/hooks/useProfileFeed.ts @@ -0,0 +1,156 @@ +import { useNostr } from '@nostrify/react'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useFeedSettings } from './useFeedSettings'; +import { getEnabledFeedKinds } from '@/lib/extraKinds'; +import type { NostrEvent } from '@nostrify/nostrify'; + +const PAGE_SIZE = 30; + +export type ProfileTab = 'posts' | 'replies' | 'media' | 'likes'; + +/** Check if content contains media URLs. */ +function hasMedia(content: string): boolean { + return /https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg|mp4|webm|mov)(\?[^\s]*)?/i.test(content); +} + +/** Filter events by the active tab. */ +function filterByTab(events: NostrEvent[], tab: ProfileTab): NostrEvent[] { + switch (tab) { + case 'posts': + return events.filter((e) => !e.tags.some(([n]) => n === 'e')); + case 'replies': + return events; + case 'media': + return events.filter((e) => hasMedia(e.content)); + default: + return events; + } +} + +/** + * Infinite-scroll hook for profile posts/replies/media. + * Fetches paginated events for a given pubkey and tab. + */ +export function useProfileFeed(pubkey: string | undefined, tab: ProfileTab) { + const { nostr } = useNostr(); + const { feedSettings } = useFeedSettings(); + + const extraKinds = getEnabledFeedKinds(feedSettings); + const profileKinds = [1, ...extraKinds]; + const kindsKey = profileKinds.sort().join(','); + + return useInfiniteQuery({ + queryKey: ['profile-feed', pubkey ?? '', tab, kindsKey], + queryFn: async ({ pageParam, signal }) => { + if (!pubkey) return []; + + const querySignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]); + + // Fetch more than PAGE_SIZE because client-side filtering (e.g. "posts only" + // excludes replies, "media" excludes non-media) can discard many events. + const fetchLimit = tab === 'replies' ? PAGE_SIZE : PAGE_SIZE * 3; + + const filter: Record = { + kinds: profileKinds, + authors: [pubkey], + limit: fetchLimit, + }; + if (pageParam) { + filter.until = pageParam; + } + + const events = await nostr.query( + [filter as { kinds: number[]; authors: string[]; limit: number; until?: number }], + { signal: querySignal }, + ); + + const sorted = events.sort((a, b) => b.created_at - a.created_at); + return filterByTab(sorted, tab); + }, + getNextPageParam: (lastPage) => { + if (lastPage.length === 0) return undefined; + const oldest = lastPage[lastPage.length - 1]; + return oldest.created_at - 1; + }, + initialPageParam: undefined as number | undefined, + enabled: !!pubkey && tab !== 'likes', + staleTime: 30 * 1000, + }); +} + +/** Result from the likes query, including the cursor for pagination. */ +interface LikesPage { + events: NostrEvent[]; + /** The `created_at` of the oldest reaction in this page (used for cursor-based pagination). */ + oldestReactionTimestamp: number | undefined; +} + +/** + * Infinite-scroll hook for a profile's liked events. + * Paginates through kind 7 reactions, then resolves the liked events. + */ +export function useProfileLikes(pubkey: string | undefined, active: boolean) { + const { nostr } = useNostr(); + + return useInfiniteQuery({ + queryKey: ['profile-likes-infinite', pubkey ?? ''], + queryFn: async ({ pageParam, signal }) => { + if (!pubkey) return { events: [], oldestReactionTimestamp: undefined }; + + const querySignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]); + + const filter: Record = { + kinds: [7], + authors: [pubkey], + limit: PAGE_SIZE, + }; + if (pageParam) { + filter.until = pageParam; + } + + const reactions = await nostr.query( + [filter as { kinds: number[]; authors: string[]; limit: number; until?: number }], + { signal: querySignal }, + ); + + if (reactions.length === 0) return { events: [], oldestReactionTimestamp: undefined }; + + // Sort reactions newest → oldest + const sortedReactions = reactions.sort((a, b) => b.created_at - a.created_at); + const oldestReactionTimestamp = sortedReactions[sortedReactions.length - 1].created_at; + + // Extract the liked event IDs, preserving order + const likedIds: string[] = []; + for (const r of sortedReactions) { + const id = r.tags.find(([n]) => n === 'e')?.[1]; + if (id && !likedIds.includes(id)) { + likedIds.push(id); + } + } + + if (likedIds.length === 0) return { events: [], oldestReactionTimestamp }; + + // Fetch the original events + const events = await nostr.query( + [{ ids: likedIds, limit: likedIds.length }], + { signal: querySignal }, + ); + + // Sort by the reaction order (preserves the "liked at" timeline) + const idOrder = new Map(likedIds.map((id, i) => [id, i])); + const sorted = events.sort((a, b) => (idOrder.get(a.id) ?? 999) - (idOrder.get(b.id) ?? 999)); + + return { events: sorted, oldestReactionTimestamp }; + }, + getNextPageParam: (lastPage) => { + if (lastPage.events.length === 0 || lastPage.oldestReactionTimestamp === undefined) { + return undefined; + } + // Continue pagination from just before the oldest reaction + return lastPage.oldestReactionTimestamp - 1; + }, + initialPageParam: undefined as number | undefined, + enabled: !!pubkey && active, + staleTime: 30 * 1000, + }); +} diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 31e0de54..1758aafa 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -1,10 +1,9 @@ import { useEffect, useMemo, useState } from 'react'; import { useParams, Link } from 'react-router-dom'; +import { useInView } from 'react-intersection-observer'; import { useSeoMeta } from '@unhead/react'; import { nip19 } from 'nostr-tools'; -import { useNostr } from '@nostrify/react'; -import { useQuery } from '@tanstack/react-query'; -import { Zap, Flame, MoreHorizontal, ClipboardCopy, ExternalLink, VolumeX, Flag, Bitcoin, Users, Pin, X, QrCode, Check, Copy } from 'lucide-react'; +import { Zap, Flame, MoreHorizontal, ClipboardCopy, ExternalLink, VolumeX, Flag, Bitcoin, Users, Pin, X, QrCode, Check, Copy, Loader2 } from 'lucide-react'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; @@ -20,9 +19,9 @@ import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useToast } from '@/hooks/useToast'; import { useProfileFollowing } from '@/hooks/useProfileFollowing'; import { usePinnedNotes } from '@/hooks/usePinnedNotes'; -import { useFeedSettings } from '@/hooks/useFeedSettings'; import { useFollowList, useFollowActions } from '@/hooks/useFollowActions'; -import { getEnabledFeedKinds } from '@/lib/extraKinds'; +import { useProfileFeed, useProfileLikes as useProfileLikesInfinite } from '@/hooks/useProfileFeed'; +import type { ProfileTab } from '@/hooks/useProfileFeed'; import { genUserName } from '@/lib/genUserName'; import { cn } from '@/lib/utils'; import type { NostrEvent } from '@nostrify/nostrify'; @@ -31,8 +30,6 @@ import QRCode from 'qrcode'; const STREAK_WINDOW_HOURS = 24; const STREAK_DISPLAY_LIMIT = 99; -type ProfileTab = 'posts' | 'replies' | 'media' | 'likes'; - /** Calculate posting streak: consecutive kind 1 posts within 24-hour windows. */ function calculateStreak(posts: NostrEvent[]): number { if (!posts || posts.length === 0) return 0; @@ -71,47 +68,8 @@ function parseProfileFields(content: string): Array<{ label: string; value: stri return []; } -/** Extract image/video URLs from content. */ -function hasMedia(content: string): boolean { - return /https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg|mp4|webm|mov)(\?[^\s]*)?/i.test(content); -} - // useFollowList is now imported from @/hooks/useFollowActions -/** Hook to query a user's liked events (kind 7 reactions they've sent). */ -function useProfileLikes(pubkey: string | undefined) { - const { nostr } = useNostr(); - - return useQuery({ - queryKey: ['profile-likes', pubkey ?? ''], - queryFn: async ({ signal }) => { - if (!pubkey) return []; - // Get kind 7 reactions from this user - const reactions = await nostr.query( - [{ kinds: [7], authors: [pubkey], limit: 50 }], - { signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) }, - ); - - // Extract the event IDs they liked - const likedIds = reactions - .map((r) => r.tags.find(([n]) => n === 'e')?.[1]) - .filter((id): id is string => !!id); - - if (likedIds.length === 0) return []; - - // Fetch the original events - const events = await nostr.query( - [{ ids: likedIds, limit: likedIds.length }], - { signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) }, - ); - - return events.sort((a, b) => b.created_at - a.created_at); - }, - enabled: !!pubkey, - staleTime: 5 * 60 * 1000, - }); -} - // ----- Profile More Menu ----- interface ProfileMoreMenuProps { @@ -527,7 +485,6 @@ function PinnedLabel({ isOwn, onUnpin }: { isOwn: boolean; onUnpin: () => void } export function ProfilePage() { const { npub } = useParams<{ npub: string }>(); const { user } = useCurrentUser(); - const { nostr } = useNostr(); const { toast } = useToast(); @@ -568,30 +525,23 @@ export function ProfilePage() { description: metadata?.about || 'Nostr profile', }); - // Feed settings for extra kinds - const { feedSettings } = useFeedSettings(); - const profileExtraKinds = getEnabledFeedKinds(feedSettings); - const profileKinds = [1, ...profileExtraKinds]; - const profileKindsKey = profileKinds.sort().join(','); + // Infinite-scroll profile feed (posts/replies/media) + const { + data: feedData, + isPending: feedPending, + fetchNextPage: fetchNextFeedPage, + hasNextPage: hasNextFeedPage, + isFetchingNextPage: isFetchingNextFeedPage, + } = useProfileFeed(pubkey, activeTab); - // Fetch posts (kind 1 + enabled extra kinds) from this user - const { data: posts, isLoading: postsLoading } = useQuery({ - queryKey: ['profile-posts', pubkey ?? '', profileKindsKey], - queryFn: async ({ signal }) => { - if (!pubkey) return []; - const events = await nostr.query( - [{ kinds: profileKinds, authors: [pubkey], limit: 50 }], - { signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) }, - ); - return events.sort((a, b) => b.created_at - a.created_at); - }, - enabled: !!pubkey, - }); - - // Fetch likes - const { data: likedEvents, isLoading: likesLoading } = useProfileLikes( - activeTab === 'likes' ? pubkey : undefined, - ); + // Infinite-scroll likes + const { + data: likesData, + isPending: likesPending, + fetchNextPage: fetchNextLikesPage, + hasNextPage: hasNextLikesPage, + isFetchingNextPage: isFetchingNextLikesPage, + } = useProfileLikesInfinite(pubkey, activeTab === 'likes'); // Follow list (cached, for display checks only) const { data: followData } = useFollowList(); @@ -624,25 +574,55 @@ export function ProfilePage() { } }; - const streak = useMemo(() => calculateStreak(posts ?? []), [posts]); - - // Derived content for each tab - const filteredPosts = useMemo(() => { - if (!posts) return []; - switch (activeTab) { - case 'posts': - // Only top-level posts (no replies) - return posts.filter((e) => !e.tags.some(([n]) => n === 'e')); - case 'replies': - // All posts including replies - return posts; - case 'media': - // Posts with media - return posts.filter((e) => hasMedia(e.content)); - default: - return []; + // Flatten feed pages and deduplicate + const feedItems = useMemo(() => { + if (!feedData?.pages) return []; + const seen = new Set(); + const items: NostrEvent[] = []; + for (const page of feedData.pages) { + for (const event of page) { + if (!seen.has(event.id)) { + seen.add(event.id); + items.push(event); + } + } } - }, [posts, activeTab]); + return items; + }, [feedData?.pages]); + + // Flatten likes pages and deduplicate + const likedItems = useMemo(() => { + if (!likesData?.pages) return []; + const seen = new Set(); + const items: NostrEvent[] = []; + for (const page of likesData.pages) { + for (const event of page.events) { + if (!seen.has(event.id)) { + seen.add(event.id); + items.push(event); + } + } + } + return items; + }, [likesData?.pages]); + + const streak = useMemo(() => calculateStreak(feedItems), [feedItems]); + + // Infinite scroll sentinel + const { ref: scrollRef, inView } = useInView(); + + useEffect(() => { + if (!inView) return; + if (activeTab === 'likes') { + if (hasNextLikesPage && !isFetchingNextLikesPage) { + fetchNextLikesPage(); + } + } else { + if (hasNextFeedPage && !isFetchingNextFeedPage) { + fetchNextFeedPage(); + } + } + }, [inView, activeTab, hasNextFeedPage, isFetchingNextFeedPage, fetchNextFeedPage, hasNextLikesPage, isFetchingNextLikesPage, fetchNextLikesPage]); if (!pubkey) { return ( @@ -659,10 +639,10 @@ export function ProfilePage() { const isOwnProfile = user?.pubkey === pubkey; const authorEvent = author.data?.event; - const showPosts = activeTab === 'posts' || activeTab === 'replies' || activeTab === 'media'; - const currentPosts = showPosts ? filteredPosts : []; - const currentLoading = showPosts ? postsLoading : likesLoading; - const currentEvents = activeTab === 'likes' ? (likedEvents ?? []) : currentPosts; + const currentEvents = activeTab === 'likes' ? likedItems : feedItems; + const currentLoading = activeTab === 'likes' ? likesPending : feedPending; + const hasMore = activeTab === 'likes' ? hasNextLikesPage : hasNextFeedPage; + const isFetchingMore = activeTab === 'likes' ? isFetchingNextLikesPage : isFetchingNextFeedPage; return ( ) : currentEvents.length > 0 ? ( - currentEvents.map((event) => ) +
+ {currentEvents.map((event) => )} + + {/* Infinite scroll sentinel */} + {hasMore && ( +
+ {isFetchingMore && ( + + )} +
+ )} +
) : (
{activeTab === 'posts' && 'No posts yet.'}