import NoteCard from '@/components/NoteCard' import ProfileSearchBar from '@/components/ui/ProfileSearchBar' import { Skeleton } from '@/components/ui/skeleton' import { ExtendedKind, PROFILE_FEED_KINDS } from '@/constants' import { isReplyNoteEvent } from '@/lib/event' import { getZapInfoFromEvent } from '@/lib/event-metadata' import { useProfilePins } from '@/hooks/useProfilePins' import { useProfileTimeline } from '@/hooks/useProfileTimeline' import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useKindFilter } from '@/providers/KindFilterProvider' import { useZap } from '@/providers/ZapProvider' import client from '@/services/client.service' import storage from '@/services/local-storage.service' import { RefreshCw } from 'lucide-react' import { Event, kinds } from 'nostr-tools' import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' const INITIAL_SHOW_COUNT = 25 const LOAD_MORE_COUNT = 25 function useHideRepliesLikeMainFeed() { const [hideReplies, setHideReplies] = useState(() => { const m = storage.getNoteListMode() return m !== 'postsAndReplies' }) useEffect(() => { const sync = () => { const m = storage.getNoteListMode() setHideReplies(m !== 'postsAndReplies') } window.addEventListener('noteListModeChanged', sync) return () => window.removeEventListener('noteListModeChanged', sync) }, []) return hideReplies } const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string }>(({ pubkey }, ref) => { const { t } = useTranslation() const { isEventDeleted } = useDeletedEvent() const { zapReplyThreshold } = useZap() const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilter() /** Profile timelines always show reposts; global kind filter still applies to other kinds. */ const profileTimelineShowKinds = useMemo(() => { if (showKinds.includes(kinds.Repost)) return showKinds return [...showKinds, kinds.Repost].sort((a, b) => a - b) }, [showKinds]) const hideReplies = useHideRepliesLikeMainFeed() const [searchQuery, setSearchQuery] = useState('') const [isRefreshing, setIsRefreshing] = useState(false) const [showCount, setShowCount] = useState(INITIAL_SHOW_COUNT) const bottomRef = useRef(null) const { pinEvents, loadingPins, refreshPins } = useProfilePins(pubkey) const filterPredicate = useCallback( (event: Event) => { if (event.kind === ExtendedKind.ZAP_RECEIPT) { const zapInfo = getZapInfoFromEvent(event) if (!zapInfo?.amount || zapInfo.amount < zapReplyThreshold) { return false } } return true }, [zapReplyThreshold] ) const cacheKey = useMemo(() => `${pubkey}-profile-unified-${zapReplyThreshold}`, [pubkey, zapReplyThreshold]) const { events: timelineEvents, isLoading: loadingTimeline, refresh: refreshTimeline } = useProfileTimeline({ pubkey, cacheKey, kinds: PROFILE_FEED_KINDS, limit: 200, filterPredicate }) const pinIds = useMemo(() => new Set(pinEvents.map((e) => e.id)), [pinEvents]) const passesMainFeedTimelineRules = useCallback( (event: Event) => { if (!profileTimelineShowKinds.includes(event.kind)) return false if (event.kind === kinds.ShortTextNote) { const isReply = isReplyNoteEvent(event) if (hideReplies && isReply) return false if (isReply && !showKind1Replies) return false if (!isReply && !showKind1OPs) return false } if (event.kind === ExtendedKind.COMMENT && !showKind1111) return false return true }, [profileTimelineShowKinds, showKind1OPs, showKind1Replies, showKind1111, hideReplies] ) const restTimeline = useMemo( () => timelineEvents.filter((e) => !pinIds.has(e.id)).filter(passesMainFeedTimelineRules), [timelineEvents, pinIds, passesMainFeedTimelineRules] ) const applySearch = useCallback( (events: Event[]) => { const q = searchQuery.trim().toLowerCase() if (!q) return events return events.filter((event) => { if (event.content.toLowerCase().includes(q)) return true return event.tags.some((tag) => tag.length > 1 && tag[1]?.toLowerCase().includes(q)) }) }, [searchQuery] ) const filteredPins = useMemo( () => applySearch(pinEvents).filter((e) => !isEventDeleted(e)), [pinEvents, applySearch, isEventDeleted] ) const filteredRest = useMemo( () => applySearch(restTimeline).filter((e) => !isEventDeleted(e)), [restTimeline, applySearch, isEventDeleted] ) const mergedDisplay = useMemo(() => [...filteredPins, ...filteredRest], [filteredPins, filteredRest]) /** Pins always occupy the top of the profile; `showCount` caps total visible rows (pins + posts). */ const displayedPins = useMemo(() => { if (filteredPins.length <= showCount) return filteredPins return filteredPins.slice(0, showCount) }, [filteredPins, showCount]) const displayedFeed = useMemo( () => filteredRest.slice(0, Math.max(0, showCount - displayedPins.length)), [filteredRest, showCount, displayedPins.length] ) const totalVisible = displayedPins.length + displayedFeed.length useEffect(() => { setShowCount(INITIAL_SHOW_COUNT) }, [searchQuery, pubkey]) useEffect(() => { if (!loadingPins && !loadingTimeline) { setIsRefreshing(false) } }, [loadingPins, loadingTimeline]) const refreshAll = useCallback(() => { setIsRefreshing(true) refreshPins() refreshTimeline() void client.fetchDeletionEventsForPubkey(pubkey) }, [refreshPins, refreshTimeline, pubkey]) useImperativeHandle(ref, () => ({ refresh: refreshAll }), [refreshAll]) useEffect(() => { if (!bottomRef.current || totalVisible >= mergedDisplay.length) return const observer = new IntersectionObserver( (entries) => { if (entries[0]?.isIntersecting && totalVisible < mergedDisplay.length) { setShowCount((prev) => Math.min(prev + LOAD_MORE_COUNT, mergedDisplay.length)) } }, { threshold: 0.1 } ) observer.observe(bottomRef.current) return () => observer.disconnect() }, [totalVisible, mergedDisplay.length]) const loading = (loadingPins || loadingTimeline) && mergedDisplay.length === 0 if (loading) { return (
{Array.from({ length: 4 }).map((_, i) => ( ))}
) } if (!mergedDisplay.length && !loadingPins && !loadingTimeline) { return (
{searchQuery.trim() ? t('No posts match your search') : t('No posts found')}
) } return (
{isRefreshing && (
{t('Refreshing posts...')}
)} {searchQuery.trim() && (
{t('Showing {{filtered}} of {{total}} items', { filtered: totalVisible, total: mergedDisplay.length })}
)}
{displayedPins.length > 0 && (
{displayedPins.map((event) => ( ))}
)} {displayedPins.length > 0 && displayedFeed.length > 0 && (
{t('Feed')}
)} {displayedFeed.length > 0 && (
{displayedFeed.map((event) => ( ))}
)}
{totalVisible < mergedDisplay.length && (
{t('Loading more...')}
)}
) }) ProfileFeedWithPins.displayName = 'ProfileFeedWithPins' export default ProfileFeedWithPins