import NewNotesButton from '@/components/NewNotesButton' import { Button } from '@/components/ui/button' import { getReplaceableCoordinateFromEvent, isMentioningMutedUsers, isReplaceableEvent, isReplyNoteEvent } from '@/lib/event' import { getZapInfoFromEvent } from '@/lib/event-metadata' import logger from '@/lib/logger' import { isTouchDevice } from '@/lib/utils' import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useMuteList } from '@/providers/MuteListProvider' import { useNostr } from '@/providers/NostrProvider' import { useUserTrust } from '@/providers/UserTrustProvider' import { useZap } from '@/providers/ZapProvider' import client from '@/services/client.service' import { TFeedSubRequest } from '@/types' import dayjs from 'dayjs' import { Event, kinds } from 'nostr-tools' import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import PullToRefresh from 'react-simple-pull-to-refresh' import { toast } from 'sonner' import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' const LIMIT = 100 const ALGO_LIMIT = 100 const SHOW_COUNT = 10 const NoteList = forwardRef( ( { subRequests, showKinds, filterMutedNotes = true, hideReplies = false, hideUntrustedNotes = false, areAlgoRelays = false, showRelayCloseReason = false, customHeader }: { subRequests: TFeedSubRequest[] showKinds: number[] filterMutedNotes?: boolean hideReplies?: boolean hideUntrustedNotes?: boolean areAlgoRelays?: boolean showRelayCloseReason?: boolean customHeader?: React.ReactNode }, ref ) => { const { t } = useTranslation() const { startLogin, pubkey } = useNostr() const { isUserTrusted } = useUserTrust() const { mutePubkeySet } = useMuteList() const { hideContentMentioningMutedUsers } = useContentPolicy() const { isEventDeleted } = useDeletedEvent() const { zapReplyThreshold } = useZap() const [events, setEvents] = useState([]) const [newEvents, setNewEvents] = useState([]) const [hasMore, setHasMore] = useState(true) const [loading, setLoading] = useState(true) const [timelineKey, setTimelineKey] = useState(undefined) const [refreshCount, setRefreshCount] = useState(0) const [showCount, setShowCount] = useState(SHOW_COUNT) const supportTouch = useMemo(() => isTouchDevice(), []) const bottomRef = useRef(null) const topRef = useRef(null) const shouldHideEvent = useCallback( (evt: Event) => { // Check if this is a profile feed const isProfileFeed = subRequests.some(req => req.filter.authors && req.filter.authors.length === 1) console.log('🔍 [NoteList] Checking shouldHideEvent for:', { id: evt.id, kind: evt.kind, pubkey: evt.pubkey.substring(0, 8), isDeleted: isEventDeleted(evt), isReply: isReplyNoteEvent(evt), isTrusted: isUserTrusted(evt.pubkey), isMuted: mutePubkeySet.has(evt.pubkey), hideReplies, hideUntrustedNotes, filterMutedNotes, isProfileFeed }) if (isEventDeleted(evt)) { console.log('❌ [NoteList] Event deleted:', evt.id) return true } // Special handling for zaps - check threshold, but be more lenient for profile feeds if (evt.kind === kinds.Zap) { const zapInfo = getZapInfoFromEvent(evt) // For profile feeds, show all zaps from the profile owner // For timeline feeds, filter by threshold if (!isProfileFeed && zapInfo && zapInfo.amount < zapReplyThreshold) { console.log('❌ [NoteList] Zap below threshold:', { id: evt.id, amount: zapInfo.amount, threshold: zapReplyThreshold }) return true } } else if (hideReplies && isReplyNoteEvent(evt)) { console.log('❌ [NoteList] Reply hidden:', evt.id) return true } if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) { console.log('❌ [NoteList] Untrusted user:', evt.id) return true } if (filterMutedNotes && mutePubkeySet.has(evt.pubkey)) { console.log('❌ [NoteList] Muted user:', evt.id) return true } if ( filterMutedNotes && hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet) ) { console.log('❌ [NoteList] Mentions muted users:', evt.id) return true } console.log('✅ [NoteList] Event passed all filters:', evt.id) return false }, [hideReplies, hideUntrustedNotes, mutePubkeySet, isEventDeleted, zapReplyThreshold, subRequests] ) const filteredEvents = useMemo(() => { const idSet = new Set() console.log('🔍 [NoteList] Filtering events:', { totalEvents: events.length, showCount, eventKinds: events.map(e => e.kind).slice(0, 10) }) const filtered = events.slice(0, showCount).filter((evt) => { if (shouldHideEvent(evt)) { console.log('❌ [NoteList] Event hidden:', { id: evt.id, kind: evt.kind, reason: 'shouldHideEvent' }) return false } const id = isReplaceableEvent(evt.kind) ? getReplaceableCoordinateFromEvent(evt) : evt.id if (idSet.has(id)) { console.log('❌ [NoteList] Event hidden:', { id: evt.id, kind: evt.kind, reason: 'duplicate' }) return false } idSet.add(id) return true }) console.log('✅ [NoteList] Filtered events result:', { total: events.length, filtered: filtered.length, showCount }) return filtered }, [events, showCount, shouldHideEvent]) const filteredNewEvents = useMemo(() => { const idSet = new Set() return newEvents.filter((event: Event) => { if (shouldHideEvent(event)) return false const id = isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) : event.id if (idSet.has(id)) { return false } idSet.add(id) return true }) }, [newEvents, shouldHideEvent]) const scrollToTop = (behavior: ScrollBehavior = 'instant') => { setTimeout(() => { topRef.current?.scrollIntoView({ behavior, block: 'start' }) }, 20) } const refresh = () => { scrollToTop() // Clear relay connection state to force fresh connections const relayUrls = subRequests.flatMap(req => req.urls) client.clearRelayConnectionState(relayUrls) setTimeout(() => { setRefreshCount((count) => count + 1) }, 500) } useImperativeHandle(ref, () => ({ scrollToTop, refresh }), []) useEffect(() => { console.log('🚀 [NoteList] useEffect triggered:', { subRequests: subRequests.length, showKinds: showKinds.length, refreshCount }) logger.debug('NoteList useEffect:', { subRequests, subRequestsLength: subRequests.length }) if (!subRequests.length) { console.log('❌ [NoteList] No subRequests, returning early') return } async function init() { console.log('🔄 [NoteList] Initializing feed...') setLoading(true) setEvents([]) setNewEvents([]) setHasMore(true) if (showKinds.length === 0) { logger.warn('NoteList: showKinds is empty, no events will be displayed') setLoading(false) setHasMore(false) return () => {} } const finalFilters = subRequests.map(({ urls, filter }) => ({ urls, filter: { kinds: showKinds, ...filter, limit: areAlgoRelays ? ALGO_LIMIT : LIMIT } })) console.log('[NoteList] Subscribing to timeline with:', finalFilters) console.log('[NoteList] showKinds:', showKinds) const { closer, timelineKey } = await client.subscribeTimeline( finalFilters, { onEvents: (events, eosed) => { console.log('📥 [NoteList] Received events:', { eventsCount: events.length, eosed, loading, hasMore, eventKinds: events.map(e => e.kind).slice(0, 10), // First 10 event kinds showKinds }) logger.debug('NoteList received events:', { eventsCount: events.length, eosed }) if (events.length > 0) { console.log('✅ [NoteList] Accumulating events from relay') setEvents(prevEvents => { // For profile feeds, accumulate events from all relays // For timeline feeds, replace events const isProfileFeed = subRequests.some(req => req.filter.authors && req.filter.authors.length === 1) if (isProfileFeed) { // Accumulate events, removing duplicates const existingIds = new Set(prevEvents.map(e => e.id)) const newEvents = events.filter(e => !existingIds.has(e.id)) const combined = [...prevEvents, ...newEvents] console.log('📊 [NoteList] Profile feed - accumulated:', { previous: prevEvents.length, new: events.length, unique: newEvents.length, total: combined.length }) return combined } else { // Timeline feed - replace events return events } }) // Stop loading as soon as we have events, don't wait for all relays setLoading(false) } if (areAlgoRelays) { setHasMore(false) } if (eosed) { console.log('🏁 [NoteList] EOSED - setting loading false, hasMore:', events.length > 0) setLoading(false) setHasMore(events.length > 0) } }, onNew: (event) => { if (pubkey && event.pubkey === pubkey) { // If the new event is from the current user, insert it directly into the feed setEvents((oldEvents) => oldEvents.some((e) => e.id === event.id) ? oldEvents : [event, ...oldEvents] ) } else { // Otherwise, buffer it and show the New Notes button setNewEvents((oldEvents) => [event, ...oldEvents].sort((a, b) => b.created_at - a.created_at) ) } }, onClose: (url, reason) => { logger.debug('Relay connection closed:', { url, reason }) if (!showRelayCloseReason) return // ignore reasons from nostr-tools if ( [ 'closed by caller', 'relay connection errored', 'relay connection closed', 'pingpong timed out', 'relay connection closed by us' ].includes(reason) ) { return } toast.error(`${url}: ${reason}`) } }, { startLogin, needSort: !areAlgoRelays } ) // Add a fallback timeout to prevent infinite loading // Increased timeout to 15 seconds to handle slow relay connections const fallbackTimeout = setTimeout(() => { if (loading) { setLoading(false) logger.debug('NoteList loading timeout - stopping after 15 seconds') } }, 15000) setTimelineKey(timelineKey) return () => { clearTimeout(fallbackTimeout) closer?.() } } const promise = init() return () => { promise.then((closer) => closer()) } }, [subRequests, refreshCount, showKinds]) useEffect(() => { const options = { root: null, rootMargin: '10px', threshold: 0.1 } const loadMore = async () => { if (showCount < events.length) { setShowCount((prev) => prev + SHOW_COUNT) // preload more if (events.length - showCount > LIMIT / 2) { return } } if (!timelineKey || loading || !hasMore) return setLoading(true) const newEvents = await client.loadMoreTimeline( timelineKey, events.length ? events[events.length - 1].created_at - 1 : dayjs().unix(), LIMIT ) setLoading(false) if (newEvents.length === 0) { setHasMore(false) return } setEvents((oldEvents) => [...oldEvents, ...newEvents]) } const observerInstance = new IntersectionObserver((entries) => { if (entries[0].isIntersecting && hasMore) { loadMore() } }, options) const currentBottomRef = bottomRef.current if (currentBottomRef) { observerInstance.observe(currentBottomRef) } return () => { if (observerInstance && currentBottomRef) { observerInstance.unobserve(currentBottomRef) } } }, [loading, hasMore, events, showCount, timelineKey]) const showNewEvents = () => { setEvents((oldEvents) => [...newEvents, ...oldEvents]) setNewEvents([]) setTimeout(() => { scrollToTop('smooth') }, 0) } console.log('🎨 [NoteList] Rendering with state:', { eventsCount: events.length, filteredEventsCount: filteredEvents.length, loading, hasMore, showKinds: showKinds.length }) const list = (
{customHeader} {filteredEvents.map((event) => ( ))} {hasMore || loading ? (
) : events.length ? (
{t('no more notes')}
) : (
)}
) return (
{filteredNewEvents.length > 0 && ( )}
{supportTouch ? ( { refresh() await new Promise((resolve) => setTimeout(resolve, 1000)) }} pullingContent="" > {list} ) : ( list )}
) } ) NoteList.displayName = 'NoteList' export default NoteList export type TNoteListRef = { scrollToTop: (behavior?: ScrollBehavior) => void refresh: () => void }