import NewNotesButton from '@/components/NewNotesButton' import { Button } from '@/components/ui/button' import { ExtendedKind } from '@/constants' import { getEmbeddedNoteBech32Ids, getReplaceableCoordinateFromEvent, isMentioningMutedUsers, isReplaceableEvent, isReplyNoteEvent } from '@/lib/event' import { shouldFilterEvent } from '@/lib/event-filtering' import { getZapInfoFromEvent } from '@/lib/event-metadata' 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 logger from '@/lib/logger' import { TFeedSubRequest } from '@/types' import dayjs from 'dayjs' import { Event, kinds } from 'nostr-tools' import { decode } from 'nostr-tools/nip19' 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 = 500 // Increased from 200 to load more events per request const ALGO_LIMIT = 1000 // Increased from 500 for algorithm feeds const SHOW_COUNT = 50 // Increased from 10 to show more events at once, reducing scroll load frequency const NoteList = forwardRef( ( { subRequests, showKinds, showKind1OPs = true, showKind1Replies = true, showKind1111 = true, filterMutedNotes = true, hideReplies = false, hideUntrustedNotes = false, areAlgoRelays = false, showRelayCloseReason = false, pinnedEventIds = [], useFilterAsIs = false }: { subRequests: TFeedSubRequest[] showKinds: number[] showKind1OPs?: boolean showKind1Replies?: boolean showKind1111?: boolean filterMutedNotes?: boolean hideReplies?: boolean hideUntrustedNotes?: boolean areAlgoRelays?: boolean showRelayCloseReason?: boolean pinnedEventIds?: string[] /** When true, use filter from subRequests as-is (kinds, limit) instead of showKinds. For spell feeds. */ useFilterAsIs?: boolean }, 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 consecutiveEmptyRef = useRef(0) // Track consecutive empty results to prevent infinite retries const loadMoreTimeoutRef = useRef(null) // Throttle loadMore calls to prevent stuttering // Memoize subRequests serialization to avoid expensive JSON.stringify on every render const subRequestsKey = useMemo(() => { return JSON.stringify(subRequests.map(req => ({ urls: [...req.urls].sort(), // Create a copy before sorting to avoid mutation filter: req.filter }))) }, [subRequests]) const shouldHideEvent = useCallback( (evt: Event) => { const pinnedEventHexIdSet = new Set() pinnedEventIds.forEach((id) => { try { const { type, data } = decode(id) if (type === 'nevent') { pinnedEventHexIdSet.add(data.id) } } catch { // ignore } }) if (pinnedEventHexIdSet.has(evt.id)) return true if (isEventDeleted(evt)) return true if (hideReplies && isReplyNoteEvent(evt)) return true if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return true if (filterMutedNotes && mutePubkeySet.has(evt.pubkey)) return true if ( filterMutedNotes && hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet) ) { return true } // Filter out expired events if (shouldFilterEvent(evt)) return true // Filter out zap receipts below the zap threshold (superzaps) if (evt.kind === ExtendedKind.ZAP_RECEIPT) { const zapInfo = getZapInfoFromEvent(evt) // Hide zap receipts if amount is missing, 0, or below the threshold if (!zapInfo || zapInfo.amount === undefined || zapInfo.amount === 0 || zapInfo.amount < zapReplyThreshold) { return true } } return false }, [hideReplies, hideUntrustedNotes, mutePubkeySet, pinnedEventIds, isEventDeleted, zapReplyThreshold] ) const filteredEvents = useMemo(() => { const idSet = new Set() return events.slice(0, showCount).filter((evt) => { if (!showKinds.includes(evt.kind)) return false // Kind 1: show only OPs if showKind1OPs, only replies if showKind1Replies if (evt.kind === kinds.ShortTextNote) { const isReply = isReplyNoteEvent(evt) if (isReply && !showKind1Replies) return false if (!isReply && !showKind1OPs) return false } // Kind 1111 (comments): show only if showKind1111 if (evt.kind === ExtendedKind.COMMENT && !showKind1111) return false if (shouldHideEvent(evt)) return false const id = isReplaceableEvent(evt.kind) ? getReplaceableCoordinateFromEvent(evt) : evt.id if (idSet.has(id)) { return false } idSet.add(id) return true }) }, [events, showCount, shouldHideEvent, showKinds, showKind1OPs, showKind1Replies, showKind1111]) const filteredNewEvents = useMemo(() => { const idSet = new Set() return newEvents.filter((event: Event) => { if (!showKinds.includes(event.kind)) return false if (event.kind === kinds.ShortTextNote) { const isReply = isReplyNoteEvent(event) if (isReply && !showKind1Replies) return false if (!isReply && !showKind1OPs) return false } if (event.kind === ExtendedKind.COMMENT && !showKind1111) return false 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, showKinds, showKind1OPs, showKind1Replies, showKind1111]) const scrollToTop = (behavior: ScrollBehavior = 'instant') => { setTimeout(() => { topRef.current?.scrollIntoView({ behavior, block: 'start' }) }, 20) } const refresh = () => { scrollToTop() setTimeout(() => { setRefreshCount((count) => count + 1) }, 500) } useImperativeHandle(ref, () => ({ scrollToTop, refresh }), []) useEffect(() => { logger.info('[NoteList] useEffect triggered', { subRequestsLength: subRequests.length, subRequests: subRequests.map(({ urls, filter }) => ({ urls: urls.slice(0, 2), filterKeys: Object.keys(filter) })) }) if (!subRequests.length) { logger.warn('[NoteList] subRequests is empty, not initializing') return } async function init() { logger.debug('[NoteList] init called', { subRequestsCount: subRequests.length, showKindsLength: showKinds.length, showKinds, useFilterAsIs, areAlgoRelays }) setLoading(true) setEvents([]) setNewEvents([]) setHasMore(true) consecutiveEmptyRef.current = 0 // Reset counter on refresh const mappedSubRequests = subRequests.map(({ urls, filter }) => ({ urls, filter: useFilterAsIs ? { ...filter, limit: filter.limit ?? (areAlgoRelays ? ALGO_LIMIT : LIMIT) } : { ...filter, // If showKinds is empty, default to kind 1 (ShortTextNote) only kinds: showKinds.length > 0 ? showKinds : [kinds.ShortTextNote], limit: areAlgoRelays ? ALGO_LIMIT : LIMIT } })) logger.debug('[NoteList] Subscribing with filters', { subRequestCount: mappedSubRequests.length, filters: mappedSubRequests.map(({ urls, filter }) => ({ urls: urls.slice(0, 2), // Log first 2 URLs kinds: filter.kinds, limit: filter.limit })) }) logger.info('[NoteList] About to call subscribeTimeline', { mappedSubRequestsCount: mappedSubRequests.length }) let closer: (() => void) | undefined let timelineKey: string | undefined try { const result = await client.subscribeTimeline( mappedSubRequests, { onEvents: (events: Event[], eosed: boolean) => { logger.debug('[NoteList] onEvents called', { eventCount: events.length, eosed, showKindsLength: showKinds.length, subRequestsCount: subRequests.length }) if (events.length > 0) { setEvents(events) // CRITICAL: Prefetch profiles for initial events (reduced batch size for faster initial load) // This ensures profiles are ready before user starts scrolling // Reduced from 300 to 150 to reduce initial load time const initialPubkeys = Array.from( new Set(events.slice(0, 150).map((ev: Event) => ev.pubkey).filter((p: string) => p?.length === 64)) ) if (initialPubkeys.length > 0) { // Filter out already prefetched pubkeys const pubkeysToFetch = initialPubkeys.filter((p) => !prefetchedPubkeysRef.current.has(p)) if (pubkeysToFetch.length > 0) { // Mark as prefetched immediately to prevent duplicate requests pubkeysToFetch.forEach((p) => prefetchedPubkeysRef.current.add(p)) // Batch fetch in background (non-blocking) client.fetchProfilesForPubkeys(pubkeysToFetch).catch(() => { // On error, remove from prefetched set so we can retry later pubkeysToFetch.forEach((p) => prefetchedPubkeysRef.current.delete(p)) }) } } // CRITICAL: Prefetch embedded events for initial events // Extract embedded event IDs from initial events const initialEmbeddedEventIds = new Set() events.slice(0, 150).forEach((ev: Event) => { const embeddedIds = extractEmbeddedEventIds(ev) embeddedIds.forEach((id: string) => initialEmbeddedEventIds.add(id)) }) const eventIdsToFetch = Array.from(initialEmbeddedEventIds).filter( (id) => !prefetchedEventIdsRef.current.has(id) ) if (eventIdsToFetch.length > 0) { // Mark as prefetched immediately to prevent duplicate requests eventIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.add(id)) // Batch fetch embedded events in background (non-blocking) Promise.all(eventIdsToFetch.map((id) => client.fetchEvent(id))).catch(() => { // On error, remove from prefetched set so we can retry later eventIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.delete(id)) }) } } else if (eosed) { // No events received but EOSE - set empty events array and stop loading logger.debug('[NoteList] EOSE with no events, stopping loading') setEvents([]) setLoading(false) } if (areAlgoRelays) { setHasMore(false) } if (eosed) { setLoading(false) // CRITICAL FIX: Always set hasMore to true on eosed, even if we have few events // The initial load might only return a few events due to filtering or relay limits // We should still try to load more on scroll - the loadMore logic will handle stopping // Only set to false if we explicitly know there are no more events (handled in loadMore) setHasMore(true) } }, onNew: (event: Event) => { if (!useFilterAsIs && !showKinds.includes(event.kind)) return if (event.kind === kinds.ShortTextNote) { const isReply = isReplyNoteEvent(event) if (isReply && !showKind1Replies) return if (!isReply && !showKind1OPs) return } if (event.kind === ExtendedKind.COMMENT && !showKind1111) return 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: string, reason: string) => { 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 } // don't toast for routine connection failures (noisy and expected when relays are down/slow) const r = reason.toLowerCase() if ( r.includes('connection failed') || r.includes('econnrefused') || r.includes('econnreset') || r.includes('etimedout') || r.includes('timeout') || r.includes('network') || r.includes('enotfound') || r.includes('connection refused') ) { return } toast.error(`${url}: ${reason}`) } }, { startLogin, needSort: !areAlgoRelays, useCache: false // Main feeds should always fetch fresh from relays, not use cache } ) closer = result.closer timelineKey = result.timelineKey logger.info('[NoteList] subscribeTimeline completed', { hasTimelineKey: !!timelineKey, hasCloser: !!closer }) setTimelineKey(timelineKey) return closer } catch (error) { logger.error('[NoteList] Error in subscribeTimeline', { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined }) setLoading(false) throw error } } const promise = init() return () => { promise.then((closer) => closer()) } }, [subRequestsKey, refreshCount, showKinds, showKind1OPs, showKind1Replies, showKind1111, useFilterAsIs]) // Use refs to avoid dependency issues and ensure latest values in async callbacks const eventsRef = useRef(events) const showCountRef = useRef(showCount) const loadingRef = useRef(loading) const hasMoreRef = useRef(hasMore) const timelineKeyRef = useRef(timelineKey) useEffect(() => { eventsRef.current = events }, [events]) useEffect(() => { showCountRef.current = showCount }, [showCount]) useEffect(() => { loadingRef.current = loading }, [loading]) useEffect(() => { hasMoreRef.current = hasMore }, [hasMore]) useEffect(() => { timelineKeyRef.current = timelineKey }, [timelineKey]) useEffect(() => { const options: IntersectionObserverInit = { root: null, rootMargin: '10px', threshold: 0.1 } const loadMore = async (): Promise => { const currentEvents = eventsRef.current const currentShowCount = showCountRef.current const currentLoading = loadingRef.current const currentHasMore = hasMoreRef.current const currentTimelineKey = timelineKeyRef.current // CRITICAL: Throttle loadMore calls to prevent stuttering during rapid scrolling if (loadMoreTimeoutRef.current) { return // Already scheduled, skip } // Show more events immediately if we have them cached if (currentShowCount < currentEvents.length) { // Show more aggressively: increase by SHOW_COUNT, but also check if we should show even more const remaining = currentEvents.length - currentShowCount const increment = Math.min(SHOW_COUNT * 2, remaining) // Show up to 2x SHOW_COUNT if available setShowCount((prev) => prev + increment) // Only preload more if we have plenty cached (more than 3/4 of LIMIT) // BUT: Always try to load more if we have very few events (might be due to filtering) if (currentEvents.length - currentShowCount > LIMIT * 0.75 && currentEvents.length >= 50) { return } // If we have very few events, always try to load more (might be aggressive filtering) if (currentEvents.length < 50) { // Continue to loadMore below even if we have cached events // This ensures we keep loading when filtering is aggressive } } if (!currentTimelineKey || currentLoading || !currentHasMore) return // Schedule loadMore with a small delay to throttle rapid calls loadMoreTimeoutRef.current = setTimeout(async () => { loadMoreTimeoutRef.current = null const latestEvents = eventsRef.current const latestTimelineKey = timelineKeyRef.current const latestLoading = loadingRef.current const latestHasMore = hasMoreRef.current if (!latestTimelineKey || latestLoading || !latestHasMore) return setLoading(true) let newEvents: Event[] = [] try { const until = latestEvents.length ? latestEvents[latestEvents.length - 1].created_at - 1 : dayjs().unix() newEvents = await client.loadMoreTimeline( latestTimelineKey, until, LIMIT ) // CRITICAL FIX: Be extremely conservative about stopping the feed // Only stop if we're absolutely certain there are no more events if (newEvents.length === 0) { // Check if timeline has more cached refs that we haven't loaded yet const hasMoreCached = client.hasMoreTimelineEvents?.(latestTimelineKey, until) ?? false if (hasMoreCached) { // There are more cached events, keep hasMore true and try again setLoading(false) // Retry after a short delay to allow IndexedDB to catch up setTimeout(() => { if (hasMoreRef.current && !loadingRef.current) { loadMore() } }, 300) return } // No cached events and network returned empty // Be VERY patient - don't stop too early, especially when we have few events // This prevents stopping due to temporary relay issues or slow relays consecutiveEmptyRef.current += 1 // CRITICAL FIX: Only stop if we have MANY consecutive empty results // This ensures we don't stop prematurely when relays are slow or filtering is aggressive // Even with few visible events, we might have many events that are filtered out if (consecutiveEmptyRef.current >= 20) { // After 20 consecutive empty results, assume we've reached the end // Increased from 10 to 20 to be even more patient with slow relays setHasMore(false) } // Otherwise, keep hasMore true to allow retry on next scroll // This ensures the feed continues trying even if relays are slow setLoading(false) return } // Reset consecutive empty counter on success consecutiveEmptyRef.current = 0 setEvents((oldEvents) => [...oldEvents, ...newEvents]) // NEVER automatically set hasMore to false based on result count // Only stop when we get consecutive empty results // This ensures the feed continues loading even with partial results // CRITICAL: Prefetch profiles for newly loaded events (throttled to reduce frequency) // This ensures profiles are ready before user scrolls to them if (newEvents.length > 0) { // Throttle profile prefetching for newly loaded events to reduce network load setTimeout(() => { const newPubkeys = Array.from( new Set(newEvents.map((ev) => ev.pubkey).filter((p) => p?.length === 64)) ) if (newPubkeys.length > 0) { // Filter out already prefetched pubkeys const pubkeysToFetch = newPubkeys.filter((p) => !prefetchedPubkeysRef.current.has(p)) if (pubkeysToFetch.length > 0) { // Mark as prefetched immediately to prevent duplicate requests pubkeysToFetch.forEach((p) => prefetchedPubkeysRef.current.add(p)) // Batch fetch in background (non-blocking) client.fetchProfilesForPubkeys(pubkeysToFetch).catch(() => { // On error, remove from prefetched set so we can retry later pubkeysToFetch.forEach((p) => prefetchedPubkeysRef.current.delete(p)) }) } } // CRITICAL: Prefetch embedded events for newly loaded events const newEmbeddedEventIds = new Set() newEvents.forEach((ev) => { const embeddedIds = extractEmbeddedEventIds(ev) embeddedIds.forEach((id) => newEmbeddedEventIds.add(id)) }) const eventIdsToFetch = Array.from(newEmbeddedEventIds).filter( (id) => !prefetchedEventIdsRef.current.has(id) ) if (eventIdsToFetch.length > 0) { // Mark as prefetched immediately to prevent duplicate requests eventIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.add(id)) // Batch fetch embedded events in background (non-blocking) Promise.all(eventIdsToFetch.map((id) => client.fetchEvent(id))).catch(() => { // On error, remove from prefetched set so we can retry later eventIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.delete(id)) }) } }, 100) // Small delay to batch with other profile fetches } } catch (error) { // On error, don't set hasMore to false - might be temporary network issue logger.error('[NoteList] Error loading more events', { error }) consecutiveEmptyRef.current += 1 // Only stop after MANY consecutive errors - be very patient with network issues // This prevents stopping when relays are temporarily down or slow if (consecutiveEmptyRef.current >= 25) { // Increased from 15 to 25 to be even more patient with network issues setHasMore(false) } } finally { setLoading(false) } }, 50) // Reduced delay from 100ms to 50ms for more responsive scrolling } const observerInstance = new IntersectionObserver((entries) => { if (entries[0].isIntersecting && hasMoreRef.current && !loadingRef.current) { // Throttle: only trigger if not already loading and not already scheduled loadMore() } }, options) const currentBottomRef = bottomRef.current if (currentBottomRef) { observerInstance.observe(currentBottomRef) } return () => { if (observerInstance && currentBottomRef) { observerInstance.unobserve(currentBottomRef) } // Clean up timeout on unmount if (loadMoreTimeoutRef.current) { clearTimeout(loadMoreTimeoutRef.current) loadMoreTimeoutRef.current = null } } // Dependencies are handled via refs to avoid stale closures in async callbacks // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // CRITICAL: Prefetch profiles for visible authors + upcoming events in one batched request // This prevents browser crashes during rapid scrolling by pre-loading profiles before they're needed const visiblePubkeysRef = useRef>(new Set()) const prefetchedPubkeysRef = useRef>(new Set()) const prefetchTimeoutRef = useRef(null) // CRITICAL: Prefetch embedded events (referenced in e tags, a tags, and content) // This ensures embedded events are ready before user scrolls to them const prefetchedEventIdsRef = useRef>(new Set()) const prefetchEmbeddedEventsTimeoutRef = useRef(null) // Helper function to extract all embedded event IDs from an event const extractEmbeddedEventIds = useCallback((evt: Event): string[] => { const eventIds: string[] = [] // 1. Extract from 'e' tags (event references) evt.tags .filter((tag) => tag[0] === 'e' && tag[1] && tag[1].length === 64) .forEach((tag) => { const eventId = tag[1] if (eventId && /^[0-9a-f]{64}$/.test(eventId)) { eventIds.push(eventId) } }) // 2. Extract from 'a' tags (addressable events) - get event ID if present evt.tags .filter((tag) => tag[0] === 'a' && tag[3]) // tag[3] is the event ID for version tracking .forEach((tag) => { const eventId = tag[3] if (eventId && /^[0-9a-f]{64}$/.test(eventId)) { eventIds.push(eventId) } }) // 3. Extract from content (nostr: links) // Note: getEmbeddedNoteBech32Ids returns hex IDs (despite the name) const embeddedNoteIds = getEmbeddedNoteBech32Ids(evt) embeddedNoteIds.forEach((id) => { // The function already returns hex IDs, so use them directly if (id && /^[0-9a-f]{64}$/.test(id)) { eventIds.push(id) } }) return Array.from(new Set(eventIds)) // Deduplicate }, []) useEffect(() => { // Throttle profile prefetching to reduce frequency during rapid scrolling // Clear any existing timeout if (prefetchTimeoutRef.current) { clearTimeout(prefetchTimeoutRef.current) } // Debounce profile prefetching by 200ms to reduce frequency during rapid scrolling prefetchTimeoutRef.current = setTimeout(() => { // Prefetch profiles for: // 1. Currently visible events (first 60, reduced from 80) // 2. Upcoming events that will be visible when scrolling (next 150, reduced from 300) // This ensures profiles are ready before they're needed during rapid scrolling const visiblePubkeys = Array.from( new Set(filteredEvents.slice(0, 60).map((ev) => ev.pubkey).filter((p) => p?.length === 64)) ) const upcomingPubkeys = Array.from( new Set(events.slice(0, 150).map((ev) => ev.pubkey).filter((p) => p?.length === 64)) ) // Combine visible and upcoming, but prioritize visible ones const allPubkeys = Array.from(new Set([...visiblePubkeys, ...upcomingPubkeys])) if (allPubkeys.length === 0) return // Check if we've already prefetched these exact pubkeys const prev = visiblePubkeysRef.current const same = allPubkeys.length === prev.size && allPubkeys.every((p) => prev.has(p)) if (same) return // Find pubkeys that haven't been prefetched yet const newPubkeys = allPubkeys.filter((p) => !prefetchedPubkeysRef.current.has(p)) if (newPubkeys.length === 0) { // All pubkeys already prefetched, just update the ref visiblePubkeysRef.current = new Set(allPubkeys) return } // Update refs visiblePubkeysRef.current = new Set(allPubkeys) newPubkeys.forEach((p) => prefetchedPubkeysRef.current.add(p)) // Batch fetch profiles for new pubkeys (IndexedDB + network in one request) // This is the key optimization: batch processing prevents individual fetches during scrolling client.fetchProfilesForPubkeys(newPubkeys).catch(() => { // On error, remove from prefetched set so we can retry later newPubkeys.forEach((p) => prefetchedPubkeysRef.current.delete(p)) }) }, 200) // Debounce by 200ms to reduce frequency return () => { if (prefetchTimeoutRef.current) { clearTimeout(prefetchTimeoutRef.current) prefetchTimeoutRef.current = null } } }, [filteredEvents, events, extractEmbeddedEventIds]) // CRITICAL: Prefetch embedded events for visible events useEffect(() => { // Throttle embedded event prefetching to reduce frequency during rapid scrolling // Clear any existing timeout if (prefetchEmbeddedEventsTimeoutRef.current) { clearTimeout(prefetchEmbeddedEventsTimeoutRef.current) } // Debounce embedded event prefetching by 300ms to reduce frequency during rapid scrolling prefetchEmbeddedEventsTimeoutRef.current = setTimeout(() => { // Extract embedded event IDs from visible events (first 60) const visibleEmbeddedEventIds = new Set() filteredEvents.slice(0, 60).forEach((ev) => { const embeddedIds = extractEmbeddedEventIds(ev) embeddedIds.forEach((id) => visibleEmbeddedEventIds.add(id)) }) // Also extract from upcoming events (next 150) const upcomingEmbeddedEventIds = new Set() events.slice(0, 150).forEach((ev) => { const embeddedIds = extractEmbeddedEventIds(ev) embeddedIds.forEach((id) => upcomingEmbeddedEventIds.add(id)) }) // Combine visible and upcoming const allEmbeddedEventIds = Array.from( new Set([...visibleEmbeddedEventIds, ...upcomingEmbeddedEventIds]) ) if (allEmbeddedEventIds.length === 0) return // Filter out already prefetched event IDs const eventIdsToFetch = allEmbeddedEventIds.filter( (id) => !prefetchedEventIdsRef.current.has(id) ) if (eventIdsToFetch.length === 0) return // Mark as prefetched immediately to prevent duplicate requests eventIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.add(id)) // Batch fetch embedded events in background (non-blocking) Promise.all(eventIdsToFetch.map((id) => client.fetchEvent(id))).catch(() => { // On error, remove from prefetched set so we can retry later eventIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.delete(id)) }) }, 300) // Debounce by 300ms to reduce frequency return () => { if (prefetchEmbeddedEventsTimeoutRef.current) { clearTimeout(prefetchEmbeddedEventsTimeoutRef.current) prefetchEmbeddedEventsTimeoutRef.current = null } } }, [filteredEvents, events, extractEmbeddedEventIds]) // Also prefetch when loading more events (scrolling down) // Throttled to reduce frequency during rapid scrolling const prefetchNewEventsTimeoutRef = useRef(null) useEffect(() => { if (loading || !hasMore) return // Clear any existing timeout if (prefetchNewEventsTimeoutRef.current) { clearTimeout(prefetchNewEventsTimeoutRef.current) } // Debounce profile prefetching for newly loaded events prefetchNewEventsTimeoutRef.current = setTimeout(() => { // When we have more events loaded, prefetch profiles for the newly loaded ones // Reduced from 200 to 100 to reduce batch size const newlyLoadedPubkeys = Array.from( new Set(events.slice(showCount, showCount + 100).map((ev) => ev.pubkey).filter((p) => p?.length === 64)) ) if (newlyLoadedPubkeys.length > 0) { // Filter out already prefetched pubkeys const newPubkeys = newlyLoadedPubkeys.filter((p) => !prefetchedPubkeysRef.current.has(p)) if (newPubkeys.length > 0) { // Mark as prefetched immediately to prevent duplicate requests newPubkeys.forEach((p) => prefetchedPubkeysRef.current.add(p)) // Batch fetch in background (non-blocking) client.fetchProfilesForPubkeys(newPubkeys).catch(() => { // On error, remove from prefetched set so we can retry later newPubkeys.forEach((p) => prefetchedPubkeysRef.current.delete(p)) }) } } // CRITICAL: Prefetch embedded events for newly loaded events const newlyLoadedEmbeddedEventIds = new Set() events.slice(showCount, showCount + 100).forEach((ev) => { const embeddedIds = extractEmbeddedEventIds(ev) embeddedIds.forEach((id) => newlyLoadedEmbeddedEventIds.add(id)) }) const eventIdsToFetch = Array.from(newlyLoadedEmbeddedEventIds).filter( (id) => !prefetchedEventIdsRef.current.has(id) ) if (eventIdsToFetch.length > 0) { // Mark as prefetched immediately to prevent duplicate requests eventIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.add(id)) // Batch fetch embedded events in background (non-blocking) Promise.all(eventIdsToFetch.map((id) => client.fetchEvent(id))).catch(() => { // On error, remove from prefetched set so we can retry later eventIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.delete(id)) }) } }, 300) // Debounce by 300ms to reduce frequency during rapid scrolling return () => { if (prefetchNewEventsTimeoutRef.current) { clearTimeout(prefetchNewEventsTimeoutRef.current) prefetchNewEventsTimeoutRef.current = null } } }, [events.length, showCount, loading, hasMore]) const showNewEvents = () => { setEvents((oldEvents) => [...newEvents, ...oldEvents]) setNewEvents([]) setTimeout(() => { scrollToTop('smooth') }, 0) } const list = (
{filteredEvents.map((event) => ( ))} {hasMore || loading ? (
) : events.length ? (
{t('no more notes')}
) : (
)}
) return (
{supportTouch ? ( { refresh() await new Promise((resolve) => setTimeout(resolve, 1000)) }} pullingContent="" > {list} ) : ( list )}
{filteredNewEvents.length > 0 && ( )}
) } ) NoteList.displayName = 'NoteList' export default NoteList export type TNoteListRef = { scrollToTop: (behavior?: ScrollBehavior) => void refresh: () => void }