import NoteCard from '@/components/NoteCard' import { Skeleton } from '@/components/ui/skeleton' import { Event } from 'nostr-tools' import { forwardRef, useEffect, useImperativeHandle, useMemo, useState, useRef } from 'react' import { useProfileTimeline } from '@/hooks/useProfileTimeline' const INITIAL_SHOW_COUNT = 25 const LOAD_MORE_COUNT = 25 interface ProfileTimelineProps { pubkey: string topSpace?: number searchQuery?: string kindFilter?: string onEventsChange?: (events: Event[]) => void kinds: number[] cacheKey: string filterPredicate?: (event: Event) => boolean getKindLabel: (kindValue: string) => string refreshLabel: string emptyLabel: string emptySearchLabel: string } const ProfileTimeline = forwardRef< { refresh: () => void; getEvents?: () => Event[] }, ProfileTimelineProps >( ( { pubkey, topSpace, searchQuery = '', kindFilter = 'all', onEventsChange, kinds: timelineKinds, cacheKey, filterPredicate, getKindLabel, refreshLabel, emptyLabel, emptySearchLabel }, ref ) => { const [isRefreshing, setIsRefreshing] = useState(false) const [showCount, setShowCount] = useState(INITIAL_SHOW_COUNT) const bottomRef = useRef(null) const { events: timelineEvents, isLoading, refresh } = useProfileTimeline({ pubkey, cacheKey, kinds: timelineKinds, limit: 200, filterPredicate }) useEffect(() => { onEventsChange?.(timelineEvents) }, [timelineEvents, onEventsChange]) useEffect(() => { if (!isLoading) { setIsRefreshing(false) } }, [isLoading]) useImperativeHandle( ref, () => ({ refresh: () => { setIsRefreshing(true) refresh() }, getEvents: () => timelineEvents }), [refresh, timelineEvents] ) const eventsFilteredByKind = useMemo(() => { if (kindFilter === 'all') { return timelineEvents } const kindNumber = parseInt(kindFilter, 10) if (Number.isNaN(kindNumber)) { return timelineEvents } return timelineEvents.filter((event) => event.kind === kindNumber) }, [timelineEvents, kindFilter]) const filteredEvents = useMemo(() => { if (!searchQuery.trim()) { return eventsFilteredByKind } const query = searchQuery.toLowerCase() return eventsFilteredByKind.filter( (event) => event.content.toLowerCase().includes(query) || event.tags.some((tag) => tag.length > 1 && tag[1]?.toLowerCase().includes(query)) ) }, [eventsFilteredByKind, searchQuery]) // Reset showCount when filters change useEffect(() => { setShowCount(INITIAL_SHOW_COUNT) }, [searchQuery, kindFilter, pubkey]) // Pagination: slice to showCount for display const displayedEvents = useMemo(() => { return filteredEvents.slice(0, showCount) }, [filteredEvents, showCount]) // IntersectionObserver for infinite scroll useEffect(() => { if (!bottomRef.current || displayedEvents.length >= filteredEvents.length) return const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && displayedEvents.length < filteredEvents.length) { setShowCount((prev) => Math.min(prev + LOAD_MORE_COUNT, filteredEvents.length)) } }, { threshold: 0.1 } ) observer.observe(bottomRef.current) return () => { observer.disconnect() } }, [displayedEvents.length, filteredEvents.length]) if (!pubkey) { return (
No profile selected
) } if (isLoading && timelineEvents.length === 0) { return (
{Array.from({ length: 3 }).map((_, i) => ( ))}
) } if (!filteredEvents.length && !isLoading) { return (
{searchQuery.trim() ? emptySearchLabel : emptyLabel}
) } return (
{isRefreshing && (
🔄 {refreshLabel}
)} {(searchQuery.trim() || (kindFilter && kindFilter !== 'all')) && (
Showing {displayedEvents.length} of {filteredEvents.length} {getKindLabel(kindFilter)}
)}
{displayedEvents.map((event) => ( ))}
{displayedEvents.length < filteredEvents.length && (
Loading more...
)}
) } ) ProfileTimeline.displayName = 'ProfileTimeline' export default ProfileTimeline