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 { @@ -19,6 +19,7 @@ import {
import logger from '@/lib/logger'
import {
getPaymentAttestationTargetId,
isNestedThreadReplyParentKind,
partitionAttestedSuperchats,
replyFeedSuperchatsFirst
} from '@/lib/superchat'
@ -516,6 +517,12 @@ function ReplyNoteList({ @@ -516,6 +517,12 @@ function ReplyNoteList({
const processedEventIds = new Set<string>() // Prevent infinite loops
let iterationCount = 0
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) {
iterationCount++
@ -533,10 +540,16 @@ function ReplyNoteList({ @@ -533,10 +540,16 @@ 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.
@ -1353,7 +1366,7 @@ function ReplyNoteList({ @@ -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({ @@ -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[]
)

11
src/lib/superchat.test.ts

@ -9,6 +9,7 @@ import { @@ -9,6 +9,7 @@ import {
canUserAttestSuperchatPayment,
isProfileWallPaymentNotification,
isProfileWallZapReceipt,
isNestedThreadReplyParentKind,
partitionAttestedSuperchats
} from '@/lib/superchat'
import { parsePaytoTagType } from '@/lib/payto'
@ -109,6 +110,16 @@ describe('buildAttestedPaymentIdSet', () => { @@ -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])

10
src/lib/superchat.ts

@ -132,6 +132,16 @@ export function isSuperchatKind(kind: number): boolean { @@ -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) {

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

@ -1,5 +1,7 @@ @@ -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', () => { @@ -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
)
})
})

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

@ -8,6 +8,8 @@ import { @@ -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( @@ -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<string, Event>
@ -75,10 +77,17 @@ function replyParentIsZapToThreadHex( @@ -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( @@ -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)
}

Loading…
Cancel
Save