import { Separator } from '@/components/ui/separator' import { BIG_RELAY_URLS } from '@/constants' import { getParentEventHexId, getRootEventHexId, getRootEventTag, isReplyNoteEvent } from '@/lib/event' import { generateEventIdFromETag } from '@/lib/tag' import { useSecondaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' import { useNoteStats } from '@/providers/NoteStatsProvider' import client from '@/services/client.service' import { Event as NEvent, kinds } from 'nostr-tools' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import ReplyNote from '../ReplyNote' const LIMIT = 100 export default function ReplyNoteList({ index, event, className }: { index?: number event: NEvent className?: string }) { const { t } = useTranslation() const { currentIndex } = useSecondaryPage() const { pubkey } = useNostr() const [rootInfo, setRootInfo] = useState<{ id: string; pubkey: string } | undefined>(undefined) const [timelineKey, setTimelineKey] = useState(undefined) const [until, setUntil] = useState(undefined) const [events, setEvents] = useState([]) const [replies, setReplies] = useState([]) const [replyMap, setReplyMap] = useState< Map >(new Map()) const [loading, setLoading] = useState(false) const [highlightReplyId, setHighlightReplyId] = useState(undefined) const { updateNoteReplyCount } = useNoteStats() const replyRefs = useRef>({}) const bottomRef = useRef(null) useEffect(() => { const fetchRootEvent = async () => { let root = { id: event.id, pubkey: event.pubkey } const rootEventTag = getRootEventTag(event) if (rootEventTag) { const [, rootEventHexId, , , rootEventPubkey] = rootEventTag if (rootEventHexId && rootEventPubkey) { root = { id: rootEventHexId, pubkey: rootEventPubkey } } else { const rootEventId = generateEventIdFromETag(rootEventTag) if (rootEventId) { const rootEvent = await client.fetchEvent(rootEventId) if (rootEvent) { root = { id: rootEvent.id, pubkey: rootEvent.pubkey } } } } } setRootInfo(root) } fetchRootEvent() }, [event]) useEffect(() => { if (!rootInfo) return const handleEventPublished = (data: Event) => { const customEvent = data as CustomEvent const evt = customEvent.detail const rootId = getRootEventHexId(evt) if (rootId === rootInfo.id && isReplyNoteEvent(evt)) { onNewReply(evt) } } client.addEventListener('eventPublished', handleEventPublished) return () => { client.removeEventListener('eventPublished', handleEventPublished) } }, [rootInfo]) useEffect(() => { if (loading || !rootInfo || currentIndex !== index) return const init = async () => { setLoading(true) setEvents([]) try { const relayList = await client.fetchRelayList(rootInfo.pubkey) const relayUrls = relayList.read.concat(BIG_RELAY_URLS) const seenOn = client.getSeenEventRelayUrls(rootInfo.id) relayUrls.unshift(...seenOn) let eventCount = 0 const { closer, timelineKey } = await client.subscribeTimeline( relayUrls.slice(0, 5), { '#e': [rootInfo.id], kinds: [kinds.ShortTextNote], limit: LIMIT }, { onEvents: (evts, eosed) => { if (eventCount > events.length) return eventCount = events.length if (events.length > 0) { setEvents(evts.filter((evt) => isReplyNoteEvent(evt)).reverse()) } if (eosed) { setUntil(evts.length >= LIMIT ? evts[evts.length - 1].created_at - 1 : undefined) setLoading(false) } }, onNew: (evt) => { if (!isReplyNoteEvent(evt)) return onNewReply(evt) } } ) setTimelineKey(timelineKey) return closer } catch { setLoading(false) } return } const promise = init() return () => { promise.then((closer) => closer?.()) } }, [rootInfo, currentIndex, index]) useEffect(() => { const replies: NEvent[] = [] const replyMap: Map = new Map() const rootEventId = getRootEventHexId(event) ?? event.id const isRootEvent = rootEventId === event.id for (const evt of events) { if (evt.created_at < event.created_at) continue const parentEventId = getParentEventHexId(evt) if (parentEventId) { const parentReplyInfo = replyMap.get(parentEventId) if (!parentReplyInfo && parentEventId !== event.id) continue const level = parentReplyInfo ? parentReplyInfo.level + 1 : 1 replies.push(evt) replyMap.set(evt.id, { event: evt, level, parent: parentReplyInfo?.event }) continue } if (!isRootEvent) continue replies.push(evt) replyMap.set(evt.id, { event: evt, level: 1 }) } setReplyMap(replyMap) setReplies(replies) updateNoteReplyCount(event.id, replies.length) if (replies.length === 0) { loadMore() } }, [events, event, updateNoteReplyCount]) const loadMore = useCallback(async () => { if (loading || !until || !timelineKey) return setLoading(true) const events = await client.loadMoreTimeline(timelineKey, until, LIMIT) const olderEvents = events.filter((evt) => isReplyNoteEvent(evt)).reverse() if (olderEvents.length > 0) { setEvents((pre) => [...olderEvents, ...pre]) } setUntil(events.length ? events[events.length - 1].created_at - 1 : undefined) setLoading(false) }, [loading, until, timelineKey]) const onNewReply = useCallback( (evt: NEvent) => { setEvents((pre) => { if (pre.some((reply) => reply.id === evt.id)) return pre return [...pre, evt] }) if (evt.pubkey === pubkey) { setTimeout(() => { if (bottomRef.current) { bottomRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) } highlightReply(evt.id, false) }, 100) } }, [pubkey] ) const highlightReply = useCallback((eventId: string, scrollTo = true) => { if (scrollTo) { const ref = replyRefs.current[eventId] if (ref) { ref.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) } } setHighlightReplyId(eventId) setTimeout(() => { setHighlightReplyId((pre) => (pre === eventId ? undefined : pre)) }, 1500) }, []) return ( <> {(loading || (!!until && replies.length > 0)) && (
{loading ? t('loading...') : t('load more older replies')}
)} {replies.length > 0 && (loading || until) && }
{replies.map((reply) => { const info = replyMap.get(reply.id) return (
(replyRefs.current[reply.id] = el)} key={reply.id}>
) })}
{!loading && (
{replies.length > 0 ? t('no more replies') : t('no replies')}
)}
) }