From c5ab685f76bcce3510873918bc29a1d0908bf2a3 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 23 May 2026 23:29:36 +0200 Subject: [PATCH] bug-fix superchats in threads --- src/components/ReplyNoteList/index.tsx | 614 +++--------------- .../ReplyNoteList/reply-list-utils.ts | 293 +++++++++ src/components/ReplyNoteList/types.ts | 10 + .../useThreadAttestedPayments.ts | 126 ++++ .../ReplyNoteList/useThreadRootInfo.ts | 101 +++ src/hooks/useFetchEvent.tsx | 4 +- src/hooks/useFetchThreadContextEvent.tsx | 4 +- src/hooks/useProfileWall.tsx | 87 +-- src/hooks/useReplyIngress.ts | 15 + src/lib/op-reference-tags.ts | 6 +- src/lib/payment-attestation-cache.test.ts | 52 ++ src/lib/payment-attestation-cache.ts | 111 ++++ src/lib/reply-index.ts | 113 ++++ src/lib/superchat.test.ts | 35 + src/lib/superchat.ts | 40 +- src/pages/secondary/NotePage/index.tsx | 5 +- src/providers/ReplyProvider.tsx | 118 +--- src/providers/ThreadReplyProvider.tsx | 42 ++ 18 files changed, 1085 insertions(+), 691 deletions(-) create mode 100644 src/components/ReplyNoteList/reply-list-utils.ts create mode 100644 src/components/ReplyNoteList/types.ts create mode 100644 src/components/ReplyNoteList/useThreadAttestedPayments.ts create mode 100644 src/components/ReplyNoteList/useThreadRootInfo.ts create mode 100644 src/hooks/useReplyIngress.ts create mode 100644 src/lib/payment-attestation-cache.test.ts create mode 100644 src/lib/reply-index.ts create mode 100644 src/providers/ThreadReplyProvider.tsx diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 8b3de68c..e97addc3 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -1,4 +1,4 @@ -import { ExtendedKind, NOTE_STATS_OP_REFERENCE_KINDS } from '@/constants' +import { ExtendedKind } from '@/constants' import { isDiscussionDownvoteEmoji, isDiscussionUpvoteEmoji } from '@/lib/discussion-votes' import { canonicalizeRssArticleUrl, @@ -7,19 +7,15 @@ import { import { getParentETag, getReplaceableCoordinateFromEvent, - getRootATag, - getRootETag, - isNip56ReportEvent, isMentioningMutedUsers, isNip18RepostKind, - isReplaceableEvent, - kind1QuotesThreadRoot, - resolveDeclaredThreadRootEventHex + isReplaceableEvent } from '@/lib/event' import logger from '@/lib/logger' import { - getPaymentAttestationTargetId, + collectAttestedSuperchatsFromRepliesMap, isNestedThreadReplyParentKind, + isSuperchatKind, partitionAttestedSuperchats, replyFeedSuperchatsFirst } from '@/lib/superchat' @@ -29,12 +25,12 @@ 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, useSecondaryPage } from '@/PageManager' +import { useSmartNoteNavigation } from '@/PageManager' import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useMuteList } from '@/contexts/mute-list-context' import { useNostr } from '@/providers/NostrProvider' import { useZap } from '@/providers/ZapProvider' -import { useReply } from '@/providers/ReplyProvider' +import { useReplyIngress } from '@/hooks/useReplyIngress' import { useUserTrust } from '@/contexts/user-trust-context' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' @@ -51,351 +47,44 @@ import { collectProfilePubkeysFromEvents } from '@/lib/profile-batch-coordinator import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder' import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' import { buildThreadInteractionFilters, buildThreadSuperchatPriorityFilters } from '@/lib/thread-interaction-req' -import { - resolveAttestedPaymentIdSet -} from '@/lib/payment-attestation-cache' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' -import { eventReferencesThreadTarget } from '@/lib/op-reference-tags' -import { replyBelongsToNoteThread } from '@/lib/thread-reply-root-match' import { buildRssWebNostrQueryRelayUrls, isRssArticleUrlThreadInteraction } from '@/lib/rss-web-feed' -import type { TProfile, TSubRequestFilter } from '@/types' +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 type { TFunction } from 'i18next' import { useTranslation } from 'react-i18next' import { LoadingBar } from '../LoadingBar' import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote' import ThreadQuoteBacklink, { BacklinkAvatarStrip } from './ThreadQuoteBacklink' - -type TRootInfo = - | { type: 'E'; id: string; pubkey: string } - | { type: 'A'; id: string; eventId: string; pubkey: string; relay?: string } - | { type: 'I'; id: string } - -const LIMIT = 200 -const SHOW_COUNT = 10 -/** Some relays cap `#e` array length; chunk parent-id batches for nested-thread REQs. */ -const MAX_PARENT_IDS_PER_NESTED_REQ = 64 -/** Short debounce so thread / detail headers populate avatars quickly after events arrive. */ -const THREAD_PROFILE_BATCH_DEBOUNCE_MS = 120 -const THREAD_PROFILE_CHUNK = 80 - -async function hydrateAttestedSuperchatTargets( - attestedIds: ReadonlySet, - relayUrls: string[] -): Promise { - const ids = [...attestedIds].filter((id) => /^[0-9a-f]{64}$/i.test(id)) - if (ids.length === 0) return [] - - const byId = new Map() - try { - const local = await client.getLocalFeedEvents( - [{ urls: [], filter: { ids, limit: ids.length } }], - { maxMatches: ids.length } - ) - for (const e of local) byId.set(e.id.toLowerCase(), e) - } catch { - /* optional */ - } - - const missing = ids.filter((id) => !byId.has(id.toLowerCase())) - if (missing.length > 0 && relayUrls.length > 0) { - try { - const fetched = await client.fetchEvents( - relayUrls, - { ids: missing, limit: missing.length }, - { cache: true, eoseTimeout: 4500, globalTimeout: 12_000 } - ) - for (const e of fetched) byId.set(e.id.toLowerCase(), e) - } catch { - /* optional */ - } - } - - return [...byId.values()] -} - -async function fetchPaymentAttestationsForRecipient( - recipientPubkey: string, - relayUrls: string[], - options: { foreground?: boolean } = {} -): Promise { - const filter: Filter = { - kinds: [ExtendedKind.PAYMENT_ATTESTATION], - authors: [recipientPubkey], - limit: 500 - } - const byId = new Map() - try { - const local = await client.getLocalFeedEvents( - [{ urls: [], filter: filter as TSubRequestFilter }], - { maxMatches: 500 } - ) - for (const e of local) byId.set(e.id, e) - } catch { - /* optional */ - } - if (relayUrls.length > 0) { - try { - const rows = await client.fetchEvents(relayUrls, filter, { - cache: true, - eoseTimeout: 4500, - globalTimeout: 12_000, - foreground: options.foreground - }) - for (const e of rows) byId.set(e.id, e) - } catch { - /* optional */ - } - } - return [...byId.values()] -} - -function replyFeedZapsFirst(sortedNonZapReplies: NEvent[], superchats: NEvent[]) { - return replyFeedSuperchatsFirst(sortedNonZapReplies, superchats) -} - -type TBacklinkSubsection = 'primary' | 'bookmark' | 'list' | 'report' - -function sortWithinBacklinkGroup(events: NEvent[]): NEvent[] { - return [...events].sort((a, b) => b.created_at - a.created_at) -} - -function backlinkTailSubsection(item: NEvent): TBacklinkSubsection { - if (isNip56ReportEvent(item)) return 'report' - if (item.kind === kinds.BookmarkList) return 'bookmark' - if ( - item.kind === kinds.Pinlist || - item.kind === kinds.Genericlists || - item.kind === kinds.Bookmarksets || - item.kind === kinds.Curationsets - ) { - return 'list' - } - return 'primary' -} - -/** Quotes/highlights/citations → bookmarks → lists → reports; newest first within each group. */ -function partitionAndSortBacklinkTail(tail: NEvent[]): NEvent[] { - const primary: NEvent[] = [] - const bookmarks: NEvent[] = [] - const lists: NEvent[] = [] - const reports: NEvent[] = [] - for (const e of tail) { - const sub = backlinkTailSubsection(e) - if (sub === 'report') reports.push(e) - else if (sub === 'bookmark') bookmarks.push(e) - else if (sub === 'list') lists.push(e) - else primary.push(e) - } - return [ - ...sortWithinBacklinkGroup(primary), - ...sortWithinBacklinkGroup(bookmarks), - ...sortWithinBacklinkGroup(lists), - ...sortWithinBacklinkGroup(reports) - ] -} - -type TBacklinkDisplayRow = - | { type: 'reply'; event: NEvent } - | { type: 'backlink-run'; subsection: TBacklinkSubsection; events: NEvent[] } - -function buildVisibleBacklinkRows( - visibleFeed: NEvent[], - quoteUiIdSet: Set -): TBacklinkDisplayRow[] { - const rows: TBacklinkDisplayRow[] = [] - let i = 0 - while (i < visibleFeed.length) { - const item = visibleFeed[i] - if (!quoteUiIdSet.has(item.id)) { - rows.push({ type: 'reply', event: item }) - i++ - continue - } - const sub = backlinkTailSubsection(item) - const run: NEvent[] = [] - while ( - i < visibleFeed.length && - quoteUiIdSet.has(visibleFeed[i].id) && - backlinkTailSubsection(visibleFeed[i]) === sub - ) { - run.push(visibleFeed[i]) - i++ - } - if (run.length > 0) { - rows.push({ type: 'backlink-run', subsection: sub, events: run }) - } - } - return rows -} - -function backlinkRunSectionClass( - subsection: TBacklinkSubsection, - prev: TBacklinkDisplayRow | undefined -): string { - if (!prev) { - return subsection === 'report' - ? 'mb-3 pt-1' - : 'mb-3 pt-1' - } - if (prev.type === 'reply') { - return subsection === 'report' - ? 'mt-8 mb-3 border-t border-amber-500/40 pt-6 dark:border-amber-400/30' - : 'mt-8 mb-3 border-t border-border/60 pt-6' - } - return subsection === 'report' - ? 'mt-6 mb-3 border-t border-amber-500/40 pt-4 dark:border-amber-400/30' - : 'mt-6 mb-3 border-t border-border/60 pt-4' -} - -/** Preserve order except NIP-56 reports move to the end (after all non-reports). */ -function moveReportsToEndPreserveOrder(events: NEvent[]): NEvent[] { - const non = events.filter((e) => !isNip56ReportEvent(e)) - const rep = events.filter((e) => isNip56ReportEvent(e)) - return [...non, ...rep] -} - -/** Shown after thread replies for E/A roots (quote stream + kind 1 #q-only); matches {@link NOTE_STATS_OP_REFERENCE_KINDS}. */ -const EA_THREAD_TAIL_REFERENCE_KINDS = new Set(NOTE_STATS_OP_REFERENCE_KINDS) - -function isWebThreadTailKind(kind: number): boolean { - return EA_THREAD_TAIL_REFERENCE_KINDS.has(kind) -} - -/** Kind 1111 / 1244 that includes the thread root id on an e/E tag (common on relays; stricter root-tag walks may miss these). */ -function commentReferencesThreadRootEventHex(evt: NEvent, rootHexLower: string): boolean { - if (evt.kind !== ExtendedKind.COMMENT && evt.kind !== ExtendedKind.VOICE_COMMENT) return false - const h = rootHexLower.trim().toLowerCase() - if (!/^[0-9a-f]{64}$/.test(h)) return false - return evt.tags.some( - (t) => (t[0] === 'e' || t[0] === 'E') && typeof t[1] === 'string' && t[1].toLowerCase() === h - ) -} - -function replyIdPresentInRepliesMap( - map: Map }>, - replyId: string -): boolean { - for (const { events } of map.values()) { - if (events.some((e) => e.id === replyId)) return true - } - return false -} - -/** NIP-25 reaction: any `e` / `E` tag value equals this hex id (lowercased). */ -function noteReactionEtagEqualsHex(ev: NEvent, hexLower: string): boolean { - const h = hexLower.trim().toLowerCase() - if (!/^[0-9a-f]{64}$/i.test(h)) return false - for (const t of ev.tags) { - if ((t[0] === 'e' || t[0] === 'E') && typeof t[1] === 'string' && t[1].toLowerCase() === h) return true - } - return false -} - -/** - * Thread REQ may still omit some kind-7 rows; merge reactions that tag the root hex so OP stats stay warm. - * Reactions are not listed under “Antworten”; this merge keeps OP stats warm when the thread REQ omits kind 7. - */ -function mergeFetchedKind7ReactionsIntoRootNoteStats(all: NEvent[], rootInfo: TRootInfo) { - if (rootInfo.type === 'E') { - const rootHex = rootInfo.id.trim().toLowerCase() - const hits = all.filter((ev) => ev.kind === kinds.Reaction && noteReactionEtagEqualsHex(ev, rootHex)) - if (hits.length > 0) { - noteStatsService.updateNoteStatsByEvents(hits, undefined, { interactionTargetNoteId: rootInfo.id }) - } - } else if (rootInfo.type === 'A') { - const idHex = rootInfo.eventId?.trim().toLowerCase() - if (idHex && /^[0-9a-f]{64}$/i.test(idHex)) { - const hits = all.filter((ev) => ev.kind === kinds.Reaction && noteReactionEtagEqualsHex(ev, idHex)) - if (hits.length > 0) { - noteStatsService.updateNoteStatsByEvents(hits, undefined, { interactionTargetNoteId: rootInfo.eventId }) - } - } - } -} - -function replyMatchesThreadForList( - evt: NEvent, - opEvent: NEvent, - rootInfo: TRootInfo, - isDiscussionRoot: boolean, - /** Events from the current relay batch (parent walk may not be in session LRU yet). */ - threadWalkLocal?: ReadonlyMap -): boolean { - if (rootInfo.type === 'I') { - return isRssArticleUrlThreadInteraction(evt, rootInfo.id) - } - if ( - isDiscussionRoot && - rootInfo.type === 'E' && - commentReferencesThreadRootEventHex(evt, rootInfo.id) - ) { - return true - } - if (replyBelongsToNoteThread(evt, opEvent, rootInfo, threadWalkLocal)) return true - if ( - evt.kind === kinds.Zap && - (rootInfo.type === 'E' || rootInfo.type === 'A') && - eventReferencesThreadTarget(evt, rootInfo) - ) { - return true - } - if ( - evt.kind === ExtendedKind.PAYMENT_NOTIFICATION && - (rootInfo.type === 'E' || rootInfo.type === 'A') && - eventReferencesThreadTarget(evt, rootInfo) - ) { - return true - } - if ( - (rootInfo.type === 'E' || rootInfo.type === 'A') && - evt.kind !== kinds.ShortTextNote && - NOTE_STATS_OP_REFERENCE_KINDS.includes(evt.kind) && - eventReferencesThreadTarget(evt, rootInfo) - ) { - return true - } - return false -} - -/** NIP-69 poll responses (kind 1018): aggregated in the poll UI, not as thread rows under “Antworten”. */ -function isPollVoteKind(evt: Pick): boolean { - return evt.kind === ExtendedKind.POLL_RESPONSE -} - -function threadBacklinkRelationLabel(item: NEvent, t: TFunction): string { - if (item.kind === kinds.Highlights) return t('highlighted this note') - if (item.kind === kinds.ShortTextNote) return t('quoted this note') - if ( - item.kind === kinds.LongFormArticle || - item.kind === ExtendedKind.WIKI_ARTICLE || - item.kind === ExtendedKind.NOSTR_SPECIFICATION || - item.kind === ExtendedKind.PUBLICATION_CONTENT - ) { - return t('cited in article') - } - if (item.kind === kinds.Label) return t('labeled this note') - if (isNip56ReportEvent(item)) return t('reported this note') - if (item.kind === kinds.BookmarkList) return t('bookmarked this note') - if (item.kind === kinds.Pinlist) return t('pinned this note') - if (item.kind === kinds.Genericlists) return t('listed this note') - if (item.kind === kinds.Bookmarksets) return t('bookmark set reference') - if (item.kind === kinds.Curationsets) return t('curated this note') - if (item.kind === kinds.BadgeAward) return t('badge award for this note') - return t('referenced this note') -} - -/** E/A roots: kind-1 #q quotes + op-reference kinds belong in backlinks tail, not the chronological middle. */ -function isEaThreadTailBacklinkCandidate(evt: NEvent, root: TRootInfo): boolean { - if (root.type !== 'E' && root.type !== 'A') return false - if (evt.kind === kinds.ShortTextNote && kind1QuotesThreadRoot(evt, root)) return true - return EA_THREAD_TAIL_REFERENCE_KINDS.has(evt.kind) -} +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, + fetchPaymentAttestationsForRecipient, + isEaThreadTailBacklinkCandidate, + isPollVoteKind, + isWebThreadTailKind, + mergeFetchedKind7ReactionsIntoRootNoteStats, + moveReportsToEndPreserveOrder, + partitionAndSortBacklinkTail, + replyFeedZapsFirst, + replyIdPresentInRepliesMap, + replyMatchesThreadForList, + threadBacklinkRelationLabel +} from './reply-list-utils' +import { useThreadRootInfo } from './useThreadRootInfo' +import { useThreadAttestedPayments } from './useThreadAttestedPayments' function ReplyNoteList({ - index, + index: _pageIndex, event, sort = 'oldest', showQuotes = true, @@ -420,22 +109,28 @@ function ReplyNoteList({ }) { const { t } = useTranslation() const { navigateToNote } = useSmartNoteNavigation() - const { currentIndex } = useSecondaryPage() const { hideUntrustedInteractions, isUserTrusted, isTrustLoaded } = useUserTrust() const noteStats = useNoteStatsById(event.id) const { mutePubkeySet } = useMuteList() const { hideContentMentioningMutedUsers } = useContentPolicy() const { pubkey: userPubkey } = useNostr() const { zapReplyThreshold } = useZap() - const [attestedPaymentIds, setAttestedPaymentIds] = useState>(() => new Set()) - const threadRelayUrlsRef = useRef([]) const { blockedRelays, favoriteRelays } = useFavoriteRelays() const { relayUrls: browsingRelayUrls } = useCurrentRelays() const relayAuthoritativeRead = singleRelayAuthoritativeRead ?? browsingRelayUrls.length === 1 - const [rootInfo, setRootInfo] = useState(undefined) - const { repliesMap, addReplies } = useReply() + const rootInfo = useThreadRootInfo(event) + const { repliesMap, addReplies } = useReplyIngress() const isDiscussionRoot = event.kind === ExtendedKind.DISCUSSION + const threadRelayUrlsRef = useRef([]) + const replyFetchGenRef = useRef(0) + const { attestedPaymentIds, applyAttestedSuperchatWave } = useThreadAttestedPayments( + event.pubkey, + addReplies, + threadRelayUrlsRef, + browsingRelayUrls, + replyFetchGenRef + ) const replyDuplicateWebPreviewHints = useMemo(() => { const out: string[] = [...(duplicateWebPreviewCleanedUrlHints ?? [])] @@ -443,54 +138,7 @@ function ReplyNoteList({ return out.length ? out : undefined }, [duplicateWebPreviewCleanedUrlHints, rootInfo]) - useEffect(() => { - const pk = event.pubkey - if (!pk) return - let cancelled = false - void (async () => { - const ids = await resolveAttestedPaymentIdSet(pk) - if (cancelled) return - setAttestedPaymentIds(ids) - const relayHints = threadRelayUrlsRef.current.length - ? threadRelayUrlsRef.current - : browsingRelayUrls.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean) - const targets = await hydrateAttestedSuperchatTargets(ids, relayHints) - if (cancelled) return - if (targets.length > 0) addReplies(targets) - })() - return () => { - cancelled = true - } - }, [event.pubkey, event.id, addReplies, browsingRelayUrls]) - - useEffect(() => { - const handleAttestation = (data: Event) => { - const ce = data as CustomEvent - const evt = ce.detail - if (!evt || evt.kind !== ExtendedKind.PAYMENT_ATTESTATION) return - if (evt.pubkey.toLowerCase() !== event.pubkey.toLowerCase()) return - const targetId = getPaymentAttestationTargetId(evt) - if (!targetId) return - setAttestedPaymentIds((prev) => { - if (prev.has(targetId)) return prev - const next = new Set(prev) - next.add(targetId) - return next - }) - void client - .fetchEvent(targetId, { relayHints: threadRelayUrlsRef.current }) - .then((target) => { - if (target) addReplies([target]) - }) - .catch(() => { - /* optional */ - }) - } - client.addEventListener('newEvent', handleAttestation) - return () => client.removeEventListener('newEvent', handleAttestation) - }, [event.pubkey, addReplies]) - - const replies = useMemo(() => { + const replies: NEvent[] = useMemo(() => { const replyIdSet = new Set() const replyEvents: NEvent[] = [] const currentEventKey = isReplaceableEvent(event.kind) @@ -564,8 +212,33 @@ function ReplyNoteList({ if (iterationCount >= MAX_ITERATIONS) { logger.warn('ReplyNoteList: Maximum iterations reached, possible circular reference in replies') } - + 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 + } + return true + } + + for (const evt of collectAttestedSuperchatsFromRepliesMap( + repliesMap, + attestedPaymentIds, + replyIdSet, + includeThreadReply + )) { + replyIdSet.add(evt.id) + replyEvents.push(evt) + threadWalkFromRepliesMap.set(evt.id.toLowerCase(), evt) + } const { superchats, rest: nonZaps } = partitionAttestedSuperchats( replyEvents, @@ -860,83 +533,11 @@ function ReplyNoteList({ ]) const [loading, setLoading] = useState(false) - const [showCount, setShowCount] = useState(SHOW_COUNT) + const [showCount, setShowCount] = useState(THREAD_REPLY_SHOW_COUNT) const [highlightReplyId, setHighlightReplyId] = useState(undefined) const replyRefs = useRef>({}) const bottomRef = useRef(null) - useEffect(() => { - const fetchRootEvent = async () => { - if (event.kind === ExtendedKind.RSS_THREAD_ROOT) { - const url = getArticleUrlFromCommentITags(event) - if (url) { - setRootInfo({ type: 'I', id: canonicalizeRssArticleUrl(url) }) - } - return - } - - 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 { - const eid = event.id - root = { - type: 'E', - id: /^[0-9a-f]{64}$/i.test(eid) ? eid.toLowerCase() : eid, - pubkey: event.pubkey - } - } - - const rootETag = getRootETag(event) - if (rootETag) { - const [, rootEventHexId, , , rootEventPubkey] = rootETag - if (rootEventHexId && rootEventPubkey) { - const hid = resolveDeclaredThreadRootEventHex(rootEventHexId) - const resolvedRootEvent = client.peekSessionCachedEvent(hid) - root = { - type: 'E', - id: /^[0-9a-f]{64}$/i.test(hid) ? hid.toLowerCase() : hid, - pubkey: resolvedRootEvent?.pubkey ?? rootEventPubkey - } - } else { - const rootEventId = generateBech32IdFromETag(rootETag) - if (rootEventId) { - const rootEvent = await eventService.fetchEvent(rootEventId) - if (rootEvent) { - const rid = resolveDeclaredThreadRootEventHex(rootEvent.id) - const resolvedRootEvent = client.peekSessionCachedEvent(rid) ?? rootEvent - root = { - type: 'E', - id: /^[0-9a-f]{64}$/i.test(rid) ? rid.toLowerCase() : rid, - pubkey: resolvedRootEvent.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 rootArticleUrl = getArticleUrlFromCommentITags(event) - if (rootArticleUrl) { - root = { type: 'I', id: canonicalizeRssArticleUrl(rootArticleUrl) } - } - } - setRootInfo(root) - } - fetchRootEvent() - }, [event]) - /** When stats saw a URL-thread reply on relays we didn't REQ in the reply list, fetch by id so count matches list. */ const rssStatsHydratedReplyIdsRef = useRef>(new Set()) @@ -1121,14 +722,8 @@ function ReplyNoteList({ } }, [rootInfo, event, onNewReply, isDiscussionRoot]) - const replyFetchGenRef = useRef(0) - useEffect(() => { if (!rootInfo) return - // Hidden stack pages pass a numeric index that differs from the top panel's currentIndex. - // When index is omitted (edge routes), still fetch so replies are not stuck empty. - if (index !== undefined && currentIndex !== index) return - const fetchGeneration = ++replyFetchGenRef.current const init = async () => { @@ -1204,7 +799,7 @@ function ReplyNoteList({ const filters = buildThreadInteractionFilters({ root: rootInfo, opEventKind: event.kind, - limit: LIMIT + limit: THREAD_REPLY_LIMIT }) const relayUrlsForThreadReq = sanitizeRelayUrlsForFetch( @@ -1233,7 +828,7 @@ function ReplyNoteList({ const superchatFilters = buildThreadSuperchatPriorityFilters({ root: rootInfo, opEventKind: event.kind, - limit: LIMIT + limit: THREAD_REPLY_LIMIT }) if (superchatFilters.length > 0) { void queryService @@ -1251,20 +846,27 @@ function ReplyNoteList({ const attestationTask = recipientPubkey ? fetchPaymentAttestationsForRecipient(recipientPubkey, relayUrlsForThreadReq, { - foreground: statsForeground + foreground: true }) : Promise.resolve([] as NEvent[]) - const [allReplies, relayAttestations] = await Promise.all([ - queryService.fetchEvents(relayUrlsForThreadReq, filters, { - onevent: streamThreadReply, - foreground: true, - firstRelayResultGraceMs: 900, - globalTimeout: 12_000, - relayOpSource: 'ReplyNoteList.thread' - }), - attestationTask - ]) + 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 @@ -1303,23 +905,6 @@ function ReplyNoteList({ const repliesForStatsPrime = mergedForUi addReplies(mergedForUi) - if (recipientPubkey) { - void resolveAttestedPaymentIdSet(recipientPubkey, relayAttestations) - .then(async (attestedIds) => { - if (fetchGeneration !== replyFetchGenRef.current) return - setAttestedPaymentIds(attestedIds) - const targets = await hydrateAttestedSuperchatTargets( - attestedIds, - relayUrlsForThreadReq - ) - if (fetchGeneration !== replyFetchGenRef.current) return - if (targets.length > 0) addReplies(targets) - }) - .catch(() => { - /* attestations optional */ - }) - } - const statsBatch = mergedCachedReplies !== null && mergedCachedReplies.length > 0 ? mergedCachedReplies : regularReplies if (statsBatch.length > 0) { noteStatsService.updateNoteStatsByEvents(statsBatch, event.pubkey, { @@ -1373,7 +958,7 @@ function ReplyNoteList({ 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: LIMIT } + { '#e': idChunk, kinds: commentKinds, limit: THREAD_REPLY_LIMIT } ] const nestedReplies = await queryService.fetchEvents(relayUrlsForThreadReq, nestedFilters, { onevent: (evt: NEvent) => { @@ -1440,11 +1025,11 @@ function ReplyNoteList({ 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: LIMIT }, + { '#e': idChunk, kinds: commentKindsNested, limit: THREAD_REPLY_LIMIT }, { '#E': idChunk, kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], - limit: LIMIT + limit: THREAD_REPLY_LIMIT } ] const nestedReplies = await queryService.fetchEvents(relayUrlsForThreadReq, nestedFilters, { @@ -1490,8 +1075,6 @@ function ReplyNoteList({ init() }, [ rootInfo, - currentIndex, - index, userPubkey, event.id, event.kind, @@ -1503,7 +1086,8 @@ function ReplyNoteList({ mutePubkeySet, hideContentMentioningMutedUsers, isDiscussionRoot, - statsForeground + statsForeground, + applyAttestedSuperchatWave ]) useEffect(() => { @@ -1515,7 +1099,7 @@ function ReplyNoteList({ const observerInstance = new IntersectionObserver((entries) => { if (entries[0].isIntersecting && showCount < mergedFeed.length) { - setShowCount((prev) => prev + SHOW_COUNT) + setShowCount((prev) => prev + THREAD_REPLY_SHOW_COUNT) } }, options) @@ -1567,7 +1151,7 @@ function ReplyNoteList({ } const isQuote = quoteUiIdSet.has(item.id) // Attested superchats are public payment records — always show when they passed mute filters. - if (item.kind === kinds.Zap || item.kind === ExtendedKind.PAYMENT_NOTIFICATION) return true + if (isSuperchatKind(item.kind)) return true // Backlink rows (quotes, highlights, …): show even when author is not in the trust list. if (isQuote) return true if (isTrustLoaded && hideUntrustedInteractions && !isUserTrusted(item.pubkey)) { diff --git a/src/components/ReplyNoteList/reply-list-utils.ts b/src/components/ReplyNoteList/reply-list-utils.ts new file mode 100644 index 00000000..eccaab24 --- /dev/null +++ b/src/components/ReplyNoteList/reply-list-utils.ts @@ -0,0 +1,293 @@ +import type { TRootInfo } from './types' + +export type { TRootInfo } from './types' +export { + THREAD_REPLY_LIMIT, + THREAD_REPLY_SHOW_COUNT, + MAX_PARENT_IDS_PER_NESTED_REQ, + THREAD_PROFILE_BATCH_DEBOUNCE_MS, + THREAD_PROFILE_CHUNK +} from './types' + +import { ExtendedKind, NOTE_STATS_OP_REFERENCE_KINDS } from '@/constants' +import { isNip56ReportEvent, kind1QuotesThreadRoot } from '@/lib/event' +import { isSuperchatKind, replyFeedSuperchatsFirst } from '@/lib/superchat' +import { eventReferencesThreadTarget } from '@/lib/op-reference-tags' +import { replyBelongsToNoteThread } from '@/lib/thread-reply-root-match' +import { isRssArticleUrlThreadInteraction } from '@/lib/rss-web-feed' +import noteStatsService from '@/services/note-stats.service' +import client from '@/services/client.service' +import type { TSubRequestFilter } from '@/types' +import { Filter, Event as NEvent, kinds } from 'nostr-tools' +import type { TFunction } from 'i18next' + +export async function fetchPaymentAttestationsForRecipient( + recipientPubkey: string, + relayUrls: string[], + options: { foreground?: boolean } = {} +): Promise { + const filter: Filter = { + kinds: [ExtendedKind.PAYMENT_ATTESTATION], + authors: [recipientPubkey], + limit: 500 + } + const byId = new Map() + try { + const local = await client.getLocalFeedEvents( + [{ urls: [], filter: filter as TSubRequestFilter }], + { maxMatches: 500 } + ) + for (const e of local) byId.set(e.id, e) + } catch { + /* optional */ + } + if (relayUrls.length > 0) { + try { + const rows = await client.fetchEvents(relayUrls, filter, { + cache: true, + foreground: options.foreground, + eoseTimeout: options.foreground ? 1600 : 4500, + globalTimeout: options.foreground ? 5000 : 12_000 + }) + for (const e of rows) byId.set(e.id, e) + } catch { + /* optional */ + } + } + return [...byId.values()] +} + +export function replyFeedZapsFirst(sortedNonZapReplies: NEvent[], superchats: NEvent[]) { + return replyFeedSuperchatsFirst(sortedNonZapReplies, superchats) +} + +export type TBacklinkSubsection = 'primary' | 'bookmark' | 'list' | 'report' + +function sortWithinBacklinkGroup(events: NEvent[]): NEvent[] { + return [...events].sort((a, b) => b.created_at - a.created_at) +} + +function backlinkTailSubsection(item: NEvent): TBacklinkSubsection { + if (isNip56ReportEvent(item)) return 'report' + if (item.kind === kinds.BookmarkList) return 'bookmark' + if ( + item.kind === kinds.Pinlist || + item.kind === kinds.Genericlists || + item.kind === kinds.Bookmarksets || + item.kind === kinds.Curationsets + ) { + return 'list' + } + return 'primary' +} + +/** Quotes/highlights/citations → bookmarks → lists → reports; newest first within each group. */ +export function partitionAndSortBacklinkTail(tail: NEvent[]): NEvent[] { + const primary: NEvent[] = [] + const bookmarks: NEvent[] = [] + const lists: NEvent[] = [] + const reports: NEvent[] = [] + for (const e of tail) { + const sub = backlinkTailSubsection(e) + if (sub === 'report') reports.push(e) + else if (sub === 'bookmark') bookmarks.push(e) + else if (sub === 'list') lists.push(e) + else primary.push(e) + } + return [ + ...sortWithinBacklinkGroup(primary), + ...sortWithinBacklinkGroup(bookmarks), + ...sortWithinBacklinkGroup(lists), + ...sortWithinBacklinkGroup(reports) + ] +} + +export type TBacklinkDisplayRow = + | { type: 'reply'; event: NEvent } + | { type: 'backlink-run'; subsection: TBacklinkSubsection; events: NEvent[] } + +export function buildVisibleBacklinkRows( + visibleFeed: NEvent[], + quoteUiIdSet: Set +): TBacklinkDisplayRow[] { + const rows: TBacklinkDisplayRow[] = [] + let i = 0 + while (i < visibleFeed.length) { + const item = visibleFeed[i] + if (!quoteUiIdSet.has(item.id)) { + rows.push({ type: 'reply', event: item }) + i++ + continue + } + const sub = backlinkTailSubsection(item) + const run: NEvent[] = [] + while ( + i < visibleFeed.length && + quoteUiIdSet.has(visibleFeed[i].id) && + backlinkTailSubsection(visibleFeed[i]) === sub + ) { + run.push(visibleFeed[i]) + i++ + } + if (run.length > 0) { + rows.push({ type: 'backlink-run', subsection: sub, events: run }) + } + } + return rows +} + +export function backlinkRunSectionClass( + subsection: TBacklinkSubsection, + prev: TBacklinkDisplayRow | undefined +): string { + if (!prev) { + return subsection === 'report' + ? 'mb-3 pt-1' + : 'mb-3 pt-1' + } + if (prev.type === 'reply') { + return subsection === 'report' + ? 'mt-8 mb-3 border-t border-amber-500/40 pt-6 dark:border-amber-400/30' + : 'mt-8 mb-3 border-t border-border/60 pt-6' + } + return subsection === 'report' + ? 'mt-6 mb-3 border-t border-amber-500/40 pt-4 dark:border-amber-400/30' + : 'mt-6 mb-3 border-t border-border/60 pt-4' +} + +/** Preserve order except NIP-56 reports move to the end (after all non-reports). */ +export function moveReportsToEndPreserveOrder(events: NEvent[]): NEvent[] { + const non = events.filter((e) => !isNip56ReportEvent(e)) + const rep = events.filter((e) => isNip56ReportEvent(e)) + return [...non, ...rep] +} + +/** Shown after thread replies for E/A roots (quote stream + kind 1 #q-only); matches {@link NOTE_STATS_OP_REFERENCE_KINDS}. */ +export const EA_THREAD_TAIL_REFERENCE_KINDS = new Set(NOTE_STATS_OP_REFERENCE_KINDS) + +export function isWebThreadTailKind(kind: number): boolean { + return EA_THREAD_TAIL_REFERENCE_KINDS.has(kind) +} + +/** Kind 1111 / 1244 that includes the thread root id on an e/E tag (common on relays; stricter root-tag walks may miss these). */ +export function commentReferencesThreadRootEventHex(evt: NEvent, rootHexLower: string): boolean { + if (evt.kind !== ExtendedKind.COMMENT && evt.kind !== ExtendedKind.VOICE_COMMENT) return false + const h = rootHexLower.trim().toLowerCase() + if (!/^[0-9a-f]{64}$/.test(h)) return false + return evt.tags.some( + (t) => (t[0] === 'e' || t[0] === 'E') && typeof t[1] === 'string' && t[1].toLowerCase() === h + ) +} + +export function replyIdPresentInRepliesMap( + map: Map }>, + replyId: string +): boolean { + for (const { events } of map.values()) { + if (events.some((e) => e.id === replyId)) return true + } + return false +} + +/** NIP-25 reaction: any `e` / `E` tag value equals this hex id (lowercased). */ +function noteReactionEtagEqualsHex(ev: NEvent, hexLower: string): boolean { + const h = hexLower.trim().toLowerCase() + if (!/^[0-9a-f]{64}$/i.test(h)) return false + for (const t of ev.tags) { + if ((t[0] === 'e' || t[0] === 'E') && typeof t[1] === 'string' && t[1].toLowerCase() === h) return true + } + return false +} + +/** + * Thread REQ may still omit some kind-7 rows; merge reactions that tag the root hex so OP stats stay warm. + * Reactions are not listed under “Antworten”; this merge keeps OP stats warm when the thread REQ omits kind 7. + */ +export function mergeFetchedKind7ReactionsIntoRootNoteStats(all: NEvent[], rootInfo: TRootInfo) { + if (rootInfo.type === 'E') { + const rootHex = rootInfo.id.trim().toLowerCase() + const hits = all.filter((ev) => ev.kind === kinds.Reaction && noteReactionEtagEqualsHex(ev, rootHex)) + if (hits.length > 0) { + noteStatsService.updateNoteStatsByEvents(hits, undefined, { interactionTargetNoteId: rootInfo.id }) + } + } else if (rootInfo.type === 'A') { + const idHex = rootInfo.eventId?.trim().toLowerCase() + if (idHex && /^[0-9a-f]{64}$/i.test(idHex)) { + const hits = all.filter((ev) => ev.kind === kinds.Reaction && noteReactionEtagEqualsHex(ev, idHex)) + if (hits.length > 0) { + noteStatsService.updateNoteStatsByEvents(hits, undefined, { interactionTargetNoteId: rootInfo.eventId }) + } + } + } +} + +export function replyMatchesThreadForList( + evt: NEvent, + opEvent: NEvent, + rootInfo: TRootInfo, + isDiscussionRoot: boolean, + /** Events from the current relay batch (parent walk may not be in session LRU yet). */ + threadWalkLocal?: ReadonlyMap +): boolean { + if (rootInfo.type === 'I') { + return isRssArticleUrlThreadInteraction(evt, rootInfo.id) + } + if ( + isDiscussionRoot && + rootInfo.type === 'E' && + commentReferencesThreadRootEventHex(evt, rootInfo.id) + ) { + return true + } + if (replyBelongsToNoteThread(evt, opEvent, rootInfo, threadWalkLocal)) return true + if ( + isSuperchatKind(evt.kind) && + (rootInfo.type === 'E' || rootInfo.type === 'A') && + eventReferencesThreadTarget(evt, rootInfo) + ) { + return true + } + if ( + (rootInfo.type === 'E' || rootInfo.type === 'A') && + evt.kind !== kinds.ShortTextNote && + NOTE_STATS_OP_REFERENCE_KINDS.includes(evt.kind) && + eventReferencesThreadTarget(evt, rootInfo) + ) { + return true + } + return false +} + +/** NIP-69 poll responses (kind 1018): aggregated in the poll UI, not as thread rows under “Antworten”. */ +export function isPollVoteKind(evt: Pick): boolean { + return evt.kind === ExtendedKind.POLL_RESPONSE +} + +export function threadBacklinkRelationLabel(item: NEvent, t: TFunction): string { + if (item.kind === kinds.Highlights) return t('highlighted this note') + if (item.kind === kinds.ShortTextNote) return t('quoted this note') + if ( + item.kind === kinds.LongFormArticle || + item.kind === ExtendedKind.WIKI_ARTICLE || + item.kind === ExtendedKind.NOSTR_SPECIFICATION || + item.kind === ExtendedKind.PUBLICATION_CONTENT + ) { + return t('cited in article') + } + if (item.kind === kinds.Label) return t('labeled this note') + if (isNip56ReportEvent(item)) return t('reported this note') + if (item.kind === kinds.BookmarkList) return t('bookmarked this note') + if (item.kind === kinds.Pinlist) return t('pinned this note') + if (item.kind === kinds.Genericlists) return t('listed this note') + if (item.kind === kinds.Bookmarksets) return t('bookmark set reference') + if (item.kind === kinds.Curationsets) return t('curated this note') + if (item.kind === kinds.BadgeAward) return t('badge award for this note') + return t('referenced this note') +} + +/** E/A roots: kind-1 #q quotes + op-reference kinds belong in backlinks tail, not the chronological middle. */ +export function isEaThreadTailBacklinkCandidate(evt: NEvent, root: TRootInfo): boolean { + if (root.type !== 'E' && root.type !== 'A') return false + if (evt.kind === kinds.ShortTextNote && kind1QuotesThreadRoot(evt, root)) return true + return EA_THREAD_TAIL_REFERENCE_KINDS.has(evt.kind) +} diff --git a/src/components/ReplyNoteList/types.ts b/src/components/ReplyNoteList/types.ts new file mode 100644 index 00000000..3fe57780 --- /dev/null +++ b/src/components/ReplyNoteList/types.ts @@ -0,0 +1,10 @@ +export type TRootInfo = + | { type: 'E'; id: string; pubkey: string } + | { type: 'A'; id: string; eventId: string; pubkey: string; relay?: string } + | { type: 'I'; id: string } + +export const THREAD_REPLY_LIMIT = 200 +export const THREAD_REPLY_SHOW_COUNT = 10 +export const MAX_PARENT_IDS_PER_NESTED_REQ = 64 +export const THREAD_PROFILE_BATCH_DEBOUNCE_MS = 120 +export const THREAD_PROFILE_CHUNK = 80 diff --git a/src/components/ReplyNoteList/useThreadAttestedPayments.ts b/src/components/ReplyNoteList/useThreadAttestedPayments.ts new file mode 100644 index 00000000..1581b85a --- /dev/null +++ b/src/components/ReplyNoteList/useThreadAttestedPayments.ts @@ -0,0 +1,126 @@ +import { buildAttestedPaymentIdSet } from '@/lib/superchat' +import { + hydrateAttestedSuperchatTargetEvents, + mergeAttestedPaymentIdSets, + peekAttestedSuperchatTargetEvents, + resolveAttestedPaymentIdSet, + resolveAttestedPaymentIdSetSync +} from '@/lib/payment-attestation-cache' +import { getPaymentAttestationTargetId } from '@/lib/superchat' +import { ExtendedKind } from '@/constants' +import client from '@/services/client.service' +import { normalizeAnyRelayUrl } from '@/lib/url' +import type { Event as NEvent } from 'nostr-tools' +import { useCallback, useEffect, useState, type MutableRefObject } from 'react' + +/** Attested kind 9735/9740 ids + hydration for the thread recipient (OP). */ +export function useThreadAttestedPayments( + recipientPubkey: string | undefined, + addReplies: (events: NEvent[]) => void, + threadRelayUrlsRef: MutableRefObject, + browsingRelayUrls: string[], + replyFetchGenRef: MutableRefObject +) { + const [attestedPaymentIds, setAttestedPaymentIds] = useState>(() => + recipientPubkey ? resolveAttestedPaymentIdSetSync(recipientPubkey) : new Set() + ) + + const mergeAttestedPaymentIds = useCallback((incoming: ReadonlySet) => { + setAttestedPaymentIds((prev) => { + const next = mergeAttestedPaymentIdSets(prev, incoming) + return next.size === prev.size ? prev : next + }) + }, []) + + const applyAttestedSuperchatWave = useCallback( + async ( + relayAttestations: NEvent[], + relayUrls: string[], + fetchGeneration: number, + foreground: boolean + ) => { + const pk = recipientPubkey + if (!pk) return + + if (relayAttestations.length > 0) { + mergeAttestedPaymentIds(buildAttestedPaymentIdSet(relayAttestations, pk)) + } + + const syncIds = resolveAttestedPaymentIdSetSync(pk) + mergeAttestedPaymentIds(syncIds) + const syncTargets = peekAttestedSuperchatTargetEvents(syncIds) + if (syncTargets.length > 0) addReplies(syncTargets) + + const attestedIds = await resolveAttestedPaymentIdSet(pk, relayAttestations) + if (fetchGeneration !== replyFetchGenRef.current) return + mergeAttestedPaymentIds(attestedIds) + + const targets = await hydrateAttestedSuperchatTargetEvents(attestedIds, relayUrls, { + foreground + }) + if (fetchGeneration !== replyFetchGenRef.current) return + if (targets.length > 0) addReplies(targets) + }, + [recipientPubkey, addReplies, mergeAttestedPaymentIds, replyFetchGenRef] + ) + + useEffect(() => { + const pk = recipientPubkey + if (!pk) return + let cancelled = false + + const syncIds = resolveAttestedPaymentIdSetSync(pk) + mergeAttestedPaymentIds(syncIds) + const syncTargets = peekAttestedSuperchatTargetEvents(syncIds) + if (syncTargets.length > 0) addReplies(syncTargets) + + void (async () => { + const relayHints = threadRelayUrlsRef.current.length + ? threadRelayUrlsRef.current + : browsingRelayUrls.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean) + await applyAttestedSuperchatWave([], relayHints, replyFetchGenRef.current, true) + if (cancelled) return + })() + + return () => { + cancelled = true + } + }, [ + recipientPubkey, + addReplies, + browsingRelayUrls, + mergeAttestedPaymentIds, + applyAttestedSuperchatWave, + threadRelayUrlsRef, + replyFetchGenRef + ]) + + useEffect(() => { + const pk = recipientPubkey + if (!pk) return + + const handleAttestation = (data: Event) => { + const ce = data as CustomEvent + const evt = ce.detail + if (!evt || evt.kind !== ExtendedKind.PAYMENT_ATTESTATION) return + if (evt.pubkey.toLowerCase() !== pk.toLowerCase()) return + const targetId = getPaymentAttestationTargetId(evt) + if (!targetId) return + mergeAttestedPaymentIds(new Set([targetId])) + const cached = client.peekSessionCachedEvent(targetId) + if (cached) addReplies([cached]) + void client + .fetchEvent(targetId, { relayHints: threadRelayUrlsRef.current }) + .then((target) => { + if (target) addReplies([target]) + }) + .catch(() => { + /* optional */ + }) + } + client.addEventListener('newEvent', handleAttestation) + return () => client.removeEventListener('newEvent', handleAttestation) + }, [recipientPubkey, addReplies, mergeAttestedPaymentIds, threadRelayUrlsRef]) + + return { attestedPaymentIds, mergeAttestedPaymentIds, applyAttestedSuperchatWave } +} diff --git a/src/components/ReplyNoteList/useThreadRootInfo.ts b/src/components/ReplyNoteList/useThreadRootInfo.ts new file mode 100644 index 00000000..a974c9b7 --- /dev/null +++ b/src/components/ReplyNoteList/useThreadRootInfo.ts @@ -0,0 +1,101 @@ +import type { TRootInfo } from './types' +import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article' +import { + getReplaceableCoordinateFromEvent, + getRootATag, + getRootETag, + isReplaceableEvent, + resolveDeclaredThreadRootEventHex +} from '@/lib/event' +import { generateBech32IdFromETag } from '@/lib/tag' +import { ExtendedKind } from '@/constants' +import client, { eventService } from '@/services/client.service' +import type { Event } from 'nostr-tools' +import { useEffect, useState } from 'react' + +/** Resolve E/A/I thread root metadata for the open note. */ +export function useThreadRootInfo(event: Event) { + const [rootInfo, setRootInfo] = useState(undefined) + + useEffect(() => { + let cancelled = false + + const fetchRootEvent = async () => { + if (event.kind === ExtendedKind.RSS_THREAD_ROOT) { + const url = getArticleUrlFromCommentITags(event) + if (url && !cancelled) { + setRootInfo({ type: 'I', id: canonicalizeRssArticleUrl(url) }) + } + return + } + + 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 { + const eid = event.id + root = { + type: 'E', + id: /^[0-9a-f]{64}$/i.test(eid) ? eid.toLowerCase() : eid, + pubkey: event.pubkey + } + } + + const rootETag = getRootETag(event) + if (rootETag) { + const [, rootEventHexId, , , rootEventPubkey] = rootETag + if (rootEventHexId && rootEventPubkey) { + const hid = resolveDeclaredThreadRootEventHex(rootEventHexId) + const resolvedRootEvent = client.peekSessionCachedEvent(hid) + root = { + type: 'E', + id: /^[0-9a-f]{64}$/i.test(hid) ? hid.toLowerCase() : hid, + pubkey: resolvedRootEvent?.pubkey ?? rootEventPubkey + } + } else { + const rootEventId = generateBech32IdFromETag(rootETag) + if (rootEventId) { + const rootEvent = await eventService.fetchEvent(rootEventId) + if (cancelled) return + if (rootEvent) { + const rid = resolveDeclaredThreadRootEventHex(rootEvent.id) + const resolvedRootEvent = client.peekSessionCachedEvent(rid) ?? rootEvent + root = { + type: 'E', + id: /^[0-9a-f]{64}$/i.test(rid) ? rid.toLowerCase() : rid, + pubkey: resolvedRootEvent.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 rootArticleUrl = getArticleUrlFromCommentITags(event) + if (rootArticleUrl) { + root = { type: 'I', id: canonicalizeRssArticleUrl(rootArticleUrl) } + } + } + + if (!cancelled) setRootInfo(root) + } + + void fetchRootEvent() + return () => { + cancelled = true + } + }, [event]) + + return rootInfo +} diff --git a/src/hooks/useFetchEvent.tsx b/src/hooks/useFetchEvent.tsx index e4fca99d..8ce23e85 100644 --- a/src/hooks/useFetchEvent.tsx +++ b/src/hooks/useFetchEvent.tsx @@ -1,6 +1,6 @@ import { getNoteBech32Id } from '@/lib/event' import { useDeletedEvent } from '@/providers/DeletedEventProvider' -import { useReply } from '@/providers/ReplyProvider' +import { useReplyIngress } from '@/hooks/useReplyIngress' import { eventService } from '@/services/client.service' import { navigationEventStore } from '@/services/navigation-event-store' import { Event } from 'nostr-tools' @@ -12,7 +12,7 @@ export function useFetchEvent( fetchOpts?: { relayHints?: string[] } ) { const { isEventDeleted } = useDeletedEvent() - const { addReplies } = useReply() + const { addReplies } = useReplyIngress() const [error, setError] = useState(null) const [event, setEvent] = useState(initialEvent) const [isFetching, setIsFetching] = useState(!initialEvent) diff --git a/src/hooks/useFetchThreadContextEvent.tsx b/src/hooks/useFetchThreadContextEvent.tsx index 605c102d..eb3c5ca6 100644 --- a/src/hooks/useFetchThreadContextEvent.tsx +++ b/src/hooks/useFetchThreadContextEvent.tsx @@ -4,7 +4,7 @@ import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useNostr } from '@/providers/NostrProvider' -import { useReply } from '@/providers/ReplyProvider' +import { useReplyIngress } from '@/hooks/useReplyIngress' import { getNoteBech32Id, getParentETag, getRootETag } from '@/lib/event' import { buildThreadContextFetchRelayUrls } from '@/lib/thread-context-relays' import client, { eventService } from '@/services/client.service' @@ -50,7 +50,7 @@ export function useFetchThreadContextEvent( const { pubkey: viewerPubkey } = useNostr() const { blockedRelays } = useFavoriteRelays() const { isEventDeleted } = useDeletedEvent() - const { addReplies } = useReply() + const { addReplies } = useReplyIngress() const [error, setError] = useState(null) const [event, setEvent] = useState(initialEvent) const [isFetching, setIsFetching] = useState(!initialEvent) diff --git a/src/hooks/useProfileWall.tsx b/src/hooks/useProfileWall.tsx index 749549d1..d877273a 100644 --- a/src/hooks/useProfileWall.tsx +++ b/src/hooks/useProfileWall.tsx @@ -19,8 +19,17 @@ import { type ResolvedProfileBadge } from '@/lib/nip58-profile-badges' import { isDirectProfileWallComment } from '@/lib/profile-wall-comments' -import { filterAttestedProfileWallSuperchats, getPaymentAttestationTargetId } from '@/lib/superchat' -import { resolveAttestedPaymentIdSet } from '@/lib/payment-attestation-cache' +import { + buildAttestedPaymentIdSet, + filterAttestedProfileWallSuperchats, + getPaymentAttestationTargetId +} from '@/lib/superchat' +import { + hydrateAttestedSuperchatTargetEvents, + mergeAttestedPaymentIdSets, + resolveAttestedPaymentIdSet, + resolveAttestedPaymentIdSetSync +} from '@/lib/payment-attestation-cache' import { isValidPubkey, userIdToPubkey } from '@/lib/pubkey' import { normalizeAnyRelayUrl } from '@/lib/url' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' @@ -81,52 +90,6 @@ async function fetchBadgeDefinitionOnRelays( const CACHE_DURATION = 5 * 60 * 1000 -async function hydrateProfileWallSuperchatTargets( - attestedIds: ReadonlySet, - relayUrls: string[] -): Promise { - const ids = [...attestedIds].filter((id) => /^[0-9a-f]{64}$/i.test(id)) - if (ids.length === 0) return [] - - const byId = new Map() - try { - const local = await client.getLocalFeedEvents( - [{ urls: [], filter: { ids, limit: ids.length } }], - { maxMatches: ids.length } - ) - for (const e of local) byId.set(e.id.toLowerCase(), e) - } catch { - /* optional */ - } - - for (const id of ids) { - const key = id.toLowerCase() - if (byId.has(key)) continue - try { - const fromPublication = await indexedDb.getEventFromPublicationStore(id) - if (fromPublication) byId.set(fromPublication.id.toLowerCase(), fromPublication) - } catch { - /* optional */ - } - } - - const missing = ids.filter((id) => !byId.has(id.toLowerCase())) - if (missing.length > 0 && relayUrls.length > 0) { - try { - const fetched = await client.fetchEvents( - relayUrls, - { ids: missing, limit: missing.length }, - { cache: true, eoseTimeout: 4500, globalTimeout: 12_000, foreground: true } - ) - for (const e of fetched) byId.set(e.id.toLowerCase(), e) - } catch { - /* optional */ - } - } - - return [...byId.values()] -} - function normalizeProfileEventId(profileEventId: string | undefined): string | undefined { const id = profileEventId?.trim().toLowerCase() return id && /^[0-9a-f]{64}$/.test(id) ? id : undefined @@ -217,8 +180,15 @@ async function hydrateProfileWallSuperchatsFromLocalCache( } const attestations = [...pool.values()].filter((e) => e.kind === ExtendedKind.PAYMENT_ATTESTATION) - const attestedIds = await resolveAttestedPaymentIdSet(pkNorm, attestations) - for (const e of await hydrateProfileWallSuperchatTargets(attestedIds, [])) { + const attestedIds = mergeAttestedPaymentIdSets( + resolveAttestedPaymentIdSetSync(pkNorm), + buildAttestedPaymentIdSet(attestations, pkNorm) + ) + for (const e of await hydrateAttestedSuperchatTargetEvents(attestedIds, [])) { + pool.set(e.id, e) + } + const fullAttestedIds = await resolveAttestedPaymentIdSet(pkNorm, attestations) + for (const e of await hydrateAttestedSuperchatTargetEvents(fullAttestedIds, [])) { pool.set(e.id, e) } @@ -235,7 +205,7 @@ async function hydrateProfileWallSuperchatsFromLocalCache( attestations, pkNorm, profileId, - attestedIds + fullAttestedIds ) } @@ -587,8 +557,19 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine const attestations = [...pool.values()].filter( (e) => e.kind === ExtendedKind.PAYMENT_ATTESTATION ) - const attestedIds = await resolveAttestedPaymentIdSet(pkNorm, attestations) - for (const e of await hydrateProfileWallSuperchatTargets(attestedIds, relayUrls)) { + let attestedIds = mergeAttestedPaymentIdSets( + resolveAttestedPaymentIdSetSync(pkNorm), + buildAttestedPaymentIdSet(attestations, pkNorm) + ) + for (const e of await hydrateAttestedSuperchatTargetEvents(attestedIds, relayUrls, { + foreground: true + })) { + pool.set(e.id, e) + } + attestedIds = await resolveAttestedPaymentIdSet(pkNorm, attestations) + for (const e of await hydrateAttestedSuperchatTargetEvents(attestedIds, relayUrls, { + foreground: true + })) { pool.set(e.id, e) } diff --git a/src/hooks/useReplyIngress.ts b/src/hooks/useReplyIngress.ts new file mode 100644 index 00000000..bca7f887 --- /dev/null +++ b/src/hooks/useReplyIngress.ts @@ -0,0 +1,15 @@ +import { useReply } from '@/providers/ReplyProvider' +import { useThreadReplyOptional } from '@/providers/ThreadReplyProvider' + +/** + * Reply map ingress for the open note panel: prefers per-thread storage when + * {@link ThreadReplyProvider} wraps the note page (avoids cross-thread pollution). + */ +export function useReplyIngress() { + const thread = useThreadReplyOptional() + const global = useReply() + if (thread) { + return { repliesMap: thread.repliesMap, addReplies: thread.addReplies, scoped: true as const } + } + return { repliesMap: global.repliesMap, addReplies: global.addReplies, scoped: false as const } +} diff --git a/src/lib/op-reference-tags.ts b/src/lib/op-reference-tags.ts index bae9b3db..e03dc96a 100644 --- a/src/lib/op-reference-tags.ts +++ b/src/lib/op-reference-tags.ts @@ -5,11 +5,11 @@ import { normalizeReplaceableCoordinateString } from '@/lib/event' import { getZapInfoFromEvent } from '@/lib/event-metadata' +import { isSuperchatKind } from '@/lib/superchat' import { isRssArticleUrlThreadInteraction } from '@/lib/rss-web-feed' import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article' import type { TThreadRootRef } from '@/lib/thread-reply-root-match' import type { Event } from 'nostr-tools' -import { kinds } from 'nostr-tools' const REF_TAG_NAMES = new Set(['e', 'E', 'a', 'A', 'q', 'Q']) @@ -35,7 +35,7 @@ export function eventReferencesThreadTarget(evt: Event, root: TThreadRootRef): b if (normalizeReplaceableCoordinateString(v) === coordNorm) return true } } - if (evt.kind === kinds.Zap) { + if (isSuperchatKind(evt.kind) && evt.kind !== ExtendedKind.PAYMENT_NOTIFICATION) { const zapped = getZapInfoFromEvent(evt)?.originalEventId if (zapped && /^[0-9a-f]{64}$/i.test(zapped) && zapped.toLowerCase() === eventHex) return true const coord = getZapInfoFromEvent(evt)?.eventId @@ -52,7 +52,7 @@ export function eventReferencesThreadTarget(evt: Event, root: TThreadRootRef): b if (!v) continue if (/^[0-9a-f]{64}$/i.test(v) && v.toLowerCase() === hex) return true } - if (evt.kind === kinds.Zap) { + if (isSuperchatKind(evt.kind) && evt.kind !== ExtendedKind.PAYMENT_NOTIFICATION) { const zapped = getZapInfoFromEvent(evt)?.originalEventId if (zapped && /^[0-9a-f]{64}$/i.test(zapped) && zapped.toLowerCase() === hex) return true } diff --git a/src/lib/payment-attestation-cache.test.ts b/src/lib/payment-attestation-cache.test.ts new file mode 100644 index 00000000..66ae89ee --- /dev/null +++ b/src/lib/payment-attestation-cache.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { ExtendedKind } from '@/constants' +import { + rememberPaymentAttestationFromPublish, + resolveAttestedPaymentIdSetSync +} from '@/lib/payment-attestation-cache' +import type { Event } from 'nostr-tools' + +const RECIPIENT = 'a'.repeat(64) +const PAYMENT_ID = 'd'.repeat(64) + +vi.mock('@/services/client.service', () => ({ + default: { + peekSessionCachedEvent: vi.fn(), + eventService: { + getSessionEventsMatchingFilters: vi.fn(() => []) + } + } +})) + +function fakeEvent(partial: Partial & Pick): Event { + return { + id: partial.id ?? 'e'.repeat(64), + pubkey: partial.pubkey ?? RECIPIENT, + created_at: partial.created_at ?? 1_700_000_000, + kind: partial.kind, + tags: partial.tags, + content: partial.content ?? '', + sig: partial.sig ?? 'sig' + } +} + +describe('resolveAttestedPaymentIdSetSync', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns attested target ids from in-memory attestations without IndexedDB', () => { + rememberPaymentAttestationFromPublish( + fakeEvent({ + kind: ExtendedKind.PAYMENT_ATTESTATION, + pubkey: RECIPIENT, + tags: [ + ['e', PAYMENT_ID], + ['k', '9740'] + ] + }) + ) + const ids = resolveAttestedPaymentIdSetSync(RECIPIENT) + expect(ids.has(PAYMENT_ID)).toBe(true) + }) +}) diff --git a/src/lib/payment-attestation-cache.ts b/src/lib/payment-attestation-cache.ts index 81857d48..4021466e 100644 --- a/src/lib/payment-attestation-cache.ts +++ b/src/lib/payment-attestation-cache.ts @@ -58,6 +58,117 @@ export function readKnownAttestedPaymentTargetsSync(recipientPubkey: string): Se return new Set(readLocalAttestedIds(recipientPubkey)) } +export function mergeAttestedPaymentIdSets( + base: ReadonlySet, + incoming: ReadonlySet +): Set { + const next = new Set(base) + for (const id of incoming) next.add(id) + return next +} + +function listInMemoryAttestationsForAuthor(recipientPubkey: string): NostrEvent[] { + const pk = normalizeHexPubkey(recipientPubkey) + if (!/^[0-9a-f]{64}$/.test(pk)) return [] + const suffix = `:${pk}` + const out: NostrEvent[] = [] + for (const [key, attestation] of attestationByTargetKey) { + if (key.endsWith(suffix)) out.push(attestation) + } + return out +} + +/** + * Attested payment target ids without awaiting IndexedDB (memory cache, session, verified local marks). + * Use for first paint; follow with {@link resolveAttestedPaymentIdSet} for a complete set. + */ +export function resolveAttestedPaymentIdSetSync(recipientPubkey: string): Set { + const pk = normalizeHexPubkey(recipientPubkey) + if (!/^[0-9a-f]{64}$/.test(pk)) return new Set() + + const attestations: NostrEvent[] = [...listInMemoryAttestationsForAuthor(pk)] + const seen = new Set(attestations.map((a) => a.id)) + for (const attestation of client.eventService.getSessionEventsMatchingFilters( + [{ kinds: [ExtendedKind.PAYMENT_ATTESTATION], authors: [pk], limit: 500 }], + 500 + )) { + if (seen.has(attestation.id)) continue + seen.add(attestation.id) + attestations.push(attestation) + } + + const out = buildAttestedPaymentIdSet(attestations, pk) + for (const id of readLocalAttestedIds(pk)) { + if (out.has(id)) continue + const cached = peekCachedPaymentAttestation(id, pk) + if (cached?.kind === ExtendedKind.PAYMENT_ATTESTATION) { + out.add(id) + } + } + return out +} + +/** Kind 9735 / 9740 events already in the session LRU (no network). */ +export function peekAttestedSuperchatTargetEvents(attestedIds: ReadonlySet): NostrEvent[] { + const out: NostrEvent[] = [] + const seen = new Set() + for (const id of attestedIds) { + const hex = id.trim().toLowerCase() + if (!/^[0-9a-f]{64}$/.test(hex)) continue + const ev = client.peekSessionCachedEvent(hex) + if (!ev || seen.has(ev.id)) continue + seen.add(ev.id) + out.push(ev) + } + return out +} + +/** Load attested superchat target events: session → local feed → relay (short timeouts when foreground). */ +export async function hydrateAttestedSuperchatTargetEvents( + attestedIds: ReadonlySet, + relayUrls: string[], + options: { foreground?: boolean } = {} +): Promise { + const ids = [...attestedIds].filter((id) => /^[0-9a-f]{64}$/i.test(id)) + if (ids.length === 0) return [] + + const byId = new Map() + for (const e of peekAttestedSuperchatTargetEvents(attestedIds)) { + byId.set(e.id.toLowerCase(), e) + } + + try { + const local = await client.getLocalFeedEvents( + [{ urls: [], filter: { ids, limit: ids.length } }], + { maxMatches: ids.length } + ) + for (const e of local) byId.set(e.id.toLowerCase(), e) + } catch { + /* optional */ + } + + const missing = ids.filter((id) => !byId.has(id.toLowerCase())) + if (missing.length > 0 && relayUrls.length > 0) { + try { + const fetched = await client.fetchEvents( + relayUrls, + { ids: missing, limit: missing.length }, + { + cache: true, + foreground: options.foreground, + eoseTimeout: options.foreground ? 1600 : 4500, + globalTimeout: options.foreground ? 5000 : 12_000 + } + ) + for (const e of fetched) byId.set(e.id.toLowerCase(), e) + } catch { + /* optional */ + } + } + + return [...byId.values()] +} + /** Drop durable local marks that are not backed by a cached kind 9741 attestation. */ export function pruneUnverifiedLocalAttestationMarks(recipientPubkey: string): void { const pk = normalizeHexPubkey(recipientPubkey) diff --git a/src/lib/reply-index.ts b/src/lib/reply-index.ts new file mode 100644 index 00000000..ede0d1fd --- /dev/null +++ b/src/lib/reply-index.ts @@ -0,0 +1,113 @@ +import { + canonicalizeRssArticleUrl, + getArticleUrlFromCommentITags, + getHighlightSourceHttpUrl +} from '@/lib/rss-article' +import { + getParentATag, + getParentETag, + getQuotedReferenceFromQTags, + getRootATag, + getRootETag, + isNip18RepostKind, + isNip25ReactionKind, + resolveDeclaredThreadRootEventHex +} from '@/lib/event' +import { getFirstHexEventIdFromETags } from '@/lib/tag' +import client from '@/services/client.service' +import type { Event } from 'nostr-tools' +import { kinds } from 'nostr-tools' + +export type TRepliesMap = Map }> + +/** Index reply events under root / parent / quote keys (shared by global and per-thread maps). */ +export function mergeRepliesIntoMap(prev: TRepliesMap, replies: Event[]): TRepliesMap { + const newReplyIdSet = new Set() + const newReplyEventMap = new Map() + + for (const reply of replies) { + if (newReplyIdSet.has(reply.id)) continue + if (isNip18RepostKind(reply.kind)) { + client.addEventToCache(reply) + continue + } + if (isNip25ReactionKind(reply.kind)) { + newReplyIdSet.add(reply.id) + client.addEventToCache(reply) + const targetHex = getFirstHexEventIdFromETags(reply.tags) + if (targetHex && /^[0-9a-f]{64}$/i.test(targetHex)) { + const key = targetHex.toLowerCase() + newReplyEventMap.set(key, [...(newReplyEventMap.get(key) || []), reply]) + } + continue + } + newReplyIdSet.add(reply.id) + client.addEventToCache(reply) + + let rootId: string | undefined + const rootETag = getRootETag(reply) + if (rootETag) { + const raw = rootETag[1]?.toLowerCase?.() ?? rootETag[1] + rootId = + raw && /^[0-9a-f]{64}$/i.test(raw) ? resolveDeclaredThreadRootEventHex(raw) : raw + } else { + const rootATag = getRootATag(reply) + if (rootATag) { + rootId = rootATag[1] + } else { + const articleUrl = getArticleUrlFromCommentITags(reply) + if (articleUrl) { + rootId = canonicalizeRssArticleUrl(articleUrl) + } else if (reply.kind === kinds.Highlights) { + const hu = getHighlightSourceHttpUrl(reply) + if (hu) rootId = canonicalizeRssArticleUrl(hu) + } + } + } + if (rootId) { + newReplyEventMap.set(rootId, [...(newReplyEventMap.get(rootId) || []), reply]) + } + + let parentId: string | undefined + const parentETag = getParentETag(reply) + if (parentETag) { + parentId = parentETag[1]?.toLowerCase?.() ?? parentETag[1] + } else { + const parentATag = getParentATag(reply) + if (parentATag) { + parentId = parentATag[1] + } + } + if (parentId && parentId !== rootId) { + newReplyEventMap.set(parentId, [...(newReplyEventMap.get(parentId) || []), reply]) + } + + if (!rootId && !parentId) { + const qref = getQuotedReferenceFromQTags(reply) + const keys = new Set([qref?.hexId, qref?.coordinate].filter(Boolean) as string[]) + for (const key of keys) { + newReplyEventMap.set(key, [...(newReplyEventMap.get(key) || []), reply]) + } + } + } + + if (newReplyEventMap.size === 0) return prev + + const next = new Map(prev) + for (const [id, newReplyEvents] of newReplyEventMap.entries()) { + const existing = next.get(id) + const events = existing ? [...existing.events] : [] + const eventIdSet = existing ? new Set(existing.eventIdSet) : new Set() + for (const reply of newReplyEvents) { + const existingIdx = events.findIndex((e) => e.id === reply.id) + if (existingIdx >= 0) { + events[existingIdx] = reply + } else { + events.push(reply) + } + eventIdSet.add(reply.id) + } + next.set(id, { events, eventIdSet }) + } + return next +} diff --git a/src/lib/superchat.test.ts b/src/lib/superchat.test.ts index d84ad2f2..62bd21f5 100644 --- a/src/lib/superchat.test.ts +++ b/src/lib/superchat.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest' import { ExtendedKind } from '@/constants' import { buildAttestedPaymentIdSet, + collectAttestedSuperchatsFromRepliesMap, + isValidPaymentAttestation, filterAttestedProfileWallSuperchats, getPaymentNotificationInfo, getSuperchatPaytoType, @@ -108,6 +110,39 @@ describe('buildAttestedPaymentIdSet', () => { }) expect(buildAttestedPaymentIdSet([attestation], RECIPIENT).has(PAYMENT_ID)).toBe(true) }) + + it('ignores attestations with an invalid k tag', () => { + const attestation = fakeEvent({ + kind: ExtendedKind.PAYMENT_ATTESTATION, + pubkey: RECIPIENT, + tags: [ + ['e', PAYMENT_ID], + ['k', '1'] + ] + }) + expect(isValidPaymentAttestation(attestation, RECIPIENT)).toBe(false) + expect(buildAttestedPaymentIdSet([attestation], RECIPIENT).size).toBe(0) + }) +}) + +describe('collectAttestedSuperchatsFromRepliesMap', () => { + it('returns attested superchats not already in the BFS set', () => { + const payment = fakeEvent({ + id: PAYMENT_ID, + kind: ExtendedKind.PAYMENT_NOTIFICATION, + tags: [['p', RECIPIENT], ['e', '2'.repeat(64)]] + }) + const repliesMap = new Map([ + ['2'.repeat(64), { events: [payment], eventIdSet: new Set([PAYMENT_ID]) }] + ]) + const found = collectAttestedSuperchatsFromRepliesMap( + repliesMap, + new Set([PAYMENT_ID]), + new Set(), + () => true + ) + expect(found.map((e) => e.id)).toEqual([PAYMENT_ID]) + }) }) describe('isNestedThreadReplyParentKind', () => { diff --git a/src/lib/superchat.ts b/src/lib/superchat.ts index 4ebc0694..b2350a96 100644 --- a/src/lib/superchat.ts +++ b/src/lib/superchat.ts @@ -45,6 +45,16 @@ export function getPaymentAttestationTargetKind(attestation: Event): string | un return k && PAYMENT_ATTESTATION_TARGET_KINDS.has(k) ? k : undefined } +/** Kind 9741 from the payment recipient with a valid `e` (and `k` when present). */ +export function isValidPaymentAttestation(attestation: Event, recipientPubkey: string): boolean { + if (attestation.kind !== ExtendedKind.PAYMENT_ATTESTATION) return false + if (!hexPubkeysEqual(attestation.pubkey, recipientPubkey)) return false + if (!getPaymentAttestationTargetId(attestation)) return false + const hasKTag = attestation.tags.some(([name]) => name === 'k' || name === 'K') + if (hasKTag && !getPaymentAttestationTargetKind(attestation)) return false + return true +} + /** Event ids (lowercase hex) the recipient has attested as received payment. */ export function buildAttestedPaymentIdSet( attestations: Event[], @@ -52,10 +62,9 @@ export function buildAttestedPaymentIdSet( ): Set { const out = new Set() for (const attestation of attestations) { - if (!hexPubkeysEqual(attestation.pubkey, recipientPubkey)) continue + if (!isValidPaymentAttestation(attestation, recipientPubkey)) continue const targetId = getPaymentAttestationTargetId(attestation) - if (!targetId) continue - out.add(targetId) + if (targetId) out.add(targetId) } return out } @@ -204,6 +213,31 @@ export function sortSuperchatsByAmountDesc(events: Event[]): Event[] { }) } +/** + * Attested kind 9735 / 9740 events already in `repliesMap` that the thread BFS may not reach + * (e.g. keyed only under a parent id, or hydrated after the walk). + */ +export function collectAttestedSuperchatsFromRepliesMap( + repliesMap: ReadonlyMap, + attestedIds: ReadonlySet, + alreadySeen: ReadonlySet, + includeEvent: (event: Event) => boolean +): Event[] { + const out: Event[] = [] + const seen = new Set(alreadySeen) + for (const { events } of repliesMap.values()) { + for (const evt of events) { + if (seen.has(evt.id)) continue + if (!isSuperchatKind(evt.kind)) continue + if (!isAttestedSuperchat(evt, attestedIds)) continue + if (!includeEvent(evt)) continue + seen.add(evt.id) + out.push(evt) + } + } + return out +} + export function partitionAttestedSuperchats( items: Event[], attestedIds: Set, diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index f0256a7e..994262ce 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -52,6 +52,7 @@ import { } from '@/lib/document-meta' import NotFound from './NotFound' import { ThreadProfileBatchProvider } from '@/providers/ThreadProfileBatchProvider' +import { ThreadReplyProvider } from '@/providers/ThreadReplyProvider' // Helper function to get event type name (matching WebPreview) function getEventTypeName(kind: number): string { @@ -515,7 +516,8 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: } return ( - + + + ) }) NotePage.displayName = 'NotePage' diff --git a/src/providers/ReplyProvider.tsx b/src/providers/ReplyProvider.tsx index bc654da6..2633cbdf 100644 --- a/src/providers/ReplyProvider.tsx +++ b/src/providers/ReplyProvider.tsx @@ -1,25 +1,9 @@ -import { - canonicalizeRssArticleUrl, - getArticleUrlFromCommentITags, - getHighlightSourceHttpUrl -} from '@/lib/rss-article' -import { - getParentATag, - getParentETag, - getQuotedReferenceFromQTags, - getRootATag, - getRootETag, - isNip18RepostKind, - isNip25ReactionKind, - resolveDeclaredThreadRootEventHex -} from '@/lib/event' -import { getFirstHexEventIdFromETags } from '@/lib/tag' -import client from '@/services/client.service' -import { Event, kinds } from 'nostr-tools' +import { mergeRepliesIntoMap, type TRepliesMap } from '@/lib/reply-index' +import type { Event } from 'nostr-tools' import { createContext, useCallback, useContext, useState } from 'react' type TReplyContext = { - repliesMap: Map }> + repliesMap: TRepliesMap addReplies: (replies: Event[]) => void } @@ -34,101 +18,11 @@ export const useReply = () => { } export function ReplyProvider({ children }: { children: React.ReactNode }) { - const [repliesMap, setRepliesMap] = useState< - Map }> - >(new Map()) + const [repliesMap, setRepliesMap] = useState(() => new Map()) const addReplies = useCallback((replies: Event[]) => { - const newReplyIdSet = new Set() - const newReplyEventMap = new Map() - replies.forEach((reply) => { - if (newReplyIdSet.has(reply.id)) return - // NIP-18 kind 6 / 16 — stats + OP booster strip only, not thread reply map keys. - if (isNip18RepostKind(reply.kind)) { - client.addEventToCache(reply) - return - } - if (isNip25ReactionKind(reply.kind)) { - newReplyIdSet.add(reply.id) - client.addEventToCache(reply) - const targetHex = getFirstHexEventIdFromETags(reply.tags) - if (targetHex && /^[0-9a-f]{64}$/i.test(targetHex)) { - const key = targetHex.toLowerCase() - newReplyEventMap.set(key, [...(newReplyEventMap.get(key) || []), reply]) - } - return - } - newReplyIdSet.add(reply.id) - client.addEventToCache(reply) - - let rootId: string | undefined - const rootETag = getRootETag(reply) - if (rootETag) { - const raw = rootETag[1]?.toLowerCase?.() ?? rootETag[1] - rootId = - raw && /^[0-9a-f]{64}$/i.test(raw) ? resolveDeclaredThreadRootEventHex(raw) : raw - } else { - const rootATag = getRootATag(reply) - if (rootATag) { - rootId = rootATag[1] - } else { - const articleUrl = getArticleUrlFromCommentITags(reply) - if (articleUrl) { - rootId = canonicalizeRssArticleUrl(articleUrl) - } else if (reply.kind === kinds.Highlights) { - const hu = getHighlightSourceHttpUrl(reply) - if (hu) rootId = canonicalizeRssArticleUrl(hu) - } - } - } - if (rootId) { - newReplyEventMap.set(rootId, [...(newReplyEventMap.get(rootId) || []), reply]) - } - - let parentId: string | undefined - const parentETag = getParentETag(reply) - if (parentETag) { - parentId = parentETag[1]?.toLowerCase?.() ?? parentETag[1] - } else { - const parentATag = getParentATag(reply) - if (parentATag) { - parentId = parentATag[1] - } - } - if (parentId && parentId !== rootId) { - newReplyEventMap.set(parentId, [...(newReplyEventMap.get(parentId) || []), reply]) - } - - // Quote-only notes (#q, no e-tags): index under quoted hex id and/or replaceable coordinate. - if (!rootId && !parentId) { - const qref = getQuotedReferenceFromQTags(reply) - const keys = new Set([qref?.hexId, qref?.coordinate].filter(Boolean) as string[]) - for (const key of keys) { - newReplyEventMap.set(key, [...(newReplyEventMap.get(key) || []), reply]) - } - } - }) - if (newReplyEventMap.size === 0) return - - setRepliesMap((prev) => { - const next = new Map(prev) - for (const [id, newReplyEvents] of newReplyEventMap.entries()) { - const existing = next.get(id) - const events = existing ? [...existing.events] : [] - const eventIdSet = existing ? new Set(existing.eventIdSet) : new Set() - newReplyEvents.forEach((reply) => { - const existingIdx = events.findIndex((e) => e.id === reply.id) - if (existingIdx >= 0) { - events[existingIdx] = reply - } else { - events.push(reply) - } - eventIdSet.add(reply.id) - }) - next.set(id, { events, eventIdSet }) - } - return next - }) + if (replies.length === 0) return + setRepliesMap((prev) => mergeRepliesIntoMap(prev, replies)) }, []) return ( diff --git a/src/providers/ThreadReplyProvider.tsx b/src/providers/ThreadReplyProvider.tsx new file mode 100644 index 00000000..9592a427 --- /dev/null +++ b/src/providers/ThreadReplyProvider.tsx @@ -0,0 +1,42 @@ +import { mergeRepliesIntoMap, type TRepliesMap } from '@/lib/reply-index' +import type { Event } from 'nostr-tools' +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' + +type TThreadReplyContext = { + threadKey: string + repliesMap: TRepliesMap + addReplies: (replies: Event[]) => void +} + +const ThreadReplyContext = createContext(undefined) + +export function ThreadReplyProvider({ + threadKey, + children +}: { + /** Stable id for the open note (hex event id or replaceable coordinate). */ + threadKey: string + children: React.ReactNode +}) { + const [repliesMap, setRepliesMap] = useState(() => new Map()) + + useEffect(() => { + setRepliesMap(new Map()) + }, [threadKey]) + + const addReplies = useCallback((replies: Event[]) => { + if (replies.length === 0) return + setRepliesMap((prev) => mergeRepliesIntoMap(prev, replies)) + }, []) + + const value = useMemo( + () => ({ threadKey, repliesMap, addReplies }), + [threadKey, repliesMap, addReplies] + ) + + return {children} +} + +export function useThreadReplyOptional() { + return useContext(ThreadReplyContext) +}