import NewNotesButton from '@/components/NewNotesButton' import { Button } from '@/components/ui/button' 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 { useMuteList } from '@/providers/MuteListProvider' import { useNostr } from '@/providers/NostrProvider' import { useUserTrust } from '@/providers/UserTrustProvider' import client from '@/services/client.service' import { TFeedSubRequest } from '@/types' import dayjs from 'dayjs' import { Event, kinds, verifyEvent } 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 PinnedNoteCard from '../PinnedNoteCard' const LIMIT = 200 const ALGO_LIMIT = 500 const SHOW_COUNT = 10 const NoteList = forwardRef( ( { subRequests, showKinds, filterMutedNotes = true, hideReplies = false, hideUntrustedNotes = false, areAlgoRelays = false, showRelayCloseReason = false, pinnedEventIds, filterFn, showNewNotesDirectly = false }: { subRequests: TFeedSubRequest[] showKinds?: number[] filterMutedNotes?: boolean hideReplies?: boolean hideUntrustedNotes?: boolean areAlgoRelays?: boolean showRelayCloseReason?: boolean pinnedEventIds?: string[] filterFn?: (event: Event) => boolean showNewNotesDirectly?: boolean }, ref ) => { const { t } = useTranslation() const { startLogin } = useNostr() const { isUserTrusted } = useUserTrust() const { mutePubkeySet } = useMuteList() const { hideContentMentioningMutedUsers } = useContentPolicy() const { isEventDeleted } = useDeletedEvent() 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) => { 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 } return false }, [hideUntrustedNotes, mutePubkeySet, JSON.stringify(pinnedEventIds), isEventDeleted, filterFn] ) const filteredNotes = useMemo(() => { // Store processed event keys to avoid duplicates const keySet = new Set() // Map to track reposters for each event key const repostersMap = new Map>() // Final list of filtered events const filteredEvents: Event[] = [] events.slice(0, showCount).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) { filteredEvents.push(evt) return } let eventFromContent: Event | null = null if (evt.content) { try { eventFromContent = JSON.parse(evt.content) as Event } catch { eventFromContent = null } } if (eventFromContent && verifyEvent(eventFromContent)) { if (eventFromContent.kind === kinds.Repost) { return } if (shouldHideEvent(eventFromContent)) return client.addEventToCache(eventFromContent) const targetSeenOn = client.getSeenEventRelays(eventFromContent.id) if (targetSeenOn.length === 0) { const seenOn = client.getSeenEventRelays(evt.id) seenOn.forEach((relay) => { client.trackEventSeenOn(eventFromContent.id, relay) }) } const targetEventKey = getEventKey(eventFromContent) const reposters = repostersMap.get(targetEventKey) if (reposters) { reposters.add(evt.pubkey) } else { repostersMap.set(targetEventKey, new Set([evt.pubkey])) } // If the target event is not already included, add it now if (!keySet.has(targetEventKey)) { filteredEvents.push(eventFromContent) keySet.add(targetEventKey) } return } const targetTag = evt.tags.find(tagNameEquals('a')) ?? evt.tags.find(tagNameEquals('e')) if (targetTag) { const targetEventKey = getKeyFromTag(targetTag) if (targetEventKey) { // Add to reposters map const reposters = repostersMap.get(targetEventKey) if (reposters) { reposters.add(evt.pubkey) } else { repostersMap.set(targetEventKey, new Set([evt.pubkey])) } // If the target event is already included, skip adding this repost if (keySet.has(targetEventKey)) { return } } } // If we can't find the original event, just show the repost itself filteredEvents.push(evt) return }) return filteredEvents.map((evt) => { const key = getEventKey(evt) return { key, event: evt, reposters: Array.from(repostersMap.get(key) ?? []) } }) }, [events, showCount, shouldHideEvent, hideReplies]) const filteredNewEvents = useMemo(() => { const keySet = new Set() return newEvents.filter((event: Event) => { if (shouldHideEvent(event)) return false if (hideReplies && isReplyNoteEvent(event)) return false const key = getEventKey(event) if (keySet.has(key)) { return false } keySet.add(key) return true }) }, [newEvents, shouldHideEvent]) 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) return async function init() { setLoading(true) setEvents([]) setNewEvents([]) setHasMore(true) if (showKinds?.length === 0 && subRequests.every(({ filter }) => !filter.kinds)) { setLoading(false) setHasMore(false) return () => {} } const { closer, timelineKey } = await client.subscribeTimeline( subRequests.map(({ urls, filter }) => ({ urls, filter: { kinds: showKinds ?? [], ...filter, limit: areAlgoRelays ? ALGO_LIMIT : LIMIT } })), { onEvents: (events, eosed) => { if (events.length > 0) { setEvents(events) } if (areAlgoRelays) { setHasMore(false) } if (eosed) { setLoading(false) setHasMore(events.length > 0) } }, onNew: (event) => { if (showNewNotesDirectly) { setEvents((oldEvents) => oldEvents.some((e) => e.id === event.id) ? oldEvents : [event, ...oldEvents] ) } else { setNewEvents((oldEvents) => [event, ...oldEvents].sort((a, b) => b.created_at - a.created_at) ) } }, 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)]) 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) } const list = (
{pinnedEventIds?.map((id) => )} {filteredNotes.map(({ key, event, reposters }) => ( ))} {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 }