From cfbceb72d4c1d4beb6218931c0c460027906ca9c Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 10 Nov 2025 14:29:27 +0100 Subject: [PATCH] improved profile timeline components for efficiency and consolidation --- src/components/Profile/ProfileArticles.tsx | 135 +++------------ src/components/Profile/ProfileFeed.tsx | 143 ++++------------ src/components/Profile/ProfileMedia.tsx | 135 +++------------ src/components/Profile/ProfileTimeline.tsx | 189 +++++++++++++++++++++ 4 files changed, 273 insertions(+), 329 deletions(-) create mode 100644 src/components/Profile/ProfileTimeline.tsx 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 ( -
-
No profile selected
-
- ) - } - - 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 ( -
-
No profile selected
-
- ) - } - - 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 ( -
-
No profile selected
-
- ) - } - - 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 ( +
+
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 +