|
|
import { ExtendedKind } from '@/constants' |
|
|
import { isDiscussionDownvoteEmoji, isDiscussionUpvoteEmoji } from '@/lib/discussion-votes' |
|
|
import { |
|
|
getParentETag, |
|
|
isMentioningMutedUsers, |
|
|
isNip18RepostKind |
|
|
} from '@/lib/event' |
|
|
import logger from '@/lib/logger' |
|
|
import { |
|
|
collectAttestedSuperchatsFromRepliesMap, |
|
|
isNestedThreadReplyParentKind, |
|
|
isSuperchatKind, |
|
|
partitionAttestedSuperchats, |
|
|
replyFeedSuperchatsFirst |
|
|
} from '@/lib/superchat' |
|
|
import { muteSetHas } from '@/lib/mute-set' |
|
|
import { normalizeAnyRelayUrl } from '@/lib/url' |
|
|
import { shouldHideThreadResponseEvent } from '@/lib/thread-response-filter' |
|
|
import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' |
|
|
import { toNote } from '@/lib/link' |
|
|
import { generateBech32IdFromETag } from '@/lib/tag' |
|
|
import { useSmartNoteNavigation } from '@/PageManager' |
|
|
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' |
|
|
import storage from '@/services/local-storage.service' |
|
|
import { useMuteList } from '@/contexts/mute-list-context' |
|
|
import { useNostr } from '@/providers/NostrProvider' |
|
|
import { useReplyIngress } from '@/hooks/useReplyIngress' |
|
|
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' |
|
|
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
|
|
import { |
|
|
NoteFeedProfileContext, |
|
|
type NoteFeedProfileContextValue, |
|
|
useNoteFeedProfileContext |
|
|
} from '@/providers/NoteFeedProfileContext' |
|
|
import client, { eventService, queryService } from '@/services/client.service' |
|
|
import noteStatsService from '@/services/note-stats.service' |
|
|
import discussionFeedCache from '@/services/discussion-feed-cache.service' |
|
|
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' |
|
|
import { collectProfilePubkeysFromEvents } from '@/lib/profile-batch-coordinator' |
|
|
import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder' |
|
|
import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' |
|
|
import { appendMoneroNostrRelays } from '@/lib/monero-nostr-relays' |
|
|
import { buildThreadInteractionFilters, buildThreadSuperchatPriorityFilters } from '@/lib/thread-interaction-req' |
|
|
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' |
|
|
import { buildRssWebNostrQueryRelayUrls, isRssArticleUrlThreadInteraction } from '@/lib/rss-web-feed' |
|
|
import type { TProfile } from '@/types' |
|
|
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 ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote' |
|
|
import ThreadQuoteBacklink, { BacklinkAvatarStrip } from './ThreadQuoteBacklink' |
|
|
import { |
|
|
MAX_PARENT_IDS_PER_NESTED_REQ, |
|
|
THREAD_PROFILE_BATCH_DEBOUNCE_MS, |
|
|
THREAD_PROFILE_CHUNK, |
|
|
THREAD_REPLY_LIMIT, |
|
|
THREAD_REPLY_SHOW_COUNT |
|
|
} from './types' |
|
|
import { |
|
|
backlinkRunSectionClass, |
|
|
buildVisibleBacklinkRows, |
|
|
EA_THREAD_TAIL_REFERENCE_KINDS, |
|
|
collectDisplayedThreadReplies, |
|
|
fetchPaymentAttestationsForRecipient, |
|
|
hydrateThreadRepliesFromStats, |
|
|
isEaThreadTailBacklinkCandidate, |
|
|
isPollVoteKind, |
|
|
isWebThreadTailKind, |
|
|
loadThreadRepliesFromLocalStores, |
|
|
mergeFetchedKind7ReactionsIntoRootNoteStats, |
|
|
moveReportsToEndPreserveOrder, |
|
|
openNoteHexId, |
|
|
partitionAndSortBacklinkTail, |
|
|
replyIsInSubtreeBelowOpenNote, |
|
|
replyFeedZapsFirst, |
|
|
replyIdPresentInRepliesMap, |
|
|
replyMatchesThreadForList, |
|
|
threadBacklinkRelationLabel |
|
|
} from './reply-list-utils' |
|
|
import { useThreadRootInfo } from './useThreadRootInfo' |
|
|
import { useThreadAttestedPayments } from './useThreadAttestedPayments' |
|
|
|
|
|
function ReplyNoteList({ |
|
|
index: _pageIndex, |
|
|
event, |
|
|
sort = 'oldest', |
|
|
showQuotes = true, |
|
|
duplicateWebPreviewCleanedUrlHints, |
|
|
statsForeground = false, |
|
|
refreshToken = 0, |
|
|
singleRelayAuthoritativeRead |
|
|
}: { |
|
|
index?: number |
|
|
event: NEvent |
|
|
sort?: 'newest' | 'oldest' | 'top' | 'controversial' | 'most-zapped' |
|
|
/** When false, omit the quotes section (e.g. discussion threads). */ |
|
|
showQuotes?: boolean |
|
|
/** Suppress WebPreview for these URLs in replies (e.g. article URL already shown as OP). */ |
|
|
duplicateWebPreviewCleanedUrlHints?: string[] |
|
|
/** Passed through to reply row `NoteStats` on note & article pages. */ |
|
|
statsForeground?: boolean |
|
|
/** Bump to force the relay reply scan to run again. */ |
|
|
refreshToken?: number |
|
|
/** Explore single-relay: only query the active browsing relay (see `useCurrentRelays`). */ |
|
|
singleRelayAuthoritativeRead?: boolean |
|
|
}) { |
|
|
const { t } = useTranslation() |
|
|
const { navigateToNote } = useSmartNoteNavigation() |
|
|
const noteStats = useNoteStatsById(event.id) |
|
|
const { mutePubkeySet } = useMuteList() |
|
|
const hideContentMentioningMutedUsers = |
|
|
useContentPolicyOptional()?.hideContentMentioningMutedUsers ?? |
|
|
storage.getHideContentMentioningMutedUsers() |
|
|
const { pubkey: userPubkey } = useNostr() |
|
|
const { blockedRelays, favoriteRelays } = useFavoriteRelays() |
|
|
const { relayUrls: browsingRelayUrls } = useCurrentRelays() |
|
|
const relayAuthoritativeRead = |
|
|
singleRelayAuthoritativeRead ?? browsingRelayUrls.length === 1 |
|
|
const rootInfo = useThreadRootInfo(event) |
|
|
const { repliesMap, addReplies } = useReplyIngress() |
|
|
const isDiscussionRoot = event.kind === ExtendedKind.DISCUSSION |
|
|
const threadRelayUrlsRef = useRef<string[]>([]) |
|
|
const replyFetchGenRef = useRef(0) |
|
|
const { attestedPaymentIds, applyAttestedSuperchatWave } = useThreadAttestedPayments( |
|
|
event.pubkey, |
|
|
addReplies, |
|
|
threadRelayUrlsRef, |
|
|
browsingRelayUrls, |
|
|
replyFetchGenRef |
|
|
) |
|
|
|
|
|
const replyDuplicateWebPreviewHints = useMemo(() => { |
|
|
const out: string[] = [...(duplicateWebPreviewCleanedUrlHints ?? [])] |
|
|
if (rootInfo?.type === 'I') out.push(rootInfo.id) |
|
|
return out.length ? out : undefined |
|
|
}, [duplicateWebPreviewCleanedUrlHints, rootInfo]) |
|
|
|
|
|
const replies: NEvent[] = useMemo(() => { |
|
|
const replyEvents = collectDisplayedThreadReplies( |
|
|
event, |
|
|
rootInfo, |
|
|
repliesMap, |
|
|
isDiscussionRoot, |
|
|
mutePubkeySet, |
|
|
hideContentMentioningMutedUsers |
|
|
) |
|
|
const replyIdSet = new Set(replyEvents.map((r) => r.id)) |
|
|
|
|
|
const threadWalkFromRepliesMap = new Map<string, NEvent>() |
|
|
for (const { events: bucket } of repliesMap.values()) { |
|
|
for (const e of bucket) { |
|
|
threadWalkFromRepliesMap.set(e.id.toLowerCase(), e) |
|
|
} |
|
|
} |
|
|
|
|
|
const includeThreadReply = (evt: NEvent) => { |
|
|
if (isPollVoteKind(evt)) return false |
|
|
if ( |
|
|
shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers) |
|
|
) { |
|
|
return false |
|
|
} |
|
|
if ( |
|
|
rootInfo && |
|
|
!replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot, threadWalkFromRepliesMap) |
|
|
) { |
|
|
return false |
|
|
} |
|
|
const opHex = openNoteHexId(event) |
|
|
if ( |
|
|
opHex && |
|
|
rootInfo?.type === 'E' && |
|
|
rootInfo.id.trim().toLowerCase() !== opHex && |
|
|
!replyIsInSubtreeBelowOpenNote(evt, opHex, threadWalkFromRepliesMap) |
|
|
) { |
|
|
return false |
|
|
} |
|
|
return true |
|
|
} |
|
|
|
|
|
for (const evt of collectAttestedSuperchatsFromRepliesMap( |
|
|
repliesMap, |
|
|
attestedPaymentIds, |
|
|
replyIdSet, |
|
|
includeThreadReply |
|
|
)) { |
|
|
replyIdSet.add(evt.id) |
|
|
replyEvents.push(evt) |
|
|
} |
|
|
|
|
|
const { superchats, rest: nonZaps } = partitionAttestedSuperchats(replyEvents, attestedPaymentIds) |
|
|
const zaps = superchats |
|
|
const replyScoreById = |
|
|
sort === 'top' || sort === 'controversial' || sort === 'most-zapped' |
|
|
? new Map( |
|
|
nonZaps.map((reply) => { |
|
|
const stats = noteStatsService.getNoteStats(reply.id) |
|
|
let upvotes = 0 |
|
|
let downvotes = 0 |
|
|
for (const reaction of stats?.likes ?? []) { |
|
|
if (isDiscussionRoot ? isDiscussionUpvoteEmoji(reaction.emoji) : reaction.emoji === '⬆️') { |
|
|
upvotes++ |
|
|
} else if ( |
|
|
isDiscussionRoot ? isDiscussionDownvoteEmoji(reaction.emoji) : reaction.emoji === '⬇️' |
|
|
) { |
|
|
downvotes++ |
|
|
} |
|
|
} |
|
|
return [ |
|
|
reply.id, |
|
|
{ |
|
|
vote: upvotes - downvotes, |
|
|
controversy: Math.min(upvotes, downvotes), |
|
|
zapAmount: (stats?.zaps ?? []).reduce((sum, zap) => sum + zap.amount, 0) |
|
|
} |
|
|
] as const |
|
|
}) |
|
|
) |
|
|
: new Map<string, { vote: number; controversy: number; zapAmount: number }>() |
|
|
|
|
|
// Sort notes/comments; zap receipts (9735) are always listed first, largest sats → smallest |
|
|
switch (sort) { |
|
|
case 'oldest': |
|
|
return replyFeedZapsFirst( |
|
|
[...nonZaps].sort((a, b) => a.created_at - b.created_at), |
|
|
zaps |
|
|
) |
|
|
case 'newest': |
|
|
return replyFeedZapsFirst( |
|
|
[...nonZaps].sort((a, b) => b.created_at - a.created_at), |
|
|
zaps |
|
|
) |
|
|
case 'top': |
|
|
return replyFeedZapsFirst( |
|
|
[...nonZaps].sort((a, b) => { |
|
|
const scoreA = replyScoreById.get(a.id)?.vote ?? 0 |
|
|
const scoreB = replyScoreById.get(b.id)?.vote ?? 0 |
|
|
if (scoreA !== scoreB) { |
|
|
return scoreB - scoreA |
|
|
} |
|
|
return b.created_at - a.created_at |
|
|
}), |
|
|
zaps |
|
|
) |
|
|
case 'controversial': |
|
|
return replyFeedZapsFirst( |
|
|
[...nonZaps].sort((a, b) => { |
|
|
const controversyA = replyScoreById.get(a.id)?.controversy ?? 0 |
|
|
const controversyB = replyScoreById.get(b.id)?.controversy ?? 0 |
|
|
if (controversyA !== controversyB) { |
|
|
return controversyB - controversyA |
|
|
} |
|
|
return b.created_at - a.created_at |
|
|
}), |
|
|
zaps |
|
|
) |
|
|
case 'most-zapped': |
|
|
return replyFeedZapsFirst( |
|
|
[...nonZaps].sort((a, b) => { |
|
|
const zapAmountA = replyScoreById.get(a.id)?.zapAmount ?? 0 |
|
|
const zapAmountB = replyScoreById.get(b.id)?.zapAmount ?? 0 |
|
|
if (zapAmountA !== zapAmountB) { |
|
|
return zapAmountB - zapAmountA |
|
|
} |
|
|
return b.created_at - a.created_at |
|
|
}), |
|
|
zaps |
|
|
) |
|
|
default: |
|
|
return replyFeedZapsFirst( |
|
|
[...nonZaps].sort((a, b) => b.created_at - a.created_at), |
|
|
zaps |
|
|
) |
|
|
} |
|
|
}, [ |
|
|
event, |
|
|
rootInfo, |
|
|
repliesMap, |
|
|
mutePubkeySet, |
|
|
hideContentMentioningMutedUsers, |
|
|
sort, |
|
|
attestedPaymentIds, |
|
|
isDiscussionRoot, |
|
|
event.kind |
|
|
]) |
|
|
|
|
|
const replyIdSet = useMemo(() => new Set(replies.map((r) => r.id)), [replies]) |
|
|
/** Render with quote card chrome (tail stream + kind 1 #q-only of E/A root). */ |
|
|
const quoteUiIdSet = useMemo(() => { |
|
|
const s = new Set<string>() |
|
|
if (rootInfo?.type === 'E' || rootInfo?.type === 'A') { |
|
|
for (const r of replies) { |
|
|
if (isEaThreadTailBacklinkCandidate(r, rootInfo)) s.add(r.id) |
|
|
} |
|
|
} |
|
|
if (rootInfo?.type === 'I') { |
|
|
for (const r of replies) { |
|
|
if (EA_THREAD_TAIL_REFERENCE_KINDS.has(r.kind)) s.add(r.id) |
|
|
} |
|
|
} |
|
|
return s |
|
|
}, [replies, rootInfo]) |
|
|
const mergedFeed = useMemo(() => { |
|
|
/** Quotes + time-sorted feeds must not interleave zap receipts chronologically */ |
|
|
const zapsThenTimeSorted = (merged: NEvent[], direction: 'asc' | 'desc') => { |
|
|
const { superchats, rest: nonZaps } = partitionAttestedSuperchats(merged, attestedPaymentIds) |
|
|
const sortedNon = [...nonZaps].sort((a, b) => |
|
|
direction === 'asc' ? a.created_at - b.created_at : b.created_at - a.created_at |
|
|
) |
|
|
return moveReportsToEndPreserveOrder(replyFeedSuperchatsFirst(sortedNon, superchats)) |
|
|
} |
|
|
|
|
|
if (!showQuotes) return replies |
|
|
|
|
|
// E/A: zaps (sats desc) → thread replies (1 / 1111 / 1244, excluding #q-only) → tail (quotes, highlights, long-form refs) |
|
|
if (rootInfo?.type === 'E' || rootInfo?.type === 'A') { |
|
|
const { superchats, rest: nonZaps } = partitionAttestedSuperchats(replies, attestedPaymentIds) |
|
|
const middle = nonZaps.filter((e) => !isEaThreadTailBacklinkCandidate(e, rootInfo)) |
|
|
const tailFromReplies = nonZaps.filter((e) => isEaThreadTailBacklinkCandidate(e, rootInfo)) |
|
|
const tailSeen = new Set<string>() |
|
|
const tail: NEvent[] = [] |
|
|
const pushTail = (e: NEvent) => { |
|
|
if (tailSeen.has(e.id)) return |
|
|
tailSeen.add(e.id) |
|
|
tail.push(e) |
|
|
} |
|
|
for (const e of tailFromReplies) pushTail(e) |
|
|
const tailSorted = partitionAndSortBacklinkTail(tail) |
|
|
return [...replyFeedSuperchatsFirst(middle, superchats), ...tailSorted] |
|
|
} |
|
|
|
|
|
// Web article / URL thread (NIP-22): same zaps → middle → tail layout as E/A |
|
|
if (rootInfo?.type === 'I') { |
|
|
const { superchats, rest: nonZaps } = partitionAttestedSuperchats(replies, attestedPaymentIds) |
|
|
const middle = nonZaps.filter((e) => !isWebThreadTailKind(e.kind)) |
|
|
const tailFromReplies = nonZaps.filter((e) => isWebThreadTailKind(e.kind)) |
|
|
const tailSeen = new Set<string>() |
|
|
const tail: NEvent[] = [] |
|
|
const pushTail = (e: NEvent) => { |
|
|
if (tailSeen.has(e.id)) return |
|
|
tailSeen.add(e.id) |
|
|
tail.push(e) |
|
|
} |
|
|
for (const e of tailFromReplies) pushTail(e) |
|
|
const tailSorted = partitionAndSortBacklinkTail(tail) |
|
|
return [...replyFeedSuperchatsFirst(middle, superchats), ...tailSorted] |
|
|
} |
|
|
|
|
|
const merged = [...replies] |
|
|
if (sort === 'oldest') return zapsThenTimeSorted(merged, 'asc') |
|
|
if (sort === 'newest') return zapsThenTimeSorted(merged, 'desc') |
|
|
if (sort === 'top' || sort === 'controversial' || sort === 'most-zapped') { |
|
|
return [...replies] |
|
|
} |
|
|
return zapsThenTimeSorted(merged, 'desc') |
|
|
}, [replies, showQuotes, sort, replyIdSet, rootInfo, event.kind, attestedPaymentIds]) |
|
|
|
|
|
const parentNoteFeed = useNoteFeedProfileContext() |
|
|
const threadProfileLoadedRef = useRef<Set<string>>(new Set()) |
|
|
const threadProfileBatchGenRef = useRef(0) |
|
|
const [threadProfileBatch, setThreadProfileBatch] = useState<{ |
|
|
profiles: Map<string, TProfile> |
|
|
pending: Set<string> |
|
|
version: number |
|
|
}>(() => ({ profiles: new Map(), pending: new Set(), version: 0 })) |
|
|
|
|
|
useEffect(() => { |
|
|
threadProfileLoadedRef.current.clear() |
|
|
threadProfileBatchGenRef.current += 1 |
|
|
setThreadProfileBatch({ profiles: new Map(), pending: new Set(), version: 0 }) |
|
|
}, [event.id]) |
|
|
|
|
|
const threadNoteFeedProfileValue = useMemo<NoteFeedProfileContextValue>(() => { |
|
|
const profiles = new Map<string, TProfile>(parentNoteFeed?.profiles ?? []) |
|
|
for (const [k, v] of threadProfileBatch.profiles) profiles.set(k, v) |
|
|
const pending = new Set<string>(parentNoteFeed?.pendingPubkeys ?? []) |
|
|
threadProfileBatch.pending.forEach((p) => pending.add(p)) |
|
|
return { |
|
|
profiles, |
|
|
pendingPubkeys: pending, |
|
|
version: (parentNoteFeed?.version ?? 0) * 1_000_000 + threadProfileBatch.version |
|
|
} |
|
|
}, [parentNoteFeed, threadProfileBatch]) |
|
|
|
|
|
useEffect(() => { |
|
|
const handle = window.setTimeout(() => { |
|
|
const gen = threadProfileBatchGenRef.current |
|
|
const candidates = new Set(collectProfilePubkeysFromEvents([event, ...mergedFeed])) |
|
|
|
|
|
const parentProfiles = parentNoteFeed?.profiles |
|
|
const parentPending = parentNoteFeed?.pendingPubkeys |
|
|
const need = [...candidates].filter((pk) => { |
|
|
if (parentProfiles?.has(pk)) return false |
|
|
if (parentPending?.has(pk)) return false |
|
|
if (threadProfileLoadedRef.current.has(pk)) return false |
|
|
return true |
|
|
}) |
|
|
if (need.length === 0) return |
|
|
|
|
|
need.forEach((pk) => threadProfileLoadedRef.current.add(pk)) |
|
|
|
|
|
setThreadProfileBatch((prev) => { |
|
|
const pending = new Set(prev.pending) |
|
|
let changed = false |
|
|
for (const pk of need) { |
|
|
if (!pending.has(pk)) { |
|
|
pending.add(pk) |
|
|
changed = true |
|
|
} |
|
|
} |
|
|
if (!changed) return prev |
|
|
return { ...prev, pending } |
|
|
}) |
|
|
|
|
|
void (async () => { |
|
|
const chunks: string[][] = [] |
|
|
for (let i = 0; i < need.length; i += THREAD_PROFILE_CHUNK) { |
|
|
chunks.push(need.slice(i, i + THREAD_PROFILE_CHUNK)) |
|
|
} |
|
|
const settled = await Promise.allSettled( |
|
|
chunks.map((chunk) => client.fetchProfilesForPubkeys(chunk)) |
|
|
) |
|
|
if (gen !== threadProfileBatchGenRef.current) return |
|
|
|
|
|
setThreadProfileBatch((prev) => { |
|
|
const next = new Map(prev.profiles) |
|
|
const pend = new Set(prev.pending) |
|
|
settled.forEach((res, idx) => { |
|
|
const chunk = chunks[idx]! |
|
|
if (res.status === 'rejected') { |
|
|
chunk.forEach((pk) => threadProfileLoadedRef.current.delete(pk)) |
|
|
chunk.forEach((pk) => pend.delete(pk)) |
|
|
return |
|
|
} |
|
|
const profiles = res.value |
|
|
for (const p of profiles) { |
|
|
const pkNorm = p.pubkey.toLowerCase() |
|
|
next.set(pkNorm, { ...p, pubkey: pkNorm }) |
|
|
pend.delete(pkNorm) |
|
|
} |
|
|
for (const pk of chunk) { |
|
|
const pkNorm = pk.toLowerCase() |
|
|
pend.delete(pkNorm) |
|
|
if (!next.has(pkNorm)) { |
|
|
next.set(pkNorm, { |
|
|
pubkey: pkNorm, |
|
|
npub: pubkeyToNpub(pkNorm) ?? '', |
|
|
username: formatPubkey(pkNorm), |
|
|
batchPlaceholder: true |
|
|
}) |
|
|
} |
|
|
} |
|
|
}) |
|
|
return { profiles: next, pending: pend, version: prev.version + 1 } |
|
|
}) |
|
|
})() |
|
|
}, THREAD_PROFILE_BATCH_DEBOUNCE_MS) |
|
|
return () => window.clearTimeout(handle) |
|
|
}, [ |
|
|
event, |
|
|
mergedFeed, |
|
|
parentNoteFeed?.profiles, |
|
|
parentNoteFeed?.pendingPubkeys |
|
|
]) |
|
|
|
|
|
const [loading, setLoading] = useState<boolean>(false) |
|
|
const [showCount, setShowCount] = useState(THREAD_REPLY_SHOW_COUNT) |
|
|
const [highlightReplyId, setHighlightReplyId] = useState<string | undefined>(undefined) |
|
|
const replyRefs = useRef<Record<string, HTMLDivElement | null>>({}) |
|
|
const bottomRef = useRef<HTMLDivElement | null>(null) |
|
|
|
|
|
/** When note-stats counted replies we did not REQ in the thread, fetch by id from archive/session. */ |
|
|
const statsHydratedReplyIdsRef = useRef<Set<string>>(new Set()) |
|
|
|
|
|
useEffect(() => { |
|
|
statsHydratedReplyIdsRef.current.clear() |
|
|
}, [event.id]) |
|
|
|
|
|
useEffect(() => { |
|
|
if (!rootInfo) return |
|
|
const fromStats = noteStats?.replies |
|
|
if (!fromStats?.length) return |
|
|
|
|
|
const candidates = fromStats.filter( |
|
|
(r) => |
|
|
!replyIdPresentInRepliesMap(repliesMap, r.id) && |
|
|
!statsHydratedReplyIdsRef.current.has(r.id) |
|
|
) |
|
|
if (candidates.length === 0) return |
|
|
|
|
|
let cancelled = false |
|
|
;(async () => { |
|
|
for (const { id } of candidates) statsHydratedReplyIdsRef.current.add(id) |
|
|
const batch = await hydrateThreadRepliesFromStats( |
|
|
candidates, |
|
|
rootInfo, |
|
|
event, |
|
|
isDiscussionRoot |
|
|
) |
|
|
if (cancelled) return |
|
|
for (const { id } of candidates) { |
|
|
if (!batch.some((e) => e.id === id)) statsHydratedReplyIdsRef.current.delete(id) |
|
|
} |
|
|
const ok = batch.filter( |
|
|
(e) => |
|
|
!shouldHideThreadResponseEvent( |
|
|
e, |
|
|
mutePubkeySet, |
|
|
hideContentMentioningMutedUsers |
|
|
) |
|
|
) |
|
|
if (ok.length > 0) addReplies(ok) |
|
|
})() |
|
|
|
|
|
return () => { |
|
|
cancelled = true |
|
|
} |
|
|
}, [ |
|
|
event, |
|
|
rootInfo, |
|
|
isDiscussionRoot, |
|
|
noteStats?.replies, |
|
|
noteStats?.updatedAt, |
|
|
repliesMap, |
|
|
addReplies, |
|
|
mutePubkeySet, |
|
|
hideContentMentioningMutedUsers |
|
|
]) |
|
|
|
|
|
const onNewReply = useCallback( |
|
|
(evt: NEvent) => { |
|
|
if (isPollVoteKind(evt)) return |
|
|
if (isNip18RepostKind(evt.kind)) { |
|
|
if ( |
|
|
rootInfo && |
|
|
replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot) && |
|
|
!muteSetHas(mutePubkeySet, evt.pubkey) && |
|
|
!( |
|
|
hideContentMentioningMutedUsers === true && |
|
|
isMentioningMutedUsers(evt, mutePubkeySet) |
|
|
) |
|
|
) { |
|
|
noteStatsService.updateNoteStatsByEvents([evt], event.pubkey, { |
|
|
statsRootEvent: event |
|
|
}) |
|
|
} |
|
|
return |
|
|
} |
|
|
if ( |
|
|
shouldHideThreadResponseEvent( |
|
|
evt, |
|
|
mutePubkeySet, |
|
|
hideContentMentioningMutedUsers |
|
|
) |
|
|
) { |
|
|
return |
|
|
} |
|
|
addReplies([evt]) |
|
|
if (rootInfo) { |
|
|
const cachedReplies = discussionFeedCache.getCachedReplies(rootInfo) || [] |
|
|
const without = cachedReplies.filter((r) => r.id !== evt.id) |
|
|
discussionFeedCache.setCachedReplies(rootInfo, [...without, evt]) |
|
|
} |
|
|
}, |
|
|
[addReplies, rootInfo, mutePubkeySet, hideContentMentioningMutedUsers, event] |
|
|
) |
|
|
|
|
|
useEffect(() => { |
|
|
if (!rootInfo) return |
|
|
const handleEventPublished = (data: Event) => { |
|
|
const ce = data as CustomEvent<NEvent> |
|
|
const evt = ce.detail |
|
|
if (!evt || !replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot)) return |
|
|
onNewReply(evt) |
|
|
} |
|
|
|
|
|
client.addEventListener('newEvent', handleEventPublished) |
|
|
return () => { |
|
|
client.removeEventListener('newEvent', handleEventPublished) |
|
|
} |
|
|
}, [rootInfo, event, onNewReply, isDiscussionRoot]) |
|
|
|
|
|
useEffect(() => { |
|
|
if (!rootInfo) return |
|
|
const fetchGeneration = ++replyFetchGenRef.current |
|
|
|
|
|
const init = async () => { |
|
|
// Session LRU (timeline / note-stats / prior panels): thread replies before relay round-trip |
|
|
if (rootInfo.type === 'E' || rootInfo.type === 'A') { |
|
|
const fromSession = eventService.getSessionThreadInteractionEvents( |
|
|
rootInfo, |
|
|
openNoteHexId(event) |
|
|
) |
|
|
if (fromSession.length > 0) { |
|
|
addReplies(fromSession) |
|
|
} |
|
|
} |
|
|
|
|
|
// Check cache next — discussion cache merges with relay results |
|
|
const cachedData = discussionFeedCache.getCachedReplies(rootInfo) |
|
|
const hasCache = cachedData !== null |
|
|
|
|
|
if (hasCache && cachedData) { |
|
|
addReplies(cachedData) |
|
|
setLoading(false) |
|
|
} else { |
|
|
setLoading(true) |
|
|
} |
|
|
|
|
|
try { |
|
|
const localRows = await loadThreadRepliesFromLocalStores( |
|
|
rootInfo, |
|
|
event, |
|
|
isDiscussionRoot, |
|
|
mutePubkeySet, |
|
|
hideContentMentioningMutedUsers |
|
|
) |
|
|
if (fetchGeneration !== replyFetchGenRef.current) return |
|
|
if (localRows.length > 0) { |
|
|
addReplies(localRows) |
|
|
discussionFeedCache.setCachedReplies(rootInfo, localRows) |
|
|
setLoading(false) |
|
|
} |
|
|
} catch (e) { |
|
|
logger.debug('[ReplyNoteList] Local thread load failed', e) |
|
|
} |
|
|
|
|
|
// Always refetch soon so relays fill gaps; no artificial delay (was 2s and caused empty threads) |
|
|
void fetchFromRelays() |
|
|
|
|
|
async function fetchFromRelays() { |
|
|
if (!rootInfo) return // Type guard |
|
|
|
|
|
try { |
|
|
// READ from: thread hints, author/user NIP-65, favorites, cache — then DEFAULT_FAVORITE_RELAYS fallback. |
|
|
const opAuthorPubkey = rootInfo.type === 'E' || rootInfo.type === 'A' ? rootInfo.pubkey : undefined |
|
|
const seenOn = client.getSeenEventRelayUrls(event.id).map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean) |
|
|
const fromBrowsingFeed = browsingRelayUrls.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean) |
|
|
const threadRelayHints = sanitizeRelayUrlsForFetch([ |
|
|
...new Set([...relayHintsFromEventTags(event), ...seenOn, ...fromBrowsingFeed]) |
|
|
]) |
|
|
const replyBlockedRelays = [ |
|
|
...(blockedRelays || []) |
|
|
] |
|
|
const finalRelayUrls = await buildReplyReadRelayList( |
|
|
opAuthorPubkey, |
|
|
userPubkey || undefined, |
|
|
replyBlockedRelays, |
|
|
threadRelayHints, |
|
|
relayAuthoritativeRead ? { relayAuthoritative: true } : undefined |
|
|
) |
|
|
|
|
|
// URL/article threads (NIP-22 `#i`): synthetic root has no e-tags or seen-relay hints — merge the same |
|
|
// relay stack as RSS+Web discovery / {@link RssUrlThreadStatsBar} so replies match feed stats. |
|
|
if (rootInfo.type === 'I') { |
|
|
const rssLayer = await buildRssWebNostrQueryRelayUrls({ |
|
|
accountPubkey: userPubkey ?? null, |
|
|
favoriteRelays: favoriteRelays ?? [], |
|
|
blockedRelays: blockedRelays ?? [] |
|
|
}) |
|
|
const seenNorm = new Set( |
|
|
finalRelayUrls.map((u) => (normalizeAnyRelayUrl(u) || u).toLowerCase()).filter(Boolean) |
|
|
) |
|
|
for (const u of rssLayer) { |
|
|
const n = normalizeAnyRelayUrl(u) || u?.trim() |
|
|
if (!n) continue |
|
|
const k = n.toLowerCase() |
|
|
if (seenNorm.has(k)) continue |
|
|
seenNorm.add(k) |
|
|
finalRelayUrls.push(n) |
|
|
} |
|
|
} |
|
|
|
|
|
if (rootInfo.type === 'A' && rootInfo.relay) { |
|
|
finalRelayUrls.push(rootInfo.relay) |
|
|
} |
|
|
|
|
|
const filters = buildThreadInteractionFilters({ |
|
|
root: rootInfo, |
|
|
opEventKind: event.kind, |
|
|
opEventHexId: openNoteHexId(event), |
|
|
limit: THREAD_REPLY_LIMIT |
|
|
}) |
|
|
|
|
|
const relayUrlsForThreadReq = sanitizeRelayUrlsForFetch( |
|
|
appendMoneroNostrRelays( |
|
|
feedRelayPolicyUrls([{ source: 'fallback', urls: finalRelayUrls }], { |
|
|
operation: 'read', |
|
|
blockedRelays: replyBlockedRelays, |
|
|
applySocialKindBlockedFilter: false, |
|
|
allowThirdPartyLocalRelays: false |
|
|
}) |
|
|
) |
|
|
) |
|
|
threadRelayUrlsRef.current = relayUrlsForThreadReq |
|
|
const recipientPubkey = event.pubkey |
|
|
|
|
|
// Stream replies as relays return them (aggr is first in the list) instead of waiting for full EOSE. |
|
|
const streamThreadReply = (evt: NEvent) => { |
|
|
if (fetchGeneration !== replyFetchGenRef.current) return |
|
|
if (isPollVoteKind(evt)) return |
|
|
if (rootInfo.type === 'I') { |
|
|
if (!isRssArticleUrlThreadInteraction(evt, rootInfo.id)) return |
|
|
} |
|
|
if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) return |
|
|
addReplies([evt]) |
|
|
if (!hasCache) setLoading(false) |
|
|
} |
|
|
|
|
|
const superchatFilters = buildThreadSuperchatPriorityFilters({ |
|
|
root: rootInfo, |
|
|
opEventKind: event.kind, |
|
|
limit: THREAD_REPLY_LIMIT |
|
|
}) |
|
|
if (superchatFilters.length > 0) { |
|
|
void queryService |
|
|
.fetchEvents(relayUrlsForThreadReq, superchatFilters, { |
|
|
onevent: streamThreadReply, |
|
|
foreground: true, |
|
|
firstRelayResultGraceMs: 400, |
|
|
globalTimeout: 8000, |
|
|
relayOpSource: 'ReplyNoteList.threadSuperchats' |
|
|
}) |
|
|
.catch(() => { |
|
|
/* optional early wave */ |
|
|
}) |
|
|
} |
|
|
|
|
|
const attestationTask = recipientPubkey |
|
|
? fetchPaymentAttestationsForRecipient(recipientPubkey, relayUrlsForThreadReq, { |
|
|
foreground: true |
|
|
}) |
|
|
: Promise.resolve([] as NEvent[]) |
|
|
|
|
|
void attestationTask.then((relayAttestations) => { |
|
|
if (fetchGeneration !== replyFetchGenRef.current) return |
|
|
void applyAttestedSuperchatWave( |
|
|
relayAttestations, |
|
|
relayUrlsForThreadReq, |
|
|
fetchGeneration, |
|
|
true |
|
|
) |
|
|
}) |
|
|
|
|
|
const allReplies = await queryService.fetchEvents(relayUrlsForThreadReq, filters, { |
|
|
onevent: streamThreadReply, |
|
|
foreground: true, |
|
|
firstRelayResultGraceMs: 900, |
|
|
globalTimeout: 12_000, |
|
|
relayOpSource: 'ReplyNoteList.thread' |
|
|
}) |
|
|
|
|
|
if (fetchGeneration !== replyFetchGenRef.current) return |
|
|
|
|
|
mergeFetchedKind7ReactionsIntoRootNoteStats(allReplies, rootInfo) |
|
|
|
|
|
const threadWalkFromBatch = new Map<string, NEvent>( |
|
|
allReplies.map((e) => [e.id.toLowerCase(), e] as const) |
|
|
) |
|
|
|
|
|
// Filter and add replies (URL threads include kind 9802 highlights of this page) |
|
|
const regularReplies = allReplies.filter((evt) => { |
|
|
if (isPollVoteKind(evt)) return false |
|
|
const match = replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot, threadWalkFromBatch) |
|
|
if (!match) return false |
|
|
return !shouldHideThreadResponseEvent( |
|
|
evt, |
|
|
mutePubkeySet, |
|
|
hideContentMentioningMutedUsers |
|
|
) |
|
|
}) |
|
|
|
|
|
// 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) |
|
|
|
|
|
let mergedForUi: NEvent[] |
|
|
if (mergedCachedReplies === null) { |
|
|
logger.warn('[ReplyNoteList] Cache returned null after store, using fetched replies only') |
|
|
mergedForUi = regularReplies |
|
|
} else { |
|
|
mergedForUi = mergedCachedReplies |
|
|
} |
|
|
const repliesForStatsPrime = mergedForUi |
|
|
addReplies(mergedForUi) |
|
|
|
|
|
const statsBatch = mergedCachedReplies !== null && mergedCachedReplies.length > 0 ? mergedCachedReplies : regularReplies |
|
|
if (statsBatch.length > 0) { |
|
|
noteStatsService.updateNoteStatsByEvents(statsBatch, event.pubkey, { |
|
|
statsRootEvent: event |
|
|
}) |
|
|
} |
|
|
|
|
|
if (repliesForStatsPrime.length > 0) { |
|
|
for (const reply of repliesForStatsPrime) { |
|
|
const sessionEdge = eventService.getSessionEventsForNoteStatsTarget(reply) |
|
|
if (sessionEdge.length > 0) { |
|
|
noteStatsService.updateNoteStatsByEvents(sessionEdge, reply.pubkey) |
|
|
} |
|
|
} |
|
|
const threadRootHexId = |
|
|
rootInfo.type === 'E' |
|
|
? rootInfo.id |
|
|
: rootInfo.type === 'A' && /^[0-9a-f]{64}$/i.test(rootInfo.eventId) |
|
|
? rootInfo.eventId.toLowerCase() |
|
|
: undefined |
|
|
window.setTimeout(() => { |
|
|
if (fetchGeneration !== replyFetchGenRef.current) return |
|
|
void noteStatsService.fetchThreadReplyNoteStatsBatch( |
|
|
repliesForStatsPrime, |
|
|
relayUrlsForThreadReq, |
|
|
userPubkey ?? null, |
|
|
{ foreground: statsForeground, threadRootHexId } |
|
|
) |
|
|
}, 0) |
|
|
} |
|
|
|
|
|
if (!hasCache) { |
|
|
// No cache: stop loading after adding replies |
|
|
setLoading(false) |
|
|
} |
|
|
|
|
|
// Second pass for URL threads: fetch replies to individual comments that may omit the |
|
|
// root I tag (non-NIP-22-compliant clients). NoteStats counts them via #e; without this |
|
|
// pass they appear as reply counts only, with no actual content shown. |
|
|
if (rootInfo.type === 'I' && regularReplies.length > 0) { |
|
|
const commentKinds = [ |
|
|
ExtendedKind.COMMENT, |
|
|
ExtendedKind.VOICE_COMMENT, |
|
|
kinds.ShortTextNote |
|
|
] |
|
|
const parentIds = regularReplies |
|
|
.filter((evt) => isNestedThreadReplyParentKind(evt.kind)) |
|
|
.map((evt) => evt.id) |
|
|
if (parentIds.length > 0) { |
|
|
const nestedAccum: NEvent[] = [] |
|
|
for (let off = 0; off < parentIds.length; off += MAX_PARENT_IDS_PER_NESTED_REQ) { |
|
|
const idChunk = parentIds.slice(off, off + MAX_PARENT_IDS_PER_NESTED_REQ) |
|
|
const nestedFilters: Filter[] = [ |
|
|
{ '#e': idChunk, kinds: commentKinds, limit: THREAD_REPLY_LIMIT } |
|
|
] |
|
|
const nestedReplies = await queryService.fetchEvents(relayUrlsForThreadReq, nestedFilters, { |
|
|
onevent: (evt: NEvent) => { |
|
|
if (fetchGeneration !== replyFetchGenRef.current) return |
|
|
if (isPollVoteKind(evt)) return |
|
|
if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) |
|
|
return |
|
|
addReplies([evt]) |
|
|
} |
|
|
}) |
|
|
if (fetchGeneration !== replyFetchGenRef.current) return |
|
|
nestedAccum.push(...nestedReplies) |
|
|
} |
|
|
const validNested = nestedAccum.filter( |
|
|
(evt) => |
|
|
!isPollVoteKind(evt) && |
|
|
!shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers) |
|
|
) |
|
|
if (validNested.length > 0) { |
|
|
discussionFeedCache.setCachedReplies(rootInfo, validNested) |
|
|
const merged = discussionFeedCache.getCachedReplies(rootInfo) |
|
|
addReplies(merged ?? validNested) |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
// Second pass for discussions, plain kind-1 threads, and replaceable (longform/wiki) roots: |
|
|
// nested 1 / 1111 / 1244 often tag only the parent's #e; root-scoped REQ misses them (same |
|
|
// idea as URL-thread #I follow-up above). |
|
|
if ( |
|
|
(rootInfo.type === 'E' && |
|
|
[ |
|
|
ExtendedKind.DISCUSSION, |
|
|
ExtendedKind.COMMENT, |
|
|
ExtendedKind.VOICE_COMMENT, |
|
|
kinds.ShortTextNote |
|
|
].includes(event.kind)) || |
|
|
rootInfo.type === 'A' |
|
|
) { |
|
|
const commentKindsNested = [ |
|
|
ExtendedKind.COMMENT, |
|
|
ExtendedKind.VOICE_COMMENT, |
|
|
kinds.ShortTextNote |
|
|
] |
|
|
const focusedParentId = |
|
|
commentKindsNested.includes(event.kind) && /^[0-9a-f]{64}$/i.test(event.id) |
|
|
? event.id.toLowerCase() |
|
|
: undefined |
|
|
const parentIdsNested = Array.from( |
|
|
new Set( |
|
|
[ |
|
|
focusedParentId, |
|
|
...regularReplies |
|
|
.filter((evt) => isNestedThreadReplyParentKind(evt.kind)) |
|
|
.map((evt) => evt.id) |
|
|
].filter(Boolean) as string[] |
|
|
) |
|
|
) |
|
|
if (parentIdsNested.length > 0) { |
|
|
const nestedAccum: NEvent[] = [] |
|
|
const streamWalkById = new Map<string, NEvent>( |
|
|
regularReplies.map((e) => [e.id.toLowerCase(), e] as const) |
|
|
) |
|
|
for (let off = 0; off < parentIdsNested.length; off += MAX_PARENT_IDS_PER_NESTED_REQ) { |
|
|
const idChunk = parentIdsNested.slice(off, off + MAX_PARENT_IDS_PER_NESTED_REQ) |
|
|
const nestedFilters: Filter[] = [ |
|
|
{ '#e': idChunk, kinds: commentKindsNested, limit: THREAD_REPLY_LIMIT }, |
|
|
{ |
|
|
'#E': idChunk, |
|
|
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], |
|
|
limit: THREAD_REPLY_LIMIT |
|
|
} |
|
|
] |
|
|
const nestedReplies = await queryService.fetchEvents(relayUrlsForThreadReq, nestedFilters, { |
|
|
onevent: (evt: NEvent) => { |
|
|
if (fetchGeneration !== replyFetchGenRef.current) return |
|
|
if (isPollVoteKind(evt)) return |
|
|
if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) |
|
|
return |
|
|
streamWalkById.set(evt.id.toLowerCase(), evt) |
|
|
if (!replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot, streamWalkById)) return |
|
|
addReplies([evt]) |
|
|
} |
|
|
}) |
|
|
if (fetchGeneration !== replyFetchGenRef.current) return |
|
|
nestedAccum.push(...nestedReplies) |
|
|
} |
|
|
const nestedWalkMerged = new Map<string, NEvent>(streamWalkById) |
|
|
for (const e of nestedAccum) nestedWalkMerged.set(e.id.toLowerCase(), e) |
|
|
const validNested = nestedAccum.filter( |
|
|
(evt) => |
|
|
!isPollVoteKind(evt) && |
|
|
!shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers) && |
|
|
replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot, nestedWalkMerged) |
|
|
) |
|
|
if (validNested.length > 0) { |
|
|
discussionFeedCache.setCachedReplies(rootInfo, validNested) |
|
|
const merged = discussionFeedCache.getCachedReplies(rootInfo) |
|
|
addReplies(merged ?? validNested) |
|
|
} |
|
|
} |
|
|
} |
|
|
} catch (error) { |
|
|
logger.error('[ReplyNoteList] Error fetching replies:', error) |
|
|
if (fetchGeneration !== replyFetchGenRef.current) return |
|
|
if (!hasCache) { |
|
|
// Only set loading to false if we don't have cache to fall back on |
|
|
setLoading(false) |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
init() |
|
|
}, [ |
|
|
rootInfo, |
|
|
userPubkey, |
|
|
event.id, |
|
|
event.kind, |
|
|
blockedRelays, |
|
|
favoriteRelays, |
|
|
browsingRelayUrls, |
|
|
refreshToken, |
|
|
addReplies, |
|
|
mutePubkeySet, |
|
|
hideContentMentioningMutedUsers, |
|
|
isDiscussionRoot, |
|
|
statsForeground, |
|
|
applyAttestedSuperchatWave |
|
|
]) |
|
|
|
|
|
useEffect(() => { |
|
|
const options = { |
|
|
root: null, |
|
|
rootMargin: '10px', |
|
|
threshold: 0.1 |
|
|
} |
|
|
|
|
|
const observerInstance = new IntersectionObserver((entries) => { |
|
|
if (entries[0].isIntersecting && showCount < mergedFeed.length) { |
|
|
setShowCount((prev) => prev + THREAD_REPLY_SHOW_COUNT) |
|
|
} |
|
|
}, options) |
|
|
|
|
|
const currentBottomRef = bottomRef.current |
|
|
|
|
|
if (currentBottomRef) { |
|
|
observerInstance.observe(currentBottomRef) |
|
|
} |
|
|
|
|
|
return () => { |
|
|
if (observerInstance && currentBottomRef) { |
|
|
observerInstance.unobserve(currentBottomRef) |
|
|
} |
|
|
} |
|
|
}, [mergedFeed.length, showCount]) |
|
|
|
|
|
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) |
|
|
}, []) |
|
|
|
|
|
/** Paginate replies only; always show the backlinks tail (quotes, highlights, bookmarks, …). */ |
|
|
const visibleFeed = useMemo(() => { |
|
|
const backlinks: NEvent[] = [] |
|
|
const main: NEvent[] = [] |
|
|
for (const item of mergedFeed) { |
|
|
if (quoteUiIdSet.has(item.id)) backlinks.push(item) |
|
|
else main.push(item) |
|
|
} |
|
|
return [...main.slice(0, showCount), ...backlinks] |
|
|
}, [mergedFeed, showCount, quoteUiIdSet]) |
|
|
|
|
|
const shouldShowFeedItem = useCallback( |
|
|
(item: NEvent) => { |
|
|
if (isPollVoteKind(item)) return false |
|
|
if (shouldHideThreadResponseEvent(item, mutePubkeySet, hideContentMentioningMutedUsers)) { |
|
|
return false |
|
|
} |
|
|
const isQuote = quoteUiIdSet.has(item.id) |
|
|
// Attested superchats are public payment records — always show when they passed mute filters. |
|
|
if (isSuperchatKind(item.kind)) return true |
|
|
// Backlink rows (quotes, highlights, …): show even when author is not in the trust list. |
|
|
if (isQuote) return true |
|
|
return true |
|
|
}, |
|
|
[ |
|
|
mutePubkeySet, |
|
|
hideContentMentioningMutedUsers, |
|
|
quoteUiIdSet, |
|
|
rootInfo?.type, |
|
|
repliesMap, |
|
|
event, |
|
|
isDiscussionRoot |
|
|
] |
|
|
) |
|
|
|
|
|
const threadRootHex = |
|
|
rootInfo?.type === 'E' && /^[0-9a-f]{64}$/i.test(rootInfo.id) |
|
|
? rootInfo.id.toLowerCase() |
|
|
: undefined |
|
|
|
|
|
const visibleForRender = useMemo( |
|
|
() => |
|
|
visibleFeed.filter((e) => { |
|
|
if (!shouldShowFeedItem(e)) return false |
|
|
if (e.id === event.id) return false |
|
|
if (threadRootHex && e.id.toLowerCase() === threadRootHex) return false |
|
|
return true |
|
|
}), |
|
|
[visibleFeed, shouldShowFeedItem, event.id, threadRootHex] |
|
|
) |
|
|
|
|
|
const displayRows = useMemo( |
|
|
() => buildVisibleBacklinkRows(visibleForRender, quoteUiIdSet), |
|
|
[visibleForRender, quoteUiIdSet] |
|
|
) |
|
|
|
|
|
return ( |
|
|
<NoteFeedProfileContext.Provider value={threadNoteFeedProfileValue}> |
|
|
<div className="pb-12"> |
|
|
{loading && <LoadingBar />} |
|
|
<div> |
|
|
{displayRows.map((row, ri) => { |
|
|
const prevRow = ri > 0 ? displayRows[ri - 1] : undefined |
|
|
if (row.type === 'reply') { |
|
|
const reply = row.event |
|
|
const parentETag = getParentETag(reply) |
|
|
const parentEventHexId = parentETag?.[1] |
|
|
const parentEventId = parentETag ? generateBech32IdFromETag(parentETag) : undefined |
|
|
|
|
|
const belongsToSameThread = |
|
|
rootInfo && replyMatchesThreadForList(reply, event, rootInfo, isDiscussionRoot) |
|
|
|
|
|
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} |
|
|
duplicateWebPreviewCleanedUrlHints={replyDuplicateWebPreviewHints} |
|
|
foregroundStats={statsForeground} |
|
|
onClickParent={() => { |
|
|
if (!parentEventHexId) return |
|
|
if (replies.every((r) => r.id !== parentEventHexId)) { |
|
|
const pid = parentEventId ?? parentEventHexId |
|
|
const parentEv = |
|
|
event.id.toLowerCase() === parentEventHexId.toLowerCase() |
|
|
? event |
|
|
: client.peekSessionCachedEvent(pid) |
|
|
navigateToNote( |
|
|
toNote(pid), |
|
|
parentEv ?? undefined, |
|
|
parentEv ? getCachedThreadContextEvents(parentEv) : undefined |
|
|
) |
|
|
return |
|
|
} |
|
|
highlightReply(parentEventHexId) |
|
|
}} |
|
|
onClickReply={belongsToSameThread ? (replyEvent) => { |
|
|
// Highlight only — do not push history (null pushState desynced stack vs URL on Back). |
|
|
const replyIndex = mergedFeed.findIndex((r) => r.id === replyEvent.id) |
|
|
if (replyIndex >= 0 && replyIndex >= showCount) { |
|
|
setShowCount(replyIndex + 1) |
|
|
} |
|
|
setTimeout(() => { |
|
|
highlightReply(replyEvent.id, true) |
|
|
}, 50) |
|
|
} : undefined} |
|
|
highlight={highlightReplyId === reply.id} |
|
|
/> |
|
|
</div> |
|
|
) |
|
|
} |
|
|
|
|
|
const { subsection, events: blEvents } = row |
|
|
const wrapClass = backlinkRunSectionClass(subsection, prevRow) |
|
|
|
|
|
if (subsection === 'bookmark') { |
|
|
return ( |
|
|
<div |
|
|
key={`bl-bookmark-${blEvents[0].id}`} |
|
|
className={wrapClass} |
|
|
> |
|
|
<BacklinkAvatarStrip |
|
|
events={blEvents} |
|
|
sectionLabel={t('Thread backlinks bookmarks section')} |
|
|
relationLabelForTitle={t('bookmarked this note')} |
|
|
/> |
|
|
</div> |
|
|
) |
|
|
} |
|
|
|
|
|
if (subsection === 'list') { |
|
|
return ( |
|
|
<div |
|
|
key={`bl-list-${blEvents[0].id}`} |
|
|
className={wrapClass} |
|
|
> |
|
|
<BacklinkAvatarStrip |
|
|
events={blEvents} |
|
|
sectionLabel={t('Thread backlinks lists section')} |
|
|
getTitle={(e) => threadBacklinkRelationLabel(e, t)} |
|
|
/> |
|
|
</div> |
|
|
) |
|
|
} |
|
|
|
|
|
if (subsection === 'report') { |
|
|
return ( |
|
|
<div key={`bl-report-${blEvents[0].id}`} className={wrapClass}> |
|
|
<h2 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-amber-950/90 dark:text-amber-100/90"> |
|
|
{t('Report events heading')} |
|
|
</h2> |
|
|
{blEvents.map((item) => ( |
|
|
<div |
|
|
key={item.id} |
|
|
ref={(el) => (replyRefs.current[item.id] = el)} |
|
|
className="scroll-mt-12 mb-1" |
|
|
> |
|
|
<ThreadQuoteBacklink |
|
|
event={item} |
|
|
quoteKindLabel={threadBacklinkRelationLabel(item, t)} |
|
|
variant="warning" |
|
|
/> |
|
|
</div> |
|
|
))} |
|
|
</div> |
|
|
) |
|
|
} |
|
|
|
|
|
return ( |
|
|
<div key={`bl-primary-${blEvents[0].id}`} className={wrapClass}> |
|
|
<h2 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"> |
|
|
{t('Thread backlinks primary section')} |
|
|
</h2> |
|
|
{blEvents.map((item) => ( |
|
|
<div |
|
|
key={item.id} |
|
|
ref={(el) => (replyRefs.current[item.id] = el)} |
|
|
className="scroll-mt-12 mb-1" |
|
|
> |
|
|
<ThreadQuoteBacklink |
|
|
event={item} |
|
|
quoteKindLabel={threadBacklinkRelationLabel(item, t)} |
|
|
variant="default" |
|
|
/> |
|
|
</div> |
|
|
))} |
|
|
</div> |
|
|
) |
|
|
})} |
|
|
</div> |
|
|
{!loading && ( |
|
|
<div className="text-sm mt-2 mb-3 text-center text-muted-foreground"> |
|
|
{mergedFeed.length > 0 ? t('no more replies') : t('no replies')} |
|
|
</div> |
|
|
)} |
|
|
<div ref={bottomRef} /> |
|
|
{loading && <ReplyNoteSkeleton />} |
|
|
</div> |
|
|
</NoteFeedProfileContext.Provider> |
|
|
) |
|
|
} |
|
|
|
|
|
export default ReplyNoteList |