import { BIG_RELAY_URLS, ExtendedKind } from '@/constants' import { getEventCoordinate, getParentEventTag, getRootAddressableEventTag, getRootEventHexId, getRootEventTag, isReplaceable, isReplyNoteEvent } from '@/lib/event' import { generateEventIdFromETag, tagNameEquals } from '@/lib/tag' import { useSecondaryPage } from '@/PageManager' import { useReply } from '@/providers/ReplyProvider' import { useUserTrust } from '@/providers/UserTrustProvider' import client from '@/services/client.service' import { Filter, Event as NEvent, kinds } from 'nostr-tools' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { LoadingBar } from '../LoadingBar' import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote' type TRootInfo = | { type: 'E'; id: string; pubkey: string } | { type: 'A'; id: string; eventId: string; pubkey: string; relay?: string } | { type: 'I'; id: string } const LIMIT = 100 const SHOW_COUNT = 10 export default function ReplyNoteList({ index, event }: { index?: number; event: NEvent }) { const { t } = useTranslation() const { currentIndex } = useSecondaryPage() const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() const [rootInfo, setRootInfo] = useState(undefined) const { repliesMap, addReplies } = useReply() const replies = useMemo(() => { const replyIdSet = new Set() const replyEvents: NEvent[] = [] const currentEventId = isReplaceable(event.kind) ? getEventCoordinate(event) : event.id let parentEventIds = [currentEventId] while (parentEventIds.length > 0) { const events = parentEventIds.flatMap((id) => repliesMap.get(id)?.events || []) events.forEach((evt) => { if (replyIdSet.has(evt.id)) return replyIdSet.add(evt.id) replyEvents.push(evt) }) parentEventIds = events.map((evt) => evt.id) } return replyEvents.sort((a, b) => a.created_at - b.created_at) }, [event.id, repliesMap]) const [timelineKey, setTimelineKey] = useState(undefined) const [until, setUntil] = useState(undefined) const [loading, setLoading] = useState(false) const [showCount, setShowCount] = useState(SHOW_COUNT) const [highlightReplyId, setHighlightReplyId] = useState(undefined) const replyRefs = useRef>({}) const bottomRef = useRef(null) useEffect(() => { const fetchRootEvent = async () => { let root: TRootInfo = isReplaceable(event.kind) ? { type: 'A', id: getEventCoordinate(event), eventId: event.id, pubkey: event.pubkey, relay: client.getEventHint(event.id) } : { type: 'E', id: event.id, pubkey: event.pubkey } const rootEventTag = getRootEventTag(event) if (rootEventTag) { const [, rootEventHexId, , , rootEventPubkey] = rootEventTag if (rootEventHexId && rootEventPubkey) { root = { type: 'E', id: rootEventHexId, pubkey: rootEventPubkey } } else { const rootEventId = generateEventIdFromETag(rootEventTag) if (rootEventId) { const rootEvent = await client.fetchEvent(rootEventId) if (rootEvent) { root = { type: 'E', id: rootEvent.id, pubkey: rootEvent.pubkey } } } } } else if (event.kind === ExtendedKind.COMMENT) { const rootATag = getRootAddressableEventTag(event) if (rootATag) { const [, coordinate, relay] = rootATag const [, pubkey] = coordinate.split(':') root = { type: 'A', id: coordinate, eventId: event.id, pubkey, relay } } const rootITag = event.tags.find(tagNameEquals('I')) if (rootITag) { root = { type: 'I', id: rootITag[1] } } } setRootInfo(root) } fetchRootEvent() }, [event]) const onNewReply = useCallback((evt: NEvent) => { addReplies([evt]) }, []) 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, onNewReply]) useEffect(() => { if (loading || !rootInfo || currentIndex !== index) return const init = async () => { setLoading(true) try { const relayList = await client.fetchRelayList( (rootInfo as { pubkey?: string }).pubkey ?? event.pubkey ) const relayUrls = relayList.read.concat(BIG_RELAY_URLS) const seenOn = rootInfo.type === 'E' ? client.getSeenEventRelayUrls(rootInfo.id) : rootInfo.type === 'A' ? client.getCurrentRelayUrls() : [] relayUrls.unshift(...seenOn) const filters: (Omit & { limit: number })[] = [] if (rootInfo.type === 'E') { filters.push({ '#e': [rootInfo.id], kinds: [kinds.ShortTextNote], limit: LIMIT }) if (event.kind !== kinds.ShortTextNote) { filters.push({ '#E': [rootInfo.id], kinds: [ExtendedKind.COMMENT], limit: LIMIT }) } } else if (rootInfo.type === 'A') { filters.push({ '#A': [rootInfo.id], kinds: [ExtendedKind.COMMENT], limit: LIMIT }) if (rootInfo.relay) { relayUrls.push(rootInfo.relay) } } else { filters.push({ '#I': [rootInfo.id], kinds: [ExtendedKind.COMMENT], limit: LIMIT }) } const { closer, timelineKey } = await client.subscribeTimeline( filters.map((filter) => ({ urls: relayUrls.slice(0, 5), filter })), { onEvents: (evts, eosed) => { if (evts.length > 0) { addReplies(evts.filter((evt) => isReplyNoteEvent(evt))) } if (eosed) { setUntil(evts.length >= LIMIT ? evts[evts.length - 1].created_at - 1 : undefined) setLoading(false) } }, onNew: (evt) => { if (!isReplyNoteEvent(evt)) return addReplies([evt]) } } ) setTimelineKey(timelineKey) return closer } catch { setLoading(false) } return } const promise = init() return () => { promise.then((closer) => closer?.()) } }, [rootInfo, currentIndex, index, onNewReply]) useEffect(() => { if (replies.length === 0) { loadMore() } }, [replies]) useEffect(() => { const options = { root: null, rootMargin: '10px', threshold: 0.1 } const observerInstance = new IntersectionObserver((entries) => { if (entries[0].isIntersecting && showCount < replies.length) { setShowCount((prev) => prev + SHOW_COUNT) } }, options) const currentBottomRef = bottomRef.current if (currentBottomRef) { observerInstance.observe(currentBottomRef) } return () => { if (observerInstance && currentBottomRef) { observerInstance.unobserve(currentBottomRef) } } }, [replies, showCount]) 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)) if (olderEvents.length > 0) { addReplies(olderEvents) } setUntil(events.length ? events[events.length - 1].created_at - 1 : undefined) setLoading(false) }, [loading, until, timelineKey]) 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 && (replies.length === 0 ? : )} {!loading && until && (
{t('load more older replies')}
)}
{replies.slice(0, showCount).map((reply) => { if (hideUntrustedInteractions && !isUserTrusted(reply.pubkey)) { const repliesForThisReply = repliesMap.get(reply.id) // If the reply is not trusted and there are no trusted replies for this reply, skip rendering if ( !repliesForThisReply || repliesForThisReply.events.every((evt) => !isUserTrusted(evt.pubkey)) ) { return null } } const parentEventTag = getParentEventTag(reply) const parentEventOriginalId = parentEventTag?.[1] const parentEventId = parentEventTag ? generateEventIdFromETag(parentEventTag) : undefined return (
(replyRefs.current[reply.id] = el)} key={reply.id} className="scroll-mt-12" > parentEventOriginalId && highlightReply(parentEventOriginalId)} highlight={highlightReplyId === reply.id} />
) })}
{!loading && (
{replies.length > 0 ? t('no more replies') : t('no replies')}
)}
) }