From ab0989423f01eba527a630bc7307cbde148d9b87 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 23 May 2026 22:06:13 +0200 Subject: [PATCH] fix superchat threading --- src/components/ReplyNoteList/index.tsx | 23 +++++++--- src/lib/superchat.test.ts | 11 +++++ src/lib/superchat.ts | 10 +++++ src/lib/thread-reply-root-match.test.ts | 57 +++++++++++++++++++++++++ src/lib/thread-reply-root-match.ts | 23 +++++++--- 5 files changed, 112 insertions(+), 12 deletions(-) diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index fc8a7025..8b3de68c 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -19,6 +19,7 @@ import { import logger from '@/lib/logger' import { getPaymentAttestationTargetId, + isNestedThreadReplyParentKind, partitionAttestedSuperchats, replyFeedSuperchatsFirst } from '@/lib/superchat' @@ -516,7 +517,13 @@ function ReplyNoteList({ const processedEventIds = new Set() // Prevent infinite loops let iterationCount = 0 const MAX_ITERATIONS = 10 // Prevent infinite loops - + const threadWalkFromRepliesMap = new Map() + for (const { events: bucket } of repliesMap.values()) { + for (const e of bucket) { + threadWalkFromRepliesMap.set(e.id.toLowerCase(), e) + } + } + while (parentEventKeys.length > 0 && iterationCount < MAX_ITERATIONS) { iterationCount++ const events = parentEventKeys.flatMap((id) => repliesMap.get(id)?.events || []) @@ -533,12 +540,18 @@ function ReplyNoteList({ ) { return } - if (rootInfo && !replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot)) return + if ( + rootInfo && + !replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot, threadWalkFromRepliesMap) + ) { + return + } replyIdSet.add(evt.id) replyEvents.push(evt) + threadWalkFromRepliesMap.set(evt.id.toLowerCase(), evt) }) - + // Include reactions (and every other kind) so BFS can find notes keyed under reaction / zap ids. const newParentEventKeys = events .map((evt) => evt.id) @@ -1353,7 +1366,7 @@ function ReplyNoteList({ kinds.ShortTextNote ] const parentIds = regularReplies - .filter((evt) => commentKinds.includes(evt.kind)) + .filter((evt) => isNestedThreadReplyParentKind(evt.kind)) .map((evt) => evt.id) if (parentIds.length > 0) { const nestedAccum: NEvent[] = [] @@ -1414,7 +1427,7 @@ function ReplyNoteList({ [ focusedParentId, ...regularReplies - .filter((evt) => commentKindsNested.includes(evt.kind)) + .filter((evt) => isNestedThreadReplyParentKind(evt.kind)) .map((evt) => evt.id) ].filter(Boolean) as string[] ) diff --git a/src/lib/superchat.test.ts b/src/lib/superchat.test.ts index a7573c42..d84ad2f2 100644 --- a/src/lib/superchat.test.ts +++ b/src/lib/superchat.test.ts @@ -9,6 +9,7 @@ import { canUserAttestSuperchatPayment, isProfileWallPaymentNotification, isProfileWallZapReceipt, + isNestedThreadReplyParentKind, partitionAttestedSuperchats } from '@/lib/superchat' import { parsePaytoTagType } from '@/lib/payto' @@ -109,6 +110,16 @@ describe('buildAttestedPaymentIdSet', () => { }) }) +describe('isNestedThreadReplyParentKind', () => { + it('includes zaps and payment notifications for nested thread fetch', () => { + expect(isNestedThreadReplyParentKind(kinds.Zap)).toBe(true) + expect(isNestedThreadReplyParentKind(ExtendedKind.ZAP_RECEIPT)).toBe(true) + expect(isNestedThreadReplyParentKind(ExtendedKind.PAYMENT_NOTIFICATION)).toBe(true) + expect(isNestedThreadReplyParentKind(kinds.ShortTextNote)).toBe(true) + expect(isNestedThreadReplyParentKind(kinds.Reaction)).toBe(false) + }) +}) + describe('partitionAttestedSuperchats', () => { it('drops unattested zaps and keeps attested zaps and payment notifications', () => { const attested = new Set([ZAP_ID, PAYMENT_ID]) diff --git a/src/lib/superchat.ts b/src/lib/superchat.ts index bd552615..4ebc0694 100644 --- a/src/lib/superchat.ts +++ b/src/lib/superchat.ts @@ -132,6 +132,16 @@ export function isSuperchatKind(kind: number): boolean { return kind === kinds.Zap || kind === ExtendedKind.ZAP_RECEIPT || kind === ExtendedKind.PAYMENT_NOTIFICATION } +/** Kinds that may be `#e` parents in the thread nested-reply relay pass (replies to zaps were missing). */ +export function isNestedThreadReplyParentKind(kind: number): boolean { + return ( + kind === kinds.ShortTextNote || + kind === ExtendedKind.COMMENT || + kind === ExtendedKind.VOICE_COMMENT || + isSuperchatKind(kind) + ) +} + /** Recipient pubkey for a kind 9735 or 9740 payment the user may attest to. */ export function getSuperchatPaymentRecipientPubkey(event: Event): string | null { if (event.kind === ExtendedKind.PAYMENT_NOTIFICATION) { diff --git a/src/lib/thread-reply-root-match.test.ts b/src/lib/thread-reply-root-match.test.ts index 20284ab7..d70408ba 100644 --- a/src/lib/thread-reply-root-match.test.ts +++ b/src/lib/thread-reply-root-match.test.ts @@ -1,5 +1,7 @@ +import { ExtendedKind } from '@/constants' import { describe, expect, it, vi } from 'vitest' import type { Event } from 'nostr-tools' +import { kinds } from 'nostr-tools' const { peekSessionCachedEvent } = vi.hoisted(() => ({ peekSessionCachedEvent: vi.fn() @@ -54,4 +56,59 @@ describe('eventReplyMatchesThreadRoot', () => { expect(eventReplyMatchesThreadRoot(child, { type: 'E', id: rootId, pubkey: author })).toBe(true) }) + + it('accepts a reply whose parent is a zap receipt on the thread root', () => { + const zapId = '3'.repeat(64) + const zapReceipt = event({ + id: zapId, + kind: kinds.Zap, + tags: [ + ['e', rootId], + ['p', author] + ] + }) + const replyToZap = event({ + id: childId, + tags: [ + ['e', zapId, '', 'reply'], + ['p', author] + ] + }) + + peekSessionCachedEvent.mockImplementation((id: string) => { + if (id === zapId) return zapReceipt + return undefined + }) + + expect(eventReplyMatchesThreadRoot(replyToZap, { type: 'E', id: rootId, pubkey: author })).toBe(true) + }) + + it('accepts a reply whose parent is a kind 9740 superchat on the thread root', () => { + const superchatId = '4'.repeat(64) + const superchat = event({ + id: superchatId, + kind: ExtendedKind.PAYMENT_NOTIFICATION, + tags: [ + ['e', rootId], + ['p', author], + ['amount', '333000'] + ] + }) + const replyToSuperchat = event({ + id: childId, + tags: [ + ['e', superchatId, '', 'reply'], + ['p', author] + ] + }) + + peekSessionCachedEvent.mockImplementation((id: string) => { + if (id === superchatId) return superchat + return undefined + }) + + expect(eventReplyMatchesThreadRoot(replyToSuperchat, { type: 'E', id: rootId, pubkey: author })).toBe( + true + ) + }) }) diff --git a/src/lib/thread-reply-root-match.ts b/src/lib/thread-reply-root-match.ts index fdef5719..dd98fab1 100644 --- a/src/lib/thread-reply-root-match.ts +++ b/src/lib/thread-reply-root-match.ts @@ -8,6 +8,8 @@ import { resolveDeclaredThreadRootEventHex } from '@/lib/event' import { getZapInfoFromEvent } from '@/lib/event-metadata' +import { getPaymentNotificationInfo, isSuperchatKind } from '@/lib/superchat' +import { ExtendedKind } from '@/constants' import { getFirstHexEventIdFromETags } from '@/lib/tag' import { canonicalizeRssArticleUrl, @@ -64,8 +66,8 @@ function hexNoteParticipatesInThread( return false } -/** Reply whose direct parent is a zap receipt whose zapped note is in this thread (OP or nested under OP). */ -function replyParentIsZapToThreadHex( +/** Reply whose direct parent is a zap / superchat whose target note is in this thread. */ +function replyParentIsSuperchatToThreadHex( reply: Event, rootHexLower: string, localByHex?: ReadonlyMap @@ -75,10 +77,17 @@ function replyParentIsZapToThreadHex( const pl = parentHex.toLowerCase() if (pl === rootHexLower) return false const parentEv = peekThreadEvent(pl, localByHex) - if (!parentEv || parentEv.kind !== kinds.Zap) return false - const zapped = getZapInfoFromEvent(parentEv)?.originalEventId - if (!zapped || !/^[0-9a-f]{64}$/i.test(zapped)) return false - return hexNoteParticipatesInThread(zapped.toLowerCase(), rootHexLower, localByHex) + if (!parentEv || !isSuperchatKind(parentEv.kind)) return false + + if (parentEv.kind === kinds.Zap || parentEv.kind === ExtendedKind.ZAP_RECEIPT) { + const zapped = getZapInfoFromEvent(parentEv)?.originalEventId + if (!zapped || !/^[0-9a-f]{64}$/i.test(zapped)) return false + return hexNoteParticipatesInThread(zapped.toLowerCase(), rootHexLower, localByHex) + } + + const ref = getPaymentNotificationInfo(parentEv)?.referencedEventId + if (!ref || !/^[0-9a-f]{64}$/i.test(ref)) return false + return hexNoteParticipatesInThread(ref.toLowerCase(), rootHexLower, localByHex) } function reactionTargetNoteHex(reaction: Event): string | undefined { @@ -160,7 +169,7 @@ export function eventReplyMatchesThreadRoot( if (evtRootHex && resolveDeclaredThreadRootEventHex(evtRootHex) === rid) return true const parentHex = getParentEventHexId(evt)?.toLowerCase() if (parentHex && hexNoteParticipatesInThread(parentHex, rid, localByHex)) return true - if (replyParentIsZapToThreadHex(evt, rid, localByHex)) return true + if (replyParentIsSuperchatToThreadHex(evt, rid, localByHex)) return true if (replyParentIsReactionToThreadHex(evt, rid, localByHex)) return true return kind1QuotesThreadRoot(evt, root) }