import { ExtendedKind } from '@/constants' import { getParentETag, getReplaceableCoordinateFromEvent, getRootATag, getRootETag, getRootEventHexId, isMentioningMutedUsers, isReplaceableEvent, isReplyNoteEvent } from '@/lib/event' import { shouldHideInteractions } from '@/lib/event-filtering' import logger from '@/lib/logger' import { normalizeUrl } from '@/lib/url' import { toNote } from '@/lib/link' import { generateBech32IdFromETag, tagNameEquals } from '@/lib/tag' import { useSmartNoteNavigation, useSecondaryPage } from '@/PageManager' import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useMuteList } from '@/providers/MuteListProvider' import { useNostr } from '@/providers/NostrProvider' import { useReply } from '@/providers/ReplyProvider' import { useUserTrust } from '@/providers/UserTrustProvider' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import client from '@/services/client.service' import { eventService, queryService } from '@/services/client.service' import noteStatsService from '@/services/note-stats.service' import discussionFeedCache from '@/services/discussion-feed-cache.service' import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder' import { Filter, Event as NEvent, kinds } from 'nostr-tools' import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { LoadingBar } from '../LoadingBar' import QuoteList from '../QuoteList' import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote' import ZapReplyFeedRow from './ZapReplyFeedRow' 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 function ReplyNoteList({ index, event, sort = 'oldest', showQuotes = true }: { index?: number event: NEvent sort?: 'newest' | 'oldest' | 'top' | 'controversial' | 'most-zapped' /** When false, omit the quotes section (e.g. discussion threads). */ showQuotes?: boolean }) { const { t } = useTranslation() const { navigateToNote } = useSmartNoteNavigation() const { currentIndex } = useSecondaryPage() const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() const noteStats = useNoteStatsById(event.id) const { mutePubkeySet } = useMuteList() const { hideContentMentioningMutedUsers } = useContentPolicy() const { relayList: userRelayList, pubkey: userPubkey } = useNostr() const { blockedRelays } = useFavoriteRelays() const { relayUrls: browsingRelayUrls } = useCurrentRelays() const [rootInfo, setRootInfo] = useState(undefined) const { repliesMap, addReplies } = useReply() // Helper function to get vote score for a reply const getReplyVoteScore = (reply: NEvent) => { const stats = noteStatsService.getNoteStats(reply.id) if (!stats?.likes) { return 0 } const upvoteReactions = stats.likes.filter(r => r.emoji === '⬆️') const downvoteReactions = stats.likes.filter(r => r.emoji === '⬇️') const score = upvoteReactions.length - downvoteReactions.length return score } // Helper function to get controversy score for a reply const getReplyControversyScore = (reply: NEvent) => { const stats = noteStatsService.getNoteStats(reply.id) if (!stats?.likes) { return 0 } const upvoteReactions = stats.likes.filter(r => r.emoji === '⬆️') const downvoteReactions = stats.likes.filter(r => r.emoji === '⬇️') // Controversy = minimum of upvotes and downvotes (both need to be high) const controversy = Math.min(upvoteReactions.length, downvoteReactions.length) return controversy } // Helper function to get total zap amount for a reply const getReplyZapAmount = (reply: NEvent) => { const stats = noteStatsService.getNoteStats(reply.id) if (!stats?.zaps) { return 0 } const totalAmount = stats.zaps.reduce((sum, zap) => sum + zap.amount, 0) return totalAmount } const replies = useMemo(() => { const replyIdSet = new Set() const replyEvents: NEvent[] = [] const currentEventKey = isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) : event.id // For replaceable events, also check the event ID in case replies are stored there const eventIdKey = event.id let parentEventKeys = [currentEventKey] if (isReplaceableEvent(event.kind) && currentEventKey !== eventIdKey) { parentEventKeys.push(eventIdKey) } const processedEventIds = new Set() // Prevent infinite loops let iterationCount = 0 const MAX_ITERATIONS = 10 // Prevent infinite loops while (parentEventKeys.length > 0 && iterationCount < MAX_ITERATIONS) { iterationCount++ const events = parentEventKeys.flatMap((id) => repliesMap.get(id)?.events || []) events.forEach((evt) => { if (replyIdSet.has(evt.id)) return if (mutePubkeySet.has(evt.pubkey)) { return } if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) { return } replyIdSet.add(evt.id) replyEvents.push(evt) }) // Prevent infinite loops by tracking processed event IDs const newParentEventKeys = events .map((evt) => evt.id) .filter((id) => !processedEventIds.has(id)) newParentEventKeys.forEach((id) => processedEventIds.add(id)) parentEventKeys = newParentEventKeys } if (iterationCount >= MAX_ITERATIONS) { logger.warn('ReplyNoteList: Maximum iterations reached, possible circular reference in replies') } // Apply sorting based on the sort parameter switch (sort) { case 'oldest': return replyEvents.sort((a, b) => a.created_at - b.created_at) case 'newest': return replyEvents.sort((a, b) => b.created_at - a.created_at) case 'top': // Sort by vote score (upvotes - downvotes), then by newest if tied return replyEvents.sort((a, b) => { const scoreA = getReplyVoteScore(a) const scoreB = getReplyVoteScore(b) if (scoreA !== scoreB) { return scoreB - scoreA // Higher scores first } return b.created_at - a.created_at // Newest first if tied }) case 'controversial': // Sort by controversy score (min of upvotes and downvotes), then by newest if tied return replyEvents.sort((a, b) => { const controversyA = getReplyControversyScore(a) const controversyB = getReplyControversyScore(b) if (controversyA !== controversyB) { return controversyB - controversyA // Higher controversy first } return b.created_at - a.created_at // Newest first if tied }) case 'most-zapped': // Sort by total zap amount, then by newest if tied return replyEvents.sort((a, b) => { const zapAmountA = getReplyZapAmount(a) const zapAmountB = getReplyZapAmount(b) if (zapAmountA !== zapAmountB) { return zapAmountB - zapAmountA // Higher zap amounts first } return b.created_at - a.created_at // Newest first if tied }) default: return replyEvents.sort((a, b) => b.created_at - a.created_at) } }, [event.id, repliesMap, mutePubkeySet, hideContentMentioningMutedUsers, sort]) const zapsForFeed = useMemo(() => { if (shouldHideInteractions(event)) return [] const raw = noteStats?.zaps ?? [] const filtered = hideUntrustedInteractions ? raw.filter((z) => isUserTrusted(z.pubkey)) : raw return [...filtered].sort((a, b) => b.amount - a.amount) }, [event, noteStats, hideUntrustedInteractions, isUserTrusted]) const [timelineKey] = 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 if (isReplaceableEvent(event.kind)) { root = { type: 'A', id: getReplaceableCoordinateFromEvent(event), eventId: event.id, pubkey: event.pubkey, relay: client.getEventHint(event.id) } } else { root = { type: 'E', id: event.id, pubkey: event.pubkey } } const rootETag = getRootETag(event) if (rootETag) { const [, rootEventHexId, , , rootEventPubkey] = rootETag if (rootEventHexId && rootEventPubkey) { root = { type: 'E', id: rootEventHexId, pubkey: rootEventPubkey } } else { const rootEventId = generateBech32IdFromETag(rootETag) if (rootEventId) { const rootEvent = await eventService.fetchEvent(rootEventId) if (rootEvent) { root = { type: 'E', id: rootEvent.id, pubkey: rootEvent.pubkey } } } } } else if (event.kind === ExtendedKind.COMMENT) { const rootATag = getRootATag(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]) // Also update the discussion cache so the reply persists if (rootInfo) { const cachedReplies = discussionFeedCache.getCachedReplies(rootInfo) || [] const existingReplyIds = new Set(cachedReplies.map(r => r.id)) if (!existingReplyIds.has(evt.id)) { discussionFeedCache.setCachedReplies(rootInfo, [...cachedReplies, evt]) } } }, [addReplies, rootInfo]) 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('newEvent', handleEventPublished) return () => { client.removeEventListener('newEvent', handleEventPublished) } }, [rootInfo, onNewReply]) useEffect(() => { if (!rootInfo || currentIndex !== index) return const init = async () => { // Check cache first - get cached data even if stale (for instant display) const cachedData = discussionFeedCache.getCachedReplies(rootInfo) const hasFreshCache = discussionFeedCache.hasFreshCache(rootInfo) const hasCache = cachedData !== null if (hasCache) { // Display cached data immediately (even if stale) for instant switching addReplies(cachedData) setLoading(false) } else { // No cache at all, show loading while fetching setLoading(true) } // Always fetch fresh data from relays to update cache // If we have fresh cache, we can skip fetching (but still do it in background after a delay) // If we have stale cache or no cache, fetch immediately if (hasFreshCache) { // Fresh cache: fetch in background after a short delay to avoid unnecessary requests setTimeout(() => { fetchFromRelays() }, 2000) // Wait 2 seconds before background refresh } else { // Stale or no cache: fetch immediately fetchFromRelays() } async function fetchFromRelays() { if (!rootInfo) return // Type guard try { // READ from: FAST_READ_RELAY_URLS + user's inboxes + local relays + OP author's outboxes const opAuthorPubkey = rootInfo.type === 'E' || rootInfo.type === 'A' ? rootInfo.pubkey : undefined const seenOn = client.getSeenEventRelayUrls(event.id).map((u) => normalizeUrl(u) || u).filter(Boolean) const fromBrowsingFeed = browsingRelayUrls.map((u) => normalizeUrl(u) || u).filter(Boolean) const threadRelayHints = [ ...new Set([...relayHintsFromEventTags(event), ...seenOn, ...fromBrowsingFeed]) ] const finalRelayUrls = await buildReplyReadRelayList( opAuthorPubkey, userPubkey || undefined, blockedRelays || [], threadRelayHints ) const filters: Filter[] = [] if (rootInfo.type === 'E') { // Fetch all reply types for event-based replies filters.push({ '#e': [rootInfo.id], kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], limit: LIMIT }) // Also fetch with uppercase E tag for replaceable events filters.push({ '#E': [rootInfo.id], kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], limit: LIMIT }) // For public messages (kind 24), also look for replies using 'q' tags if (event.kind === ExtendedKind.PUBLIC_MESSAGE) { filters.push({ '#q': [rootInfo.id], kinds: [ExtendedKind.PUBLIC_MESSAGE], limit: LIMIT }) } } else if (rootInfo.type === 'A') { // Fetch all reply types for replaceable event-based replies filters.push( { '#a': [rootInfo.id], kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], limit: LIMIT }, { '#A': [rootInfo.id], kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], limit: LIMIT } ) if (rootInfo.relay) { finalRelayUrls.push(rootInfo.relay) } } // Use fetchEvents instead of subscribeTimeline for one-time fetching const allReplies = await queryService.fetchEvents(finalRelayUrls, filters) // Filter and add replies const regularReplies = allReplies.filter((evt) => isReplyNoteEvent(evt)) // Store in cache (this merges with existing cached replies) // After this call, the cache contains ALL replies we've ever seen for this thread discussionFeedCache.setCachedReplies(rootInfo, regularReplies) // Get the merged cache (which includes all replies we've ever seen, including new ones) const mergedCachedReplies = discussionFeedCache.getCachedReplies(rootInfo) // Always add all merged cached replies to UI // This ensures we keep all previously seen replies and add any new ones // addReplies will deduplicate, so it's safe to call even if some replies are already displayed if (mergedCachedReplies) { addReplies(mergedCachedReplies) } else { // Fallback: if cache somehow failed, at least add the fetched replies logger.warn('[ReplyNoteList] Cache returned null after store, using fetched replies only') addReplies(regularReplies) } if (!hasCache) { // No cache: stop loading after adding replies setLoading(false) } } catch (error) { logger.error('[ReplyNoteList] Error fetching replies:', error) if (!hasCache) { // Only set loading to false if we don't have cache to fall back on setLoading(false) } } } } init() }, [rootInfo, currentIndex, index, userRelayList, event, blockedRelays, browsingRelayUrls, addReplies]) useEffect(() => { if (replies.length === 0 && !loading && timelineKey) { loadMore() } }, [replies.length, loading, timelineKey]) // More specific dependencies to prevent infinite loops 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) { // Use setTimeout to ensure DOM is updated before scrolling setTimeout(() => { ref.scrollIntoView({ behavior: 'smooth', block: 'center' }) }, 0) } } setHighlightReplyId(eventId) setTimeout(() => { setHighlightReplyId((pre) => (pre === eventId ? undefined : pre)) }, 1500) }, []) return (
{loading && } {zapsForFeed.map((zap) => ( ))} {!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 parentETag = getParentETag(reply) const parentEventHexId = parentETag?.[1] const parentEventId = parentETag ? generateBech32IdFromETag(parentETag) : undefined // Check if this reply belongs to the same thread as the root event const replyRootId = getRootEventHexId(reply) const belongsToSameThread = rootInfo && ( (rootInfo.type === 'E' && replyRootId === rootInfo.id) || (rootInfo.type === 'A' && getRootATag(reply)?.[1] === rootInfo.id) || (rootInfo.type === 'I' && reply.tags.find(tagNameEquals('I'))?.[1] === rootInfo.id) ) return (
(replyRefs.current[reply.id] = el)} key={reply.id} className="scroll-mt-12" > { if (!parentEventHexId) return if (replies.every((r) => r.id !== parentEventHexId)) { navigateToNote(toNote(parentEventId ?? parentEventHexId)) return } highlightReply(parentEventHexId) }} onClickReply={belongsToSameThread ? (replyEvent) => { // Update URL without full navigation const replyNoteUrl = toNote(replyEvent.id) window.history.pushState(null, '', replyNoteUrl) // Ensure the reply is visible by expanding the list if needed const replyIndex = replies.findIndex(r => r.id === replyEvent.id) if (replyIndex >= 0 && replyIndex >= showCount) { setShowCount(replyIndex + 1) } // Highlight and scroll to the reply (use setTimeout to ensure DOM is updated) setTimeout(() => { highlightReply(replyEvent.id, true) }, 50) } : undefined} highlight={highlightReplyId === reply.id} />
) })}
{!loading && (
{replies.length > 0 ? t('no more replies') : t('no replies')}
)}
{loading && } {showQuotes && }
) } export default ReplyNoteList