You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

575 lines
22 KiB

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<TRootInfo | undefined>(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<string>()
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<string>() // 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<string | undefined>(undefined)
const [until, setUntil] = useState<number | undefined>(undefined)
const [loading, setLoading] = useState<boolean>(false)
const [showCount, setShowCount] = useState(SHOW_COUNT)
const [highlightReplyId, setHighlightReplyId] = useState<string | undefined>(undefined)
const replyRefs = useRef<Record<string, HTMLDivElement | null>>({})
const bottomRef = useRef<HTMLDivElement | null>(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<NEvent>
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 (
<div className="min-h-[80vh]">
{loading && <LoadingBar />}
{zapsForFeed.map((zap) => (
<ZapReplyFeedRow key={zap.pr} zap={zap} />
))}
{!loading && until && (
<div
className={`text-sm text-center text-muted-foreground border-b py-2 ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`}
onClick={loadMore}
>
{t('load more older replies')}
</div>
)}
<div>
{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 (
<div
ref={(el) => (replyRefs.current[reply.id] = el)}
key={reply.id}
className="scroll-mt-12"
>
<ReplyNote
event={reply}
parentEventId={event.id !== parentEventHexId ? parentEventId : undefined}
onClickParent={() => {
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}
/>
</div>
)
})}
</div>
{!loading && (
<div className="text-sm mt-2 mb-3 text-center text-muted-foreground">
{replies.length > 0 ? t('no more replies') : t('no replies')}
</div>
)}
<div ref={bottomRef} />
{loading && <ReplyNoteSkeleton />}
{showQuotes && <QuoteList event={event} embedded />}
</div>
)
}
export default ReplyNoteList