index.tsx raw

   1  import NewNotesButton from '@/components/NewNotesButton'
   2  import { Button } from '@/components/ui/button'
   3  import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'
   4  import { getEventKey, getKeyFromTag, isMentioningMutedUsers, isReplyNoteEvent } from '@/lib/event'
   5  import { tagNameEquals } from '@/lib/tag'
   6  import { isTouchDevice } from '@/lib/utils'
   7  import { useContentPolicy } from '@/providers/ContentPolicyProvider'
   8  import { useDeletedEvent } from '@/providers/DeletedEventProvider'
   9  import { TNavigationColumn, useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
  10  import { useMuteList } from '@/providers/MuteListProvider'
  11  import { useNostr } from '@/providers/NostrProvider'
  12  import { useSocialGraphFilter } from '@/providers/SocialGraphFilterProvider'
  13  import { useUserPreferences } from '@/providers/UserPreferencesProvider'
  14  import { useUserTrust } from '@/providers/UserTrustProvider'
  15  import client from '@/services/client.service'
  16  import threadService from '@/services/thread.service'
  17  import { TFeedSubRequest } from '@/types'
  18  import dayjs from 'dayjs'
  19  import { Event, kinds } from 'nostr-tools'
  20  import { decode } from 'nostr-tools/nip19'
  21  import {
  22    forwardRef,
  23    useCallback,
  24    useEffect,
  25    useImperativeHandle,
  26    useMemo,
  27    useRef,
  28    useState
  29  } from 'react'
  30  import { useTranslation } from 'react-i18next'
  31  import PullToRefresh from 'react-simple-pull-to-refresh'
  32  import { toast } from 'sonner'
  33  import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
  34  import NewNotesAboveIndicator from './NewNotesAboveIndicator'
  35  import PinnedNoteCard from '../PinnedNoteCard'
  36  
  37  const LIMIT = 200
  38  const ALGO_LIMIT = 500
  39  const SHOW_COUNT = 10
  40  
  41  export type TNoteListRef = {
  42    scrollToTop: (behavior?: ScrollBehavior) => void
  43    refresh: () => void
  44  }
  45  
  46  const NoteList = forwardRef<
  47    TNoteListRef,
  48    {
  49      subRequests: TFeedSubRequest[]
  50      showKinds?: number[]
  51      filterMutedNotes?: boolean
  52      hideReplies?: boolean
  53      hideUntrustedNotes?: boolean
  54      hideSpam?: boolean
  55      areAlgoRelays?: boolean
  56      showRelayCloseReason?: boolean
  57      pinnedEventIds?: string[]
  58      filterFn?: (event: Event) => boolean
  59      showNewNotesDirectly?: boolean
  60      navColumn?: TNavigationColumn
  61      applySocialGraphFilter?: boolean
  62      onInitialLoad?: () => void
  63    }
  64  >(
  65    (
  66      {
  67        subRequests,
  68        showKinds,
  69        filterMutedNotes = true,
  70        hideReplies = false,
  71        hideUntrustedNotes = false,
  72        hideSpam = false,
  73        areAlgoRelays = false,
  74        showRelayCloseReason = false,
  75        pinnedEventIds,
  76        filterFn,
  77        showNewNotesDirectly = false,
  78        navColumn = 1,
  79        applySocialGraphFilter = false,
  80        onInitialLoad
  81      },
  82      ref
  83    ) => {
  84      const { t } = useTranslation()
  85      const { startLogin } = useNostr()
  86      const { isUserTrusted, isSpammer } = useUserTrust()
  87      const { mutePubkeySet } = useMuteList()
  88      const { hideContentMentioningMutedUsers } = useContentPolicy()
  89      const { isEventDeleted } = useDeletedEvent()
  90      const { isPubkeyAllowed } = useSocialGraphFilter()
  91      const { autoInsertNewNotes } = useUserPreferences()
  92      const { offsetSelection, registerLoadMore, unregisterLoadMore } = useKeyboardNavigation()
  93      const effectiveAutoInsert = showNewNotesDirectly || autoInsertNewNotes
  94      const [events, setEvents] = useState<Event[]>([])
  95      const [newEvents, setNewEvents] = useState<Event[]>([])
  96      const [initialLoading, setInitialLoading] = useState(false)
  97      const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
  98      const [filteredNotes, setFilteredNotes] = useState<
  99        { key: string; event: Event; reposters: string[] }[]
 100      >([])
 101      const [filteredNewEvents, setFilteredNewEvents] = useState<Event[]>([])
 102      const [refreshCount, setRefreshCount] = useState(0)
 103      const [newNotesAboveCount, setNewNotesAboveCount] = useState(0)
 104      const supportTouch = useMemo(() => isTouchDevice(), [])
 105      const topRef = useRef<HTMLDivElement | null>(null)
 106      const eventsRef = useRef(events)
 107      eventsRef.current = events
 108      const emptyRetryCountRef = useRef(0)
 109      const isAtTopRef = useRef(true)
 110      const pendingEventsRef = useRef<Event[]>([])
 111      const effectiveAutoInsertRef = useRef(effectiveAutoInsert)
 112      effectiveAutoInsertRef.current = effectiveAutoInsert
 113      const onInitialLoadRef = useRef(onInitialLoad)
 114      onInitialLoadRef.current = onInitialLoad
 115  
 116      const shouldHideEvent = useCallback(
 117        (evt: Event) => {
 118          const pinnedEventHexIdSet = new Set()
 119          pinnedEventIds?.forEach((id) => {
 120            try {
 121              const { type, data } = decode(id)
 122              if (type === 'nevent') {
 123                pinnedEventHexIdSet.add(data.id)
 124              }
 125            } catch {
 126              // ignore
 127            }
 128          })
 129  
 130          if (pinnedEventHexIdSet.has(evt.id)) return true
 131          if (isEventDeleted(evt)) return true
 132          if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return true
 133          if (filterMutedNotes && mutePubkeySet.has(evt.pubkey)) return true
 134          if (
 135            filterMutedNotes &&
 136            hideContentMentioningMutedUsers &&
 137            isMentioningMutedUsers(evt, mutePubkeySet)
 138          ) {
 139            return true
 140          }
 141          if (filterFn && !filterFn(evt)) {
 142            return true
 143          }
 144          // Social graph filter - only apply if enabled for this feed
 145          if (applySocialGraphFilter && !isPubkeyAllowed(evt.pubkey)) {
 146            return true
 147          }
 148  
 149          return false
 150        },
 151        [
 152          hideUntrustedNotes,
 153          filterMutedNotes,
 154          mutePubkeySet,
 155          hideContentMentioningMutedUsers,
 156          JSON.stringify(pinnedEventIds),
 157          isEventDeleted,
 158          filterFn,
 159          applySocialGraphFilter,
 160          isPubkeyAllowed
 161        ]
 162      )
 163  
 164      // Synchronous filter pass — renders immediately without waiting for async spam checks
 165      useEffect(() => {
 166        const keySet = new Set<string>()
 167        const repostersMap = new Map<string, Set<string>>()
 168        const filteredEvents: Event[] = []
 169        const keys: string[] = []
 170  
 171        events.forEach((evt) => {
 172          const key = getEventKey(evt)
 173          if (keySet.has(key)) return
 174          keySet.add(key)
 175  
 176          if (shouldHideEvent(evt)) return
 177          if (hideReplies && isReplyNoteEvent(evt)) return
 178          if (evt.kind !== kinds.Repost && evt.kind !== kinds.GenericRepost) {
 179            filteredEvents.push(evt)
 180            keys.push(key)
 181            return
 182          }
 183  
 184          let targetEventKey: string | undefined
 185          let eventFromContent: Event | null = null
 186          const targetTag = evt.tags.find(tagNameEquals('a')) ?? evt.tags.find(tagNameEquals('e'))
 187          if (targetTag) {
 188            targetEventKey = getKeyFromTag(targetTag)
 189          } else {
 190            if (evt.content) {
 191              try {
 192                eventFromContent = JSON.parse(evt.content) as Event
 193              } catch {
 194                eventFromContent = null
 195              }
 196            }
 197            if (eventFromContent) {
 198              if (
 199                eventFromContent.kind === kinds.Repost ||
 200                eventFromContent.kind === kinds.GenericRepost
 201              ) {
 202                return
 203              }
 204              if (shouldHideEvent(evt)) return
 205              targetEventKey = getEventKey(eventFromContent)
 206            }
 207          }
 208  
 209          if (targetEventKey) {
 210            const reposters = repostersMap.get(targetEventKey)
 211            if (reposters) {
 212              reposters.add(evt.pubkey)
 213            } else {
 214              repostersMap.set(targetEventKey, new Set([evt.pubkey]))
 215            }
 216            if (!keySet.has(targetEventKey)) {
 217              filteredEvents.push(evt)
 218              keys.push(targetEventKey)
 219              keySet.add(targetEventKey)
 220            }
 221          }
 222        })
 223  
 224        const notes = filteredEvents.map((evt, i) => {
 225          const key = keys[i]
 226          return { key, event: evt, reposters: Array.from(repostersMap.get(key) ?? []) }
 227        })
 228  
 229        // Render immediately with all events
 230        setFilteredNotes(notes)
 231  
 232        // Async second pass: remove spammers if hideSpam is enabled
 233        if (hideSpam) {
 234          let cancelled = false
 235          ;(async () => {
 236            const spamResults = await Promise.all(
 237              notes.map(async (note) => {
 238                return (await isSpammer(note.event.pubkey)) ? note.key : null
 239              })
 240            )
 241            if (cancelled) return
 242            const spamKeys = new Set(spamResults.filter(Boolean))
 243            if (spamKeys.size > 0) {
 244              setFilteredNotes((prev) => prev.filter((n) => !spamKeys.has(n.key)))
 245            }
 246          })()
 247          return () => { cancelled = true }
 248        }
 249      }, [events, shouldHideEvent, hideReplies, isSpammer, hideSpam])
 250  
 251      useEffect(() => {
 252        const processNewEvents = async () => {
 253          const keySet = new Set<string>()
 254          const filteredEvents: Event[] = []
 255  
 256          newEvents.forEach((event) => {
 257            if (shouldHideEvent(event)) return
 258            if (hideReplies && isReplyNoteEvent(event)) return
 259  
 260            const key = getEventKey(event)
 261            if (keySet.has(key)) {
 262              return
 263            }
 264            keySet.add(key)
 265            filteredEvents.push(event)
 266          })
 267  
 268          const _filteredNotes = (
 269            await Promise.all(
 270              filteredEvents.map(async (evt) => {
 271                if (hideSpam && (await isSpammer(evt.pubkey))) {
 272                  return null
 273                }
 274                return evt
 275              })
 276            )
 277          ).filter(Boolean) as Event[]
 278          setFilteredNewEvents(_filteredNotes)
 279        }
 280        processNewEvents()
 281      }, [newEvents, shouldHideEvent, isSpammer, hideSpam])
 282  
 283      const scrollToTop = (behavior: ScrollBehavior = 'instant') => {
 284        setTimeout(() => {
 285          topRef.current?.scrollIntoView({ behavior, block: 'start' })
 286        }, 20)
 287      }
 288  
 289      const refresh = () => {
 290        scrollToTop()
 291        setTimeout(() => {
 292          setRefreshCount((count) => count + 1)
 293        }, 500)
 294      }
 295  
 296      useImperativeHandle(ref, () => ({ scrollToTop, refresh }), [])
 297  
 298      useEffect(() => {
 299        if (!subRequests.length) {
 300          onInitialLoadRef.current?.()
 301          return
 302        }
 303  
 304        async function init() {
 305          setInitialLoading(true)
 306          setEvents([])
 307          setNewEvents([])
 308          pendingEventsRef.current = []
 309  
 310          if (showKinds?.length === 0 && subRequests.every(({ filter }) => !filter.kinds)) {
 311            return () => {}
 312          }
 313  
 314          const preprocessedSubRequests = await Promise.all(
 315            subRequests.map(async ({ urls, filter }) => {
 316              const relays = urls.length ? urls : await client.determineRelaysByFilter(filter)
 317              return {
 318                urls: relays,
 319                filter: {
 320                  kinds: showKinds ?? [],
 321                  ...filter,
 322                  limit: areAlgoRelays ? ALGO_LIMIT : LIMIT
 323                }
 324              }
 325            })
 326          )
 327  
 328          const { closer, timelineKey } = await client.subscribeTimeline(
 329            preprocessedSubRequests,
 330            {
 331              onEvents: (events, eosed) => {
 332                if (events.length > 0) {
 333                  setEvents(events)
 334                  // Show content as soon as first events arrive, don't wait for EOSE
 335                  setInitialLoading(false)
 336                  onInitialLoadRef.current?.()
 337                }
 338                if (eosed) {
 339                  threadService.addRepliesToThread(events)
 340                  // Final fallback in case no events arrived
 341                  setInitialLoading(false)
 342                  onInitialLoadRef.current?.()
 343                }
 344              },
 345              onNew: (event) => {
 346                if (effectiveAutoInsertRef.current) {
 347                  if (isAtTopRef.current) {
 348                    // User is at top — insert directly
 349                    setEvents((oldEvents) =>
 350                      oldEvents.some((e) => e.id === event.id) ? oldEvents : [event, ...oldEvents]
 351                    )
 352                  } else {
 353                    // User is scrolled down — buffer to avoid layout shift
 354                    if (!pendingEventsRef.current.some((e) => e.id === event.id)) {
 355                      pendingEventsRef.current = [event, ...pendingEventsRef.current]
 356                    }
 357                    setNewNotesAboveCount((c) => c + 1)
 358                  }
 359                } else {
 360                  setNewEvents((oldEvents) =>
 361                    [event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
 362                  )
 363                }
 364                threadService.addRepliesToThread([event])
 365              },
 366              onClose: (url, reason) => {
 367                if (!showRelayCloseReason) return
 368                // ignore reasons from nostr-tools
 369                if (
 370                  [
 371                    'closed by caller',
 372                    'relay connection errored',
 373                    'relay connection closed',
 374                    'pingpong timed out',
 375                    'relay connection closed by us'
 376                  ].includes(reason)
 377                ) {
 378                  return
 379                }
 380  
 381                toast.error(`${url}: ${reason}`)
 382              }
 383            },
 384            {
 385              startLogin,
 386              needSort: !areAlgoRelays
 387            }
 388          )
 389          setTimelineKey(timelineKey)
 390          return closer
 391        }
 392  
 393        const promise = init()
 394        return () => {
 395          promise.then((closer) => closer())
 396        }
 397      }, [JSON.stringify(subRequests), refreshCount, JSON.stringify(showKinds)])
 398  
 399      const handleLoadMore = useCallback(async () => {
 400        if (!timelineKey || areAlgoRelays) return false
 401        const currentEvents = eventsRef.current
 402        const newEvents = await client.loadMoreTimeline(
 403          timelineKey,
 404          currentEvents.length ? currentEvents[currentEvents.length - 1].created_at - 1 : dayjs().unix(),
 405          LIMIT
 406        )
 407        if (newEvents.length === 0) {
 408          emptyRetryCountRef.current++
 409          // Allow up to 3 consecutive empty responses before giving up
 410          if (emptyRetryCountRef.current >= 3) {
 411            emptyRetryCountRef.current = 0
 412            return false
 413          }
 414          return true
 415        }
 416        emptyRetryCountRef.current = 0
 417        setEvents((oldEvents) => [...oldEvents, ...newEvents])
 418        return true
 419      }, [timelineKey, areAlgoRelays])
 420  
 421      const { visibleItems, shouldShowLoadingIndicator, bottomRef } = useInfiniteScroll({
 422        items: filteredNotes,
 423        showCount: SHOW_COUNT,
 424        onLoadMore: handleLoadMore,
 425        initialLoading
 426      })
 427  
 428      // Register load more callback for keyboard navigation
 429      useEffect(() => {
 430        registerLoadMore(navColumn, handleLoadMore)
 431        return () => unregisterLoadMore(navColumn)
 432      }, [navColumn, handleLoadMore, registerLoadMore, unregisterLoadMore])
 433  
 434      // Track whether user is at the top of the feed for live mode indicator
 435      useEffect(() => {
 436        const el = topRef.current
 437        if (!el) return
 438  
 439        const observer = new IntersectionObserver(
 440          ([entry]) => {
 441            const atTop = entry.isIntersecting
 442            isAtTopRef.current = atTop
 443            if (atTop) {
 444              setNewNotesAboveCount(0)
 445              // Flush buffered events when user reaches the top
 446              if (pendingEventsRef.current.length > 0) {
 447                const pending = [...pendingEventsRef.current]
 448                pendingEventsRef.current = []
 449                setEvents((oldEvents) => {
 450                  const existingIds = new Set(oldEvents.map((e) => e.id))
 451                  const uniqueNew = pending.filter((e) => !existingIds.has(e.id))
 452                  return [...uniqueNew, ...oldEvents]
 453                })
 454              }
 455            }
 456          },
 457          { threshold: 0 }
 458        )
 459        observer.observe(el)
 460        return () => observer.disconnect()
 461      }, [])
 462  
 463      const showNewEvents = useCallback(() => {
 464        if (filteredNewEvents.length === 0) return
 465        // Offset the selection by the number of new items being added at the top
 466        offsetSelection(navColumn, filteredNewEvents.length)
 467        setEvents((oldEvents) => [...newEvents, ...oldEvents])
 468        setNewEvents([])
 469        setTimeout(() => {
 470          scrollToTop('smooth')
 471        }, 0)
 472      }, [filteredNewEvents.length, navColumn, newEvents, offsetSelection])
 473  
 474      // Shift+Enter to show new notes
 475      useEffect(() => {
 476        const handleKeyDown = (e: KeyboardEvent) => {
 477          if (e.shiftKey && e.key === 'Enter' && filteredNewEvents.length > 0) {
 478            e.preventDefault()
 479            showNewEvents()
 480          }
 481        }
 482        window.addEventListener('keydown', handleKeyDown)
 483        return () => window.removeEventListener('keydown', handleKeyDown)
 484      }, [showNewEvents, filteredNewEvents.length])
 485  
 486      const list = (
 487        <div className="min-h-screen">
 488          {pinnedEventIds?.map((id) => <PinnedNoteCard key={id} eventId={id} className="w-full" />)}
 489          {visibleItems.map(({ key, event, reposters }, index) => (
 490            <NoteCard
 491              key={key}
 492              className="w-full"
 493              event={event}
 494              filterMutedNotes={filterMutedNotes}
 495              reposters={reposters}
 496              navColumn={navColumn}
 497              navIndex={index}
 498            />
 499          ))}
 500          <div ref={bottomRef} />
 501          {shouldShowLoadingIndicator || initialLoading ? (
 502            <NoteCardLoadingSkeleton />
 503          ) : events.length ? (
 504            <div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div>
 505          ) : (
 506            <div className="flex justify-center w-full mt-2">
 507              <Button size="lg" onClick={() => setRefreshCount((count) => count + 1)}>
 508                {t('Reload')}
 509              </Button>
 510            </div>
 511          )}
 512        </div>
 513      )
 514  
 515      return (
 516        <div>
 517          <div ref={topRef} className="scroll-mt-[calc(6rem+1px)]" />
 518          {effectiveAutoInsert && (
 519            <NewNotesAboveIndicator
 520              count={newNotesAboveCount}
 521              onClick={() => scrollToTop('smooth')}
 522            />
 523          )}
 524          {supportTouch ? (
 525            <PullToRefresh
 526              onRefresh={async () => {
 527                refresh()
 528                await new Promise((resolve) => setTimeout(resolve, 1000))
 529              }}
 530              pullingContent=""
 531            >
 532              {list}
 533            </PullToRefresh>
 534          ) : (
 535            list
 536          )}
 537          <div className="h-20" />
 538          {!effectiveAutoInsert && filteredNewEvents.length > 0 && (
 539            <NewNotesButton newEvents={filteredNewEvents} onClick={showNewEvents} />
 540          )}
 541        </div>
 542      )
 543    }
 544  )
 545  NoteList.displayName = 'NoteList'
 546  export default NoteList
 547