Browse Source

fix superchat threading

imwald
Silberengel 3 weeks ago
parent
commit
ab0989423f
  1. 19
      src/components/ReplyNoteList/index.tsx
  2. 11
      src/lib/superchat.test.ts
  3. 10
      src/lib/superchat.ts
  4. 57
      src/lib/thread-reply-root-match.test.ts
  5. 23
      src/lib/thread-reply-root-match.ts

19
src/components/ReplyNoteList/index.tsx

@ -19,6 +19,7 @@ import {
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { import {
getPaymentAttestationTargetId, getPaymentAttestationTargetId,
isNestedThreadReplyParentKind,
partitionAttestedSuperchats, partitionAttestedSuperchats,
replyFeedSuperchatsFirst replyFeedSuperchatsFirst
} from '@/lib/superchat' } from '@/lib/superchat'
@ -516,6 +517,12 @@ function ReplyNoteList({
const processedEventIds = new Set<string>() // Prevent infinite loops const processedEventIds = new Set<string>() // Prevent infinite loops
let iterationCount = 0 let iterationCount = 0
const MAX_ITERATIONS = 10 // Prevent infinite loops const MAX_ITERATIONS = 10 // Prevent infinite loops
const threadWalkFromRepliesMap = new Map<string, NEvent>()
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) { while (parentEventKeys.length > 0 && iterationCount < MAX_ITERATIONS) {
iterationCount++ iterationCount++
@ -533,10 +540,16 @@ function ReplyNoteList({
) { ) {
return return
} }
if (rootInfo && !replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot)) return if (
rootInfo &&
!replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot, threadWalkFromRepliesMap)
) {
return
}
replyIdSet.add(evt.id) replyIdSet.add(evt.id)
replyEvents.push(evt) 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. // Include reactions (and every other kind) so BFS can find notes keyed under reaction / zap ids.
@ -1353,7 +1366,7 @@ function ReplyNoteList({
kinds.ShortTextNote kinds.ShortTextNote
] ]
const parentIds = regularReplies const parentIds = regularReplies
.filter((evt) => commentKinds.includes(evt.kind)) .filter((evt) => isNestedThreadReplyParentKind(evt.kind))
.map((evt) => evt.id) .map((evt) => evt.id)
if (parentIds.length > 0) { if (parentIds.length > 0) {
const nestedAccum: NEvent[] = [] const nestedAccum: NEvent[] = []
@ -1414,7 +1427,7 @@ function ReplyNoteList({
[ [
focusedParentId, focusedParentId,
...regularReplies ...regularReplies
.filter((evt) => commentKindsNested.includes(evt.kind)) .filter((evt) => isNestedThreadReplyParentKind(evt.kind))
.map((evt) => evt.id) .map((evt) => evt.id)
].filter(Boolean) as string[] ].filter(Boolean) as string[]
) )

11
src/lib/superchat.test.ts

@ -9,6 +9,7 @@ import {
canUserAttestSuperchatPayment, canUserAttestSuperchatPayment,
isProfileWallPaymentNotification, isProfileWallPaymentNotification,
isProfileWallZapReceipt, isProfileWallZapReceipt,
isNestedThreadReplyParentKind,
partitionAttestedSuperchats partitionAttestedSuperchats
} from '@/lib/superchat' } from '@/lib/superchat'
import { parsePaytoTagType } from '@/lib/payto' 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', () => { describe('partitionAttestedSuperchats', () => {
it('drops unattested zaps and keeps attested zaps and payment notifications', () => { it('drops unattested zaps and keeps attested zaps and payment notifications', () => {
const attested = new Set([ZAP_ID, PAYMENT_ID]) const attested = new Set([ZAP_ID, PAYMENT_ID])

10
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 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. */ /** Recipient pubkey for a kind 9735 or 9740 payment the user may attest to. */
export function getSuperchatPaymentRecipientPubkey(event: Event): string | null { export function getSuperchatPaymentRecipientPubkey(event: Event): string | null {
if (event.kind === ExtendedKind.PAYMENT_NOTIFICATION) { if (event.kind === ExtendedKind.PAYMENT_NOTIFICATION) {

57
src/lib/thread-reply-root-match.test.ts

@ -1,5 +1,7 @@
import { ExtendedKind } from '@/constants'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { kinds } from 'nostr-tools'
const { peekSessionCachedEvent } = vi.hoisted(() => ({ const { peekSessionCachedEvent } = vi.hoisted(() => ({
peekSessionCachedEvent: vi.fn() peekSessionCachedEvent: vi.fn()
@ -54,4 +56,59 @@ describe('eventReplyMatchesThreadRoot', () => {
expect(eventReplyMatchesThreadRoot(child, { type: 'E', id: rootId, pubkey: author })).toBe(true) 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
)
})
}) })

23
src/lib/thread-reply-root-match.ts

@ -8,6 +8,8 @@ import {
resolveDeclaredThreadRootEventHex resolveDeclaredThreadRootEventHex
} from '@/lib/event' } from '@/lib/event'
import { getZapInfoFromEvent } from '@/lib/event-metadata' import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { getPaymentNotificationInfo, isSuperchatKind } from '@/lib/superchat'
import { ExtendedKind } from '@/constants'
import { getFirstHexEventIdFromETags } from '@/lib/tag' import { getFirstHexEventIdFromETags } from '@/lib/tag'
import { import {
canonicalizeRssArticleUrl, canonicalizeRssArticleUrl,
@ -64,8 +66,8 @@ function hexNoteParticipatesInThread(
return false return false
} }
/** Reply whose direct parent is a zap receipt whose zapped note is in this thread (OP or nested under OP). */ /** Reply whose direct parent is a zap / superchat whose target note is in this thread. */
function replyParentIsZapToThreadHex( function replyParentIsSuperchatToThreadHex(
reply: Event, reply: Event,
rootHexLower: string, rootHexLower: string,
localByHex?: ReadonlyMap<string, Event> localByHex?: ReadonlyMap<string, Event>
@ -75,10 +77,17 @@ function replyParentIsZapToThreadHex(
const pl = parentHex.toLowerCase() const pl = parentHex.toLowerCase()
if (pl === rootHexLower) return false if (pl === rootHexLower) return false
const parentEv = peekThreadEvent(pl, localByHex) const parentEv = peekThreadEvent(pl, localByHex)
if (!parentEv || parentEv.kind !== kinds.Zap) return false if (!parentEv || !isSuperchatKind(parentEv.kind)) return false
const zapped = getZapInfoFromEvent(parentEv)?.originalEventId
if (!zapped || !/^[0-9a-f]{64}$/i.test(zapped)) return false if (parentEv.kind === kinds.Zap || parentEv.kind === ExtendedKind.ZAP_RECEIPT) {
return hexNoteParticipatesInThread(zapped.toLowerCase(), rootHexLower, localByHex) 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 { function reactionTargetNoteHex(reaction: Event): string | undefined {
@ -160,7 +169,7 @@ export function eventReplyMatchesThreadRoot(
if (evtRootHex && resolveDeclaredThreadRootEventHex(evtRootHex) === rid) return true if (evtRootHex && resolveDeclaredThreadRootEventHex(evtRootHex) === rid) return true
const parentHex = getParentEventHexId(evt)?.toLowerCase() const parentHex = getParentEventHexId(evt)?.toLowerCase()
if (parentHex && hexNoteParticipatesInThread(parentHex, rid, localByHex)) return true 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 if (replyParentIsReactionToThreadHex(evt, rid, localByHex)) return true
return kind1QuotesThreadRoot(evt, root) return kind1QuotesThreadRoot(evt, root)
} }

Loading…
Cancel
Save