import NewNotesButton from '@/components/NewNotesButton' import { Button } from '@/components/ui/button' import { useInfiniteScroll } from '@/hooks/useInfiniteScroll' import { getEventKey, getKeyFromTag, isMentioningMutedUsers, isReplyNoteEvent } from '@/lib/event' import { tagNameEquals } from '@/lib/tag' import { isTouchDevice } from '@/lib/utils' import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { TNavigationColumn, useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider' import { useMuteList } from '@/providers/MuteListProvider' import { useNostr } from '@/providers/NostrProvider' import { useSocialGraphFilter } from '@/providers/SocialGraphFilterProvider' import { useUserPreferences } from '@/providers/UserPreferencesProvider' import { useUserTrust } from '@/providers/UserTrustProvider' import client from '@/services/client.service' import threadService from '@/services/thread.service' 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' import NewNotesAboveIndicator from './NewNotesAboveIndicator' import PinnedNoteCard from '../PinnedNoteCard' const LIMIT = 200 const ALGO_LIMIT = 500 const SHOW_COUNT = 10 export type TNoteListRef = { scrollToTop: (behavior?: ScrollBehavior) => void refresh: () => void } const NoteList = forwardRef< TNoteListRef, { subRequests: TFeedSubRequest[] showKinds?: number[] filterMutedNotes?: boolean hideReplies?: boolean hideUntrustedNotes?: boolean hideSpam?: boolean areAlgoRelays?: boolean showRelayCloseReason?: boolean pinnedEventIds?: string[] filterFn?: (event: Event) => boolean showNewNotesDirectly?: boolean navColumn?: TNavigationColumn applySocialGraphFilter?: boolean onInitialLoad?: () => void } >( ( { subRequests, showKinds, filterMutedNotes = true, hideReplies = false, hideUntrustedNotes = false, hideSpam = false, areAlgoRelays = false, showRelayCloseReason = false, pinnedEventIds, filterFn, showNewNotesDirectly = false, navColumn = 1, applySocialGraphFilter = false, onInitialLoad }, ref ) => { const { t } = useTranslation() const { startLogin } = useNostr() const { isUserTrusted, isSpammer } = useUserTrust() const { mutePubkeySet } = useMuteList() const { hideContentMentioningMutedUsers } = useContentPolicy() const { isEventDeleted } = useDeletedEvent() const { isPubkeyAllowed } = useSocialGraphFilter() const { autoInsertNewNotes } = useUserPreferences() const { offsetSelection, registerLoadMore, unregisterLoadMore } = useKeyboardNavigation() const effectiveAutoInsert = showNewNotesDirectly || autoInsertNewNotes const [events, setEvents] = useState([]) const [newEvents, setNewEvents] = useState([]) const [initialLoading, setInitialLoading] = useState(false) const [timelineKey, setTimelineKey] = useState(undefined) const [filteredNotes, setFilteredNotes] = useState< { key: string; event: Event; reposters: string[] }[] >([]) const [filteredNewEvents, setFilteredNewEvents] = useState([]) const [refreshCount, setRefreshCount] = useState(0) const [newNotesAboveCount, setNewNotesAboveCount] = useState(0) const supportTouch = useMemo(() => isTouchDevice(), []) const topRef = useRef(null) const eventsRef = useRef(events) eventsRef.current = events const emptyRetryCountRef = useRef(0) const isAtTopRef = useRef(true) const pendingEventsRef = useRef([]) const effectiveAutoInsertRef = useRef(effectiveAutoInsert) effectiveAutoInsertRef.current = effectiveAutoInsert const onInitialLoadRef = useRef(onInitialLoad) onInitialLoadRef.current = onInitialLoad 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 (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return true if (filterMutedNotes && mutePubkeySet.has(evt.pubkey)) return true if ( filterMutedNotes && hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet) ) { return true } if (filterFn && !filterFn(evt)) { return true } // Social graph filter - only apply if enabled for this feed if (applySocialGraphFilter && !isPubkeyAllowed(evt.pubkey)) { return true } return false }, [ hideUntrustedNotes, filterMutedNotes, mutePubkeySet, hideContentMentioningMutedUsers, JSON.stringify(pinnedEventIds), isEventDeleted, filterFn, applySocialGraphFilter, isPubkeyAllowed ] ) // Synchronous filter pass — renders immediately without waiting for async spam checks useEffect(() => { const keySet = new Set() const repostersMap = new Map>() const filteredEvents: Event[] = [] const keys: string[] = [] events.forEach((evt) => { const key = getEventKey(evt) if (keySet.has(key)) return keySet.add(key) if (shouldHideEvent(evt)) return if (hideReplies && isReplyNoteEvent(evt)) return if (evt.kind !== kinds.Repost && evt.kind !== kinds.GenericRepost) { filteredEvents.push(evt) keys.push(key) return } let targetEventKey: string | undefined let eventFromContent: Event | null = null const targetTag = evt.tags.find(tagNameEquals('a')) ?? evt.tags.find(tagNameEquals('e')) if (targetTag) { targetEventKey = getKeyFromTag(targetTag) } else { if (evt.content) { try { eventFromContent = JSON.parse(evt.content) as Event } catch { eventFromContent = null } } if (eventFromContent) { if ( eventFromContent.kind === kinds.Repost || eventFromContent.kind === kinds.GenericRepost ) { return } if (shouldHideEvent(evt)) return targetEventKey = getEventKey(eventFromContent) } } if (targetEventKey) { const reposters = repostersMap.get(targetEventKey) if (reposters) { reposters.add(evt.pubkey) } else { repostersMap.set(targetEventKey, new Set([evt.pubkey])) } if (!keySet.has(targetEventKey)) { filteredEvents.push(evt) keys.push(targetEventKey) keySet.add(targetEventKey) } } }) const notes = filteredEvents.map((evt, i) => { const key = keys[i] return { key, event: evt, reposters: Array.from(repostersMap.get(key) ?? []) } }) // Render immediately with all events setFilteredNotes(notes) // Async second pass: remove spammers if hideSpam is enabled if (hideSpam) { let cancelled = false ;(async () => { const spamResults = await Promise.all( notes.map(async (note) => { return (await isSpammer(note.event.pubkey)) ? note.key : null }) ) if (cancelled) return const spamKeys = new Set(spamResults.filter(Boolean)) if (spamKeys.size > 0) { setFilteredNotes((prev) => prev.filter((n) => !spamKeys.has(n.key))) } })() return () => { cancelled = true } } }, [events, shouldHideEvent, hideReplies, isSpammer, hideSpam]) useEffect(() => { const processNewEvents = async () => { const keySet = new Set() const filteredEvents: Event[] = [] newEvents.forEach((event) => { if (shouldHideEvent(event)) return if (hideReplies && isReplyNoteEvent(event)) return const key = getEventKey(event) if (keySet.has(key)) { return } keySet.add(key) filteredEvents.push(event) }) const _filteredNotes = ( await Promise.all( filteredEvents.map(async (evt) => { if (hideSpam && (await isSpammer(evt.pubkey))) { return null } return evt }) ) ).filter(Boolean) as Event[] setFilteredNewEvents(_filteredNotes) } processNewEvents() }, [newEvents, shouldHideEvent, isSpammer, hideSpam]) 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(() => { if (!subRequests.length) { onInitialLoadRef.current?.() return } async function init() { setInitialLoading(true) setEvents([]) setNewEvents([]) pendingEventsRef.current = [] if (showKinds?.length === 0 && subRequests.every(({ filter }) => !filter.kinds)) { return () => {} } const preprocessedSubRequests = await Promise.all( subRequests.map(async ({ urls, filter }) => { const relays = urls.length ? urls : await client.determineRelaysByFilter(filter) return { urls: relays, filter: { kinds: showKinds ?? [], ...filter, limit: areAlgoRelays ? ALGO_LIMIT : LIMIT } } }) ) const { closer, timelineKey } = await client.subscribeTimeline( preprocessedSubRequests, { onEvents: (events, eosed) => { if (events.length > 0) { setEvents(events) // Show content as soon as first events arrive, don't wait for EOSE setInitialLoading(false) onInitialLoadRef.current?.() } if (eosed) { threadService.addRepliesToThread(events) // Final fallback in case no events arrived setInitialLoading(false) onInitialLoadRef.current?.() } }, onNew: (event) => { if (effectiveAutoInsertRef.current) { if (isAtTopRef.current) { // User is at top — insert directly setEvents((oldEvents) => oldEvents.some((e) => e.id === event.id) ? oldEvents : [event, ...oldEvents] ) } else { // User is scrolled down — buffer to avoid layout shift if (!pendingEventsRef.current.some((e) => e.id === event.id)) { pendingEventsRef.current = [event, ...pendingEventsRef.current] } setNewNotesAboveCount((c) => c + 1) } } else { setNewEvents((oldEvents) => [event, ...oldEvents].sort((a, b) => b.created_at - a.created_at) ) } threadService.addRepliesToThread([event]) }, onClose: (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 } ) setTimelineKey(timelineKey) return closer } const promise = init() return () => { promise.then((closer) => closer()) } }, [JSON.stringify(subRequests), refreshCount, JSON.stringify(showKinds)]) const handleLoadMore = useCallback(async () => { if (!timelineKey || areAlgoRelays) return false const currentEvents = eventsRef.current const newEvents = await client.loadMoreTimeline( timelineKey, currentEvents.length ? currentEvents[currentEvents.length - 1].created_at - 1 : dayjs().unix(), LIMIT ) if (newEvents.length === 0) { emptyRetryCountRef.current++ // Allow up to 3 consecutive empty responses before giving up if (emptyRetryCountRef.current >= 3) { emptyRetryCountRef.current = 0 return false } return true } emptyRetryCountRef.current = 0 setEvents((oldEvents) => [...oldEvents, ...newEvents]) return true }, [timelineKey, areAlgoRelays]) const { visibleItems, shouldShowLoadingIndicator, bottomRef } = useInfiniteScroll({ items: filteredNotes, showCount: SHOW_COUNT, onLoadMore: handleLoadMore, initialLoading }) // Register load more callback for keyboard navigation useEffect(() => { registerLoadMore(navColumn, handleLoadMore) return () => unregisterLoadMore(navColumn) }, [navColumn, handleLoadMore, registerLoadMore, unregisterLoadMore]) // Track whether user is at the top of the feed for live mode indicator useEffect(() => { const el = topRef.current if (!el) return const observer = new IntersectionObserver( ([entry]) => { const atTop = entry.isIntersecting isAtTopRef.current = atTop if (atTop) { setNewNotesAboveCount(0) // Flush buffered events when user reaches the top if (pendingEventsRef.current.length > 0) { const pending = [...pendingEventsRef.current] pendingEventsRef.current = [] setEvents((oldEvents) => { const existingIds = new Set(oldEvents.map((e) => e.id)) const uniqueNew = pending.filter((e) => !existingIds.has(e.id)) return [...uniqueNew, ...oldEvents] }) } } }, { threshold: 0 } ) observer.observe(el) return () => observer.disconnect() }, []) const showNewEvents = useCallback(() => { if (filteredNewEvents.length === 0) return // Offset the selection by the number of new items being added at the top offsetSelection(navColumn, filteredNewEvents.length) setEvents((oldEvents) => [...newEvents, ...oldEvents]) setNewEvents([]) setTimeout(() => { scrollToTop('smooth') }, 0) }, [filteredNewEvents.length, navColumn, newEvents, offsetSelection]) // Shift+Enter to show new notes useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.shiftKey && e.key === 'Enter' && filteredNewEvents.length > 0) { e.preventDefault() showNewEvents() } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [showNewEvents, filteredNewEvents.length]) const list = (
{pinnedEventIds?.map((id) => )} {visibleItems.map(({ key, event, reposters }, index) => ( ))}
{shouldShowLoadingIndicator || initialLoading ? ( ) : events.length ? (
{t('no more notes')}
) : (
)}
) return (
{effectiveAutoInsert && ( scrollToTop('smooth')} /> )} {supportTouch ? ( { refresh() await new Promise((resolve) => setTimeout(resolve, 1000)) }} pullingContent="" > {list} ) : ( list )}
{!effectiveAutoInsert && filteredNewEvents.length > 0 && ( )}
) } ) NoteList.displayName = 'NoteList' export default NoteList