diff --git a/src/components/Profile/ProfileArticles.tsx b/src/components/Profile/ProfileArticles.tsx
index 8093e70..aed111a 100644
--- a/src/components/Profile/ProfileArticles.tsx
+++ b/src/components/Profile/ProfileArticles.tsx
@@ -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 = [
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
return 'items'
}
- if (!pubkey) {
- return (
-
- )
- }
-
- if (isLoading && timelineEvents.length === 0) {
- return (
-
- {Array.from({ length: 3 }).map((_, i) => (
-
- ))}
-
- )
- }
-
- if (!filteredEvents.length && !isLoading) {
- return (
-
-
- {searchQuery.trim()
- ? `No ${getKindLabel(kindFilter)} match your search`
- : `No ${getKindLabel(kindFilter)} found`}
-
-
- )
- }
-
return (
-
- {isRefreshing && (
-
🔄 Refreshing articles...
- )}
- {(searchQuery.trim() || (kindFilter && kindFilter !== 'all')) && (
-
- {filteredEvents.length} of {eventsFilteredByKind.length} {getKindLabel(kindFilter)}
-
- )}
-
- {filteredEvents.map((event) => (
-
- ))}
-
-
+
)
}
)
diff --git a/src/components/Profile/ProfileFeed.tsx b/src/components/Profile/ProfileFeed.tsx
index fa717b7..c638d27 100644
--- a/src/components/Profile/ProfileFeed.tsx
+++ b/src/components/Profile/ProfileFeed.tsx
@@ -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 = [
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>(
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 (
-
- )
- }
-
- if (isLoading && timelineEvents.length === 0) {
- return (
-
- {Array.from({ length: 3 }).map((_, i) => (
-
- ))}
-
- )
- }
-
- if (!filteredEvents.length && !isLoading) {
- return (
-
-
- {searchQuery.trim() ? 'No posts match your search' : 'No posts found'}
-
-
- )
+ 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 (
-
- {isRefreshing && (
-
🔄 Refreshing posts...
- )}
- {searchQuery.trim() && (
-
- {filteredEvents.length} of {eventsFilteredByKind.length} posts
-
- )}
-
- {filteredEvents.map((event) => (
-
- ))}
-
-
+
)
}
)
diff --git a/src/components/Profile/ProfileMedia.tsx b/src/components/Profile/ProfileMedia.tsx
index 582ec96..336e26a 100644
--- a/src/components/Profile/ProfileMedia.tsx
+++ b/src/components/Profile/ProfileMedia.tsx
@@ -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 = [
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[]
return 'media'
}
- if (!pubkey) {
- return (
-
- )
- }
-
- if (isLoading && timelineEvents.length === 0) {
- return (
-
- {Array.from({ length: 3 }).map((_, i) => (
-
- ))}
-
- )
- }
-
- if (!filteredEvents.length && !isLoading) {
- return (
-
-
- {searchQuery.trim()
- ? `No ${getKindLabel(kindFilter)} match your search`
- : `No ${getKindLabel(kindFilter)} found`}
-
-
- )
- }
-
return (
-
- {isRefreshing && (
-
🔄 Refreshing media...
- )}
- {(searchQuery.trim() || (kindFilter && kindFilter !== 'all')) && (
-
- {filteredEvents.length} of {eventsFilteredByKind.length} {getKindLabel(kindFilter)}
-
- )}
-
- {filteredEvents.map((event) => (
-
- ))}
-
-
+
)
}
)
diff --git a/src/components/Profile/ProfileTimeline.tsx b/src/components/Profile/ProfileTimeline.tsx
new file mode 100644
index 0000000..cb62c0c
--- /dev/null
+++ b/src/components/Profile/ProfileTimeline.tsx
@@ -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(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 (
+
+ )
+ }
+
+ 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 && (
+
+ )}
+
+ )
+ }
+)
+
+ProfileTimeline.displayName = 'ProfileTimeline'
+
+export default ProfileTimeline
+