Browse Source

improved profile timeline components for efficiency and consolidation

imwald
Silberengel 4 months ago
parent
commit
cfbceb72d4
  1. 135
      src/components/Profile/ProfileArticles.tsx
  2. 143
      src/components/Profile/ProfileFeed.tsx
  3. 135
      src/components/Profile/ProfileMedia.tsx
  4. 189
      src/components/Profile/ProfileTimeline.tsx

135
src/components/Profile/ProfileArticles.tsx

@ -1,17 +1,7 @@ @@ -1,17 +1,7 @@
import { ExtendedKind } from '@/constants'
import NoteCard from '@/components/NoteCard'
import { Skeleton } from '@/components/ui/skeleton'
import { useProfileTimeline } from '@/hooks/useProfileTimeline'
import { Event, kinds } from 'nostr-tools'
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react'
interface ProfileArticlesProps {
pubkey: string
topSpace?: number
searchQuery?: string
kindFilter?: string
onEventsChange?: (events: Event[]) => void
}
import { forwardRef, useMemo } from 'react'
import ProfileTimeline from './ProfileTimeline'
const ARTICLE_KINDS = [
kinds.LongFormArticle,
@ -21,64 +11,18 @@ const ARTICLE_KINDS = [ @@ -21,64 +11,18 @@ const ARTICLE_KINDS = [
kinds.Highlights
]
interface ProfileArticlesProps {
pubkey: string
topSpace?: number
searchQuery?: string
kindFilter?: string
onEventsChange?: (events: Event[]) => void
}
const ProfileArticles = forwardRef<{ refresh: () => void; getEvents: () => Event[] }, ProfileArticlesProps>(
({ pubkey, topSpace, searchQuery = '', kindFilter = 'all', onEventsChange }, ref) => {
const [isRefreshing, setIsRefreshing] = useState(false)
const cacheKey = useMemo(() => `${pubkey}-articles`, [pubkey])
const { events: timelineEvents, isLoading, refresh } = useProfileTimeline({
pubkey,
cacheKey,
kinds: ARTICLE_KINDS,
limit: 200
})
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])
const getKindLabel = (kindValue: string) => {
if (!kindValue || kindValue === 'all') return 'articles, publications, or highlights'
const kindNum = parseInt(kindValue, 10)
@ -90,52 +34,21 @@ const ProfileArticles = forwardRef<{ refresh: () => void; getEvents: () => Event @@ -90,52 +34,21 @@ const ProfileArticles = forwardRef<{ refresh: () => void; getEvents: () => Event
return 'items'
}
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()
? `No ${getKindLabel(kindFilter)} match your search`
: `No ${getKindLabel(kindFilter)} found`}
</div>
</div>
)
}
return (
<div style={{ marginTop: topSpace || 0 }}>
{isRefreshing && (
<div className="px-4 py-2 text-sm text-green-500 text-center">🔄 Refreshing articles...</div>
)}
{(searchQuery.trim() || (kindFilter && kindFilter !== 'all')) && (
<div className="px-4 py-2 text-sm text-muted-foreground">
{filteredEvents.length} of {eventsFilteredByKind.length} {getKindLabel(kindFilter)}
</div>
)}
<div className="space-y-2">
{filteredEvents.map((event) => (
<NoteCard key={event.id} className="w-full" event={event} filterMutedNotes={false} />
))}
</div>
</div>
<ProfileTimeline
ref={ref}
pubkey={pubkey}
topSpace={topSpace}
searchQuery={searchQuery}
kindFilter={kindFilter}
onEventsChange={onEventsChange}
kinds={ARTICLE_KINDS}
cacheKey={cacheKey}
getKindLabel={getKindLabel}
refreshLabel="Refreshing articles..."
emptyLabel="No articles found"
emptySearchLabel="No articles match your search"
/>
)
}
)

143
src/components/Profile/ProfileFeed.tsx

@ -1,19 +1,9 @@ @@ -1,19 +1,9 @@
import { ExtendedKind } from '@/constants'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
import NoteCard from '@/components/NoteCard'
import { Skeleton } from '@/components/ui/skeleton'
import { kinds, Event } from 'nostr-tools'
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react'
import { useProfileTimeline } from '@/hooks/useProfileTimeline'
import { forwardRef, useMemo } from 'react'
import { useZap } from '@/providers/ZapProvider'
interface ProfileFeedProps {
pubkey: string
topSpace?: number
searchQuery?: string
kindFilter?: string
onEventsChange?: (events: Event[]) => void
}
import ProfileTimeline from './ProfileTimeline'
const POST_KIND_LIST = [
kinds.ShortTextNote,
@ -24,10 +14,17 @@ const POST_KIND_LIST = [ @@ -24,10 +14,17 @@ const POST_KIND_LIST = [
ExtendedKind.ZAP_RECEIPT
]
const ProfileFeed = forwardRef<{ refresh: () => void }, ProfileFeedProps>(
interface ProfileFeedProps {
pubkey: string
topSpace?: number
searchQuery?: string
kindFilter?: string
onEventsChange?: (events: Event[]) => void
}
const ProfileFeed = forwardRef<{ refresh: () => void; getEvents?: () => Event[] }, ProfileFeedProps>(
({ pubkey, topSpace, searchQuery = '', kindFilter = 'all', onEventsChange }, ref) => {
const { zapReplyThreshold } = useZap()
const [isRefreshing, setIsRefreshing] = useState(false)
const filterPredicate = useMemo(
() => (event: Event) => {
@ -44,102 +41,34 @@ const ProfileFeed = forwardRef<{ refresh: () => void }, ProfileFeedProps>( @@ -44,102 +41,34 @@ const ProfileFeed = forwardRef<{ refresh: () => void }, ProfileFeedProps>(
const cacheKey = useMemo(() => `${pubkey}-posts-${zapReplyThreshold}`, [pubkey, zapReplyThreshold])
const { events: timelineEvents, isLoading, refresh } = useProfileTimeline({
pubkey,
cacheKey,
kinds: POST_KIND_LIST,
limit: 200,
filterPredicate
})
useEffect(() => {
onEventsChange?.(timelineEvents)
}, [timelineEvents, onEventsChange])
useEffect(() => {
if (!isLoading) {
setIsRefreshing(false)
}
}, [isLoading])
useImperativeHandle(
ref,
() => ({
refresh: () => {
setIsRefreshing(true)
refresh()
}
}),
[refresh]
)
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])
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() ? 'No posts match your search' : 'No posts found'}
</div>
</div>
)
const getKindLabel = (kindValue: string) => {
if (!kindValue || kindValue === 'all') return 'posts'
const kindNum = parseInt(kindValue, 10)
if (kindNum === kinds.ShortTextNote) return 'notes'
if (kindNum === kinds.Repost) return 'reposts'
if (kindNum === ExtendedKind.COMMENT) return 'comments'
if (kindNum === ExtendedKind.DISCUSSION) return 'discussions'
if (kindNum === ExtendedKind.POLL) return 'polls'
if (kindNum === ExtendedKind.ZAP_RECEIPT) return 'zaps'
return 'posts'
}
return (
<div style={{ marginTop: topSpace || 0 }}>
{isRefreshing && (
<div className="px-4 py-2 text-sm text-green-500 text-center">🔄 Refreshing posts...</div>
)}
{searchQuery.trim() && (
<div className="px-4 py-2 text-sm text-muted-foreground">
{filteredEvents.length} of {eventsFilteredByKind.length} posts
</div>
)}
<div className="space-y-2">
{filteredEvents.map((event) => (
<NoteCard key={event.id} className="w-full" event={event} filterMutedNotes={false} />
))}
</div>
</div>
<ProfileTimeline
ref={ref}
pubkey={pubkey}
topSpace={topSpace}
searchQuery={searchQuery}
kindFilter={kindFilter}
onEventsChange={onEventsChange}
kinds={POST_KIND_LIST}
cacheKey={cacheKey}
filterPredicate={filterPredicate}
getKindLabel={getKindLabel}
refreshLabel="Refreshing posts..."
emptyLabel="No posts found"
emptySearchLabel="No posts match your search"
/>
)
}
)

135
src/components/Profile/ProfileMedia.tsx

@ -1,17 +1,7 @@ @@ -1,17 +1,7 @@
import NoteCard from '@/components/NoteCard'
import { Skeleton } from '@/components/ui/skeleton'
import { useProfileTimeline } from '@/hooks/useProfileTimeline'
import { Event } from 'nostr-tools'
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react'
import { forwardRef, useMemo } from 'react'
import { ExtendedKind } from '@/constants'
interface ProfileMediaProps {
pubkey: string
topSpace?: number
searchQuery?: string
kindFilter?: string
onEventsChange?: (events: Event[]) => void
}
import ProfileTimeline from './ProfileTimeline'
const MEDIA_KIND_LIST = [
ExtendedKind.PICTURE,
@ -21,64 +11,18 @@ const MEDIA_KIND_LIST = [ @@ -21,64 +11,18 @@ const MEDIA_KIND_LIST = [
ExtendedKind.VOICE_COMMENT
]
interface ProfileMediaProps {
pubkey: string
topSpace?: number
searchQuery?: string
kindFilter?: string
onEventsChange?: (events: Event[]) => void
}
const ProfileMedia = forwardRef<{ refresh: () => void; getEvents: () => Event[] }, ProfileMediaProps>(
({ pubkey, topSpace, searchQuery = '', kindFilter = 'all', onEventsChange }, ref) => {
const [isRefreshing, setIsRefreshing] = useState(false)
const cacheKey = useMemo(() => `${pubkey}-media`, [pubkey])
const { events: timelineEvents, isLoading, refresh } = useProfileTimeline({
pubkey,
cacheKey,
kinds: MEDIA_KIND_LIST,
limit: 200
})
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])
const getKindLabel = (kindValue: string) => {
if (!kindValue || kindValue === 'all') return 'media items'
const kindNum = parseInt(kindValue, 10)
@ -90,52 +34,21 @@ const ProfileMedia = forwardRef<{ refresh: () => void; getEvents: () => Event[] @@ -90,52 +34,21 @@ const ProfileMedia = forwardRef<{ refresh: () => void; getEvents: () => Event[]
return 'media'
}
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()
? `No ${getKindLabel(kindFilter)} match your search`
: `No ${getKindLabel(kindFilter)} found`}
</div>
</div>
)
}
return (
<div style={{ marginTop: topSpace || 0 }}>
{isRefreshing && (
<div className="px-4 py-2 text-sm text-green-500 text-center">🔄 Refreshing media...</div>
)}
{(searchQuery.trim() || (kindFilter && kindFilter !== 'all')) && (
<div className="px-4 py-2 text-sm text-muted-foreground">
{filteredEvents.length} of {eventsFilteredByKind.length} {getKindLabel(kindFilter)}
</div>
)}
<div className="space-y-2">
{filteredEvents.map((event) => (
<NoteCard key={event.id} className="w-full" event={event} filterMutedNotes={false} />
))}
</div>
</div>
<ProfileTimeline
ref={ref}
pubkey={pubkey}
topSpace={topSpace}
searchQuery={searchQuery}
kindFilter={kindFilter}
onEventsChange={onEventsChange}
kinds={MEDIA_KIND_LIST}
cacheKey={cacheKey}
getKindLabel={getKindLabel}
refreshLabel="Refreshing media..."
emptyLabel="No media found"
emptySearchLabel="No media match your search"
/>
)
}
)

189
src/components/Profile/ProfileTimeline.tsx

@ -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…
Cancel
Save