You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
189 lines
5.3 KiB
189 lines
5.3 KiB
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 |
|
|
|
|