4 changed files with 273 additions and 329 deletions
@ -0,0 +1,189 @@
@@ -0,0 +1,189 @@
|
||||
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<HTMLDivElement>(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 ( |
||||
<div className="flex justify-center items-center py-8"> |
||||
<div className="text-sm text-muted-foreground">No profile selected</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
if (isLoading && timelineEvents.length === 0) { |
||||
return ( |
||||
<div className="space-y-2"> |
||||
{Array.from({ length: 3 }).map((_, i) => ( |
||||
<Skeleton key={i} className="h-32 w-full" /> |
||||
))} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
if (!filteredEvents.length && !isLoading) { |
||||
return ( |
||||
<div className="flex justify-center items-center py-8"> |
||||
<div className="text-sm text-muted-foreground"> |
||||
{searchQuery.trim() ? emptySearchLabel : emptyLabel} |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<div style={{ marginTop: topSpace || 0 }}> |
||||
{isRefreshing && ( |
||||
<div className="px-4 py-2 text-sm text-green-500 text-center">🔄 {refreshLabel}</div> |
||||
)} |
||||
{(searchQuery.trim() || (kindFilter && kindFilter !== 'all')) && ( |
||||
<div className="px-4 py-2 text-sm text-muted-foreground"> |
||||
Showing {displayedEvents.length} of {filteredEvents.length} {getKindLabel(kindFilter)} |
||||
</div> |
||||
)} |
||||
<div className="space-y-2"> |
||||
{displayedEvents.map((event) => ( |
||||
<NoteCard key={event.id} className="w-full" event={event} filterMutedNotes={false} /> |
||||
))} |
||||
</div> |
||||
{displayedEvents.length < filteredEvents.length && ( |
||||
<div ref={bottomRef} className="h-10 flex items-center justify-center"> |
||||
<div className="text-sm text-muted-foreground">Loading more...</div> |
||||
</div> |
||||
)} |
||||
</div> |
||||
) |
||||
} |
||||
) |
||||
|
||||
ProfileTimeline.displayName = 'ProfileTimeline' |
||||
|
||||
export default ProfileTimeline |
||||
|
||||
Loading…
Reference in new issue