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