import NewNotesButton from '@/components/NewNotesButton' import { Button } from '@/components/ui/button' import { ExtendedKind } from '@/constants' import { getReplaceableCoordinateFromEvent, isReplaceableEvent, isReplyNoteEvent } from '@/lib/event' 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 } from 'nostr-tools' import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import PullToRefresh from 'react-simple-pull-to-refresh' import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' const LIMIT = 100 const ALGO_LIMIT = 500 const KINDS = [ kinds.ShortTextNote, kinds.Repost, kinds.Highlights, kinds.LongFormArticle, ExtendedKind.COMMENT, ExtendedKind.POLL, ExtendedKind.VOICE, ExtendedKind.VOICE_COMMENT, ExtendedKind.PICTURE ] const SHOW_COUNT = 10 const NoteList = forwardRef( ( { subRequests, filterMutedNotes = true, hideReplies = false, hideUntrustedNotes = false, areAlgoRelays = false }: { subRequests: TFeedSubRequest[] filterMutedNotes?: boolean hideReplies?: boolean hideUntrustedNotes?: boolean areAlgoRelays?: boolean }, ref ) => { const { t } = useTranslation() const { startLogin } = useNostr() const { isUserTrusted } = useUserTrust() const { mutePubkeys } = useMuteList() 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 bottomRef = useRef(null) const topRef = useRef(null) const filteredEvents = useMemo(() => { const idSet = new Set() return events.slice(0, showCount).filter((evt) => { if (hideReplies && isReplyNoteEvent(evt)) return false if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return false const id = isReplaceableEvent(evt.kind) ? getReplaceableCoordinateFromEvent(evt) : evt.id if (idSet.has(id)) { return false } idSet.add(id) return true }) }, [events, hideReplies, hideUntrustedNotes, showCount]) const filteredNewEvents = useMemo(() => { const idSet = new Set() return newEvents.filter((event: Event) => { if (hideReplies && isReplyNoteEvent(event)) return false if (hideUntrustedNotes && !isUserTrusted(event.pubkey)) return false if (filterMutedNotes && mutePubkeys.includes(event.pubkey)) return false const id = isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) : event.id if (idSet.has(id)) { return false } idSet.add(id) return true }) }, [newEvents, hideReplies, hideUntrustedNotes, filterMutedNotes, mutePubkeys]) const scrollToTop = () => { topRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) } useImperativeHandle(ref, () => ({ scrollToTop }), []) useEffect(() => { if (!subRequests.length) return async function init() { setLoading(true) setEvents([]) setNewEvents([]) setHasMore(true) const { closer, timelineKey } = await client.subscribeTimeline( subRequests.map(({ urls, filter }) => ({ urls, filter: { kinds: KINDS, ...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) => { setNewEvents((oldEvents) => [event, ...oldEvents].sort((a, b) => b.created_at - a.created_at) ) } }, { startLogin, needSort: !areAlgoRelays } ) setTimelineKey(timelineKey) return closer } const promise = init() return () => { promise.then((closer) => closer()) } }, [JSON.stringify(subRequests), refreshCount]) 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 > SHOW_COUNT * 5) { 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() }, 0) } return (
{filteredNewEvents.length > 0 && ( )}
{ setRefreshCount((count) => count + 1) await new Promise((resolve) => setTimeout(resolve, 1000)) }} pullingContent="" >
{filteredEvents.map((event) => ( ))} {hasMore || loading ? (
) : events.length ? (
{t('no more notes')}
) : (
)}
) } ) NoteList.displayName = 'NoteList' export default NoteList export type TNoteListRef = { scrollToTop: () => void }