Browse Source

bug-fix superchats in threads

imwald
Silberengel 3 weeks ago
parent
commit
c5ab685f76
  1. 614
      src/components/ReplyNoteList/index.tsx
  2. 293
      src/components/ReplyNoteList/reply-list-utils.ts
  3. 10
      src/components/ReplyNoteList/types.ts
  4. 126
      src/components/ReplyNoteList/useThreadAttestedPayments.ts
  5. 101
      src/components/ReplyNoteList/useThreadRootInfo.ts
  6. 4
      src/hooks/useFetchEvent.tsx
  7. 4
      src/hooks/useFetchThreadContextEvent.tsx
  8. 87
      src/hooks/useProfileWall.tsx
  9. 15
      src/hooks/useReplyIngress.ts
  10. 6
      src/lib/op-reference-tags.ts
  11. 52
      src/lib/payment-attestation-cache.test.ts
  12. 111
      src/lib/payment-attestation-cache.ts
  13. 113
      src/lib/reply-index.ts
  14. 35
      src/lib/superchat.test.ts
  15. 40
      src/lib/superchat.ts
  16. 5
      src/pages/secondary/NotePage/index.tsx
  17. 118
      src/providers/ReplyProvider.tsx
  18. 42
      src/providers/ThreadReplyProvider.tsx

614
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 { isDiscussionDownvoteEmoji, isDiscussionUpvoteEmoji } from '@/lib/discussion-votes'
import { import {
canonicalizeRssArticleUrl, canonicalizeRssArticleUrl,
@ -7,19 +7,15 @@ import {
import { import {
getParentETag, getParentETag,
getReplaceableCoordinateFromEvent, getReplaceableCoordinateFromEvent,
getRootATag,
getRootETag,
isNip56ReportEvent,
isMentioningMutedUsers, isMentioningMutedUsers,
isNip18RepostKind, isNip18RepostKind,
isReplaceableEvent, isReplaceableEvent
kind1QuotesThreadRoot,
resolveDeclaredThreadRootEventHex
} from '@/lib/event' } from '@/lib/event'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { import {
getPaymentAttestationTargetId, collectAttestedSuperchatsFromRepliesMap,
isNestedThreadReplyParentKind, isNestedThreadReplyParentKind,
isSuperchatKind,
partitionAttestedSuperchats, partitionAttestedSuperchats,
replyFeedSuperchatsFirst replyFeedSuperchatsFirst
} from '@/lib/superchat' } from '@/lib/superchat'
@ -29,12 +25,12 @@ import { shouldHideThreadResponseEvent } from '@/lib/thread-response-filter'
import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { generateBech32IdFromETag } from '@/lib/tag' import { generateBech32IdFromETag } from '@/lib/tag'
import { useSmartNoteNavigation, useSecondaryPage } from '@/PageManager' import { useSmartNoteNavigation } from '@/PageManager'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useZap } from '@/providers/ZapProvider' import { useZap } from '@/providers/ZapProvider'
import { useReply } from '@/providers/ReplyProvider' import { useReplyIngress } from '@/hooks/useReplyIngress'
import { useUserTrust } from '@/contexts/user-trust-context' import { useUserTrust } from '@/contexts/user-trust-context'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' 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 { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder'
import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal'
import { buildThreadInteractionFilters, buildThreadSuperchatPriorityFilters } from '@/lib/thread-interaction-req' import { buildThreadInteractionFilters, buildThreadSuperchatPriorityFilters } from '@/lib/thread-interaction-req'
import {
resolveAttestedPaymentIdSet
} from '@/lib/payment-attestation-cache'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' 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 { 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 { Filter, Event as NEvent, kinds } from 'nostr-tools'
import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { TFunction } from 'i18next'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { LoadingBar } from '../LoadingBar' import { LoadingBar } from '../LoadingBar'
import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote' import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote'
import ThreadQuoteBacklink, { BacklinkAvatarStrip } from './ThreadQuoteBacklink' import ThreadQuoteBacklink, { BacklinkAvatarStrip } from './ThreadQuoteBacklink'
import {
type TRootInfo = MAX_PARENT_IDS_PER_NESTED_REQ,
| { type: 'E'; id: string; pubkey: string } THREAD_PROFILE_BATCH_DEBOUNCE_MS,
| { type: 'A'; id: string; eventId: string; pubkey: string; relay?: string } THREAD_PROFILE_CHUNK,
| { type: 'I'; id: string } THREAD_REPLY_LIMIT,
THREAD_REPLY_SHOW_COUNT
const LIMIT = 200 } from './types'
const SHOW_COUNT = 10 import {
/** Some relays cap `#e` array length; chunk parent-id batches for nested-thread REQs. */ backlinkRunSectionClass,
const MAX_PARENT_IDS_PER_NESTED_REQ = 64 buildVisibleBacklinkRows,
/** Short debounce so thread / detail headers populate avatars quickly after events arrive. */ EA_THREAD_TAIL_REFERENCE_KINDS,
const THREAD_PROFILE_BATCH_DEBOUNCE_MS = 120 fetchPaymentAttestationsForRecipient,
const THREAD_PROFILE_CHUNK = 80 isEaThreadTailBacklinkCandidate,
isPollVoteKind,
async function hydrateAttestedSuperchatTargets( isWebThreadTailKind,
attestedIds: ReadonlySet<string>, mergeFetchedKind7ReactionsIntoRootNoteStats,
relayUrls: string[] moveReportsToEndPreserveOrder,
): Promise<NEvent[]> { partitionAndSortBacklinkTail,
const ids = [...attestedIds].filter((id) => /^[0-9a-f]{64}$/i.test(id)) replyFeedZapsFirst,
if (ids.length === 0) return [] replyIdPresentInRepliesMap,
replyMatchesThreadForList,
const byId = new Map<string, NEvent>() threadBacklinkRelationLabel
try { } from './reply-list-utils'
const local = await client.getLocalFeedEvents( import { useThreadRootInfo } from './useThreadRootInfo'
[{ urls: [], filter: { ids, limit: ids.length } }], import { useThreadAttestedPayments } from './useThreadAttestedPayments'
{ 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<NEvent[]> {
const filter: Filter = {
kinds: [ExtendedKind.PAYMENT_ATTESTATION],
authors: [recipientPubkey],
limit: 500
}
const byId = new Map<string, NEvent>()
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<string>
): 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<number>(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<string, { events: NEvent[]; eventIdSet: Set<string> }>,
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<string, NEvent>
): 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<NEvent, 'kind'>): 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)
}
function ReplyNoteList({ function ReplyNoteList({
index, index: _pageIndex,
event, event,
sort = 'oldest', sort = 'oldest',
showQuotes = true, showQuotes = true,
@ -420,22 +109,28 @@ function ReplyNoteList({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigation() const { navigateToNote } = useSmartNoteNavigation()
const { currentIndex } = useSecondaryPage()
const { hideUntrustedInteractions, isUserTrusted, isTrustLoaded } = useUserTrust() const { hideUntrustedInteractions, isUserTrusted, isTrustLoaded } = useUserTrust()
const noteStats = useNoteStatsById(event.id) const noteStats = useNoteStatsById(event.id)
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
const { pubkey: userPubkey } = useNostr() const { pubkey: userPubkey } = useNostr()
const { zapReplyThreshold } = useZap() const { zapReplyThreshold } = useZap()
const [attestedPaymentIds, setAttestedPaymentIds] = useState<Set<string>>(() => new Set())
const threadRelayUrlsRef = useRef<string[]>([])
const { blockedRelays, favoriteRelays } = useFavoriteRelays() const { blockedRelays, favoriteRelays } = useFavoriteRelays()
const { relayUrls: browsingRelayUrls } = useCurrentRelays() const { relayUrls: browsingRelayUrls } = useCurrentRelays()
const relayAuthoritativeRead = const relayAuthoritativeRead =
singleRelayAuthoritativeRead ?? browsingRelayUrls.length === 1 singleRelayAuthoritativeRead ?? browsingRelayUrls.length === 1
const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(undefined) const rootInfo = useThreadRootInfo(event)
const { repliesMap, addReplies } = useReply() const { repliesMap, addReplies } = useReplyIngress()
const isDiscussionRoot = event.kind === ExtendedKind.DISCUSSION const isDiscussionRoot = event.kind === ExtendedKind.DISCUSSION
const threadRelayUrlsRef = useRef<string[]>([])
const replyFetchGenRef = useRef(0)
const { attestedPaymentIds, applyAttestedSuperchatWave } = useThreadAttestedPayments(
event.pubkey,
addReplies,
threadRelayUrlsRef,
browsingRelayUrls,
replyFetchGenRef
)
const replyDuplicateWebPreviewHints = useMemo(() => { const replyDuplicateWebPreviewHints = useMemo(() => {
const out: string[] = [...(duplicateWebPreviewCleanedUrlHints ?? [])] const out: string[] = [...(duplicateWebPreviewCleanedUrlHints ?? [])]
@ -443,54 +138,7 @@ function ReplyNoteList({
return out.length ? out : undefined return out.length ? out : undefined
}, [duplicateWebPreviewCleanedUrlHints, rootInfo]) }, [duplicateWebPreviewCleanedUrlHints, rootInfo])
useEffect(() => { const replies: NEvent[] = useMemo(() => {
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<NEvent>
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 replyIdSet = new Set<string>() const replyIdSet = new Set<string>()
const replyEvents: NEvent[] = [] const replyEvents: NEvent[] = []
const currentEventKey = isReplaceableEvent(event.kind) const currentEventKey = isReplaceableEvent(event.kind)
@ -564,8 +212,33 @@ function ReplyNoteList({
if (iterationCount >= MAX_ITERATIONS) { if (iterationCount >= MAX_ITERATIONS) {
logger.warn('ReplyNoteList: Maximum iterations reached, possible circular reference in replies') 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( const { superchats, rest: nonZaps } = partitionAttestedSuperchats(
replyEvents, replyEvents,
@ -860,83 +533,11 @@ function ReplyNoteList({
]) ])
const [loading, setLoading] = useState<boolean>(false) const [loading, setLoading] = useState<boolean>(false)
const [showCount, setShowCount] = useState(SHOW_COUNT) const [showCount, setShowCount] = useState(THREAD_REPLY_SHOW_COUNT)
const [highlightReplyId, setHighlightReplyId] = useState<string | undefined>(undefined) const [highlightReplyId, setHighlightReplyId] = useState<string | undefined>(undefined)
const replyRefs = useRef<Record<string, HTMLDivElement | null>>({}) const replyRefs = useRef<Record<string, HTMLDivElement | null>>({})
const bottomRef = useRef<HTMLDivElement | null>(null) const bottomRef = useRef<HTMLDivElement | null>(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. */ /** 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<Set<string>>(new Set()) const rssStatsHydratedReplyIdsRef = useRef<Set<string>>(new Set())
@ -1121,14 +722,8 @@ function ReplyNoteList({
} }
}, [rootInfo, event, onNewReply, isDiscussionRoot]) }, [rootInfo, event, onNewReply, isDiscussionRoot])
const replyFetchGenRef = useRef(0)
useEffect(() => { useEffect(() => {
if (!rootInfo) return 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 fetchGeneration = ++replyFetchGenRef.current
const init = async () => { const init = async () => {
@ -1204,7 +799,7 @@ function ReplyNoteList({
const filters = buildThreadInteractionFilters({ const filters = buildThreadInteractionFilters({
root: rootInfo, root: rootInfo,
opEventKind: event.kind, opEventKind: event.kind,
limit: LIMIT limit: THREAD_REPLY_LIMIT
}) })
const relayUrlsForThreadReq = sanitizeRelayUrlsForFetch( const relayUrlsForThreadReq = sanitizeRelayUrlsForFetch(
@ -1233,7 +828,7 @@ function ReplyNoteList({
const superchatFilters = buildThreadSuperchatPriorityFilters({ const superchatFilters = buildThreadSuperchatPriorityFilters({
root: rootInfo, root: rootInfo,
opEventKind: event.kind, opEventKind: event.kind,
limit: LIMIT limit: THREAD_REPLY_LIMIT
}) })
if (superchatFilters.length > 0) { if (superchatFilters.length > 0) {
void queryService void queryService
@ -1251,20 +846,27 @@ function ReplyNoteList({
const attestationTask = recipientPubkey const attestationTask = recipientPubkey
? fetchPaymentAttestationsForRecipient(recipientPubkey, relayUrlsForThreadReq, { ? fetchPaymentAttestationsForRecipient(recipientPubkey, relayUrlsForThreadReq, {
foreground: statsForeground foreground: true
}) })
: Promise.resolve([] as NEvent[]) : Promise.resolve([] as NEvent[])
const [allReplies, relayAttestations] = await Promise.all([ void attestationTask.then((relayAttestations) => {
queryService.fetchEvents(relayUrlsForThreadReq, filters, { if (fetchGeneration !== replyFetchGenRef.current) return
onevent: streamThreadReply, void applyAttestedSuperchatWave(
foreground: true, relayAttestations,
firstRelayResultGraceMs: 900, relayUrlsForThreadReq,
globalTimeout: 12_000, fetchGeneration,
relayOpSource: 'ReplyNoteList.thread' true
}), )
attestationTask })
])
const allReplies = await queryService.fetchEvents(relayUrlsForThreadReq, filters, {
onevent: streamThreadReply,
foreground: true,
firstRelayResultGraceMs: 900,
globalTimeout: 12_000,
relayOpSource: 'ReplyNoteList.thread'
})
if (fetchGeneration !== replyFetchGenRef.current) return if (fetchGeneration !== replyFetchGenRef.current) return
@ -1303,23 +905,6 @@ function ReplyNoteList({
const repliesForStatsPrime = mergedForUi const repliesForStatsPrime = mergedForUi
addReplies(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 const statsBatch = mergedCachedReplies !== null && mergedCachedReplies.length > 0 ? mergedCachedReplies : regularReplies
if (statsBatch.length > 0) { if (statsBatch.length > 0) {
noteStatsService.updateNoteStatsByEvents(statsBatch, event.pubkey, { 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) { 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 idChunk = parentIds.slice(off, off + MAX_PARENT_IDS_PER_NESTED_REQ)
const nestedFilters: Filter[] = [ const nestedFilters: Filter[] = [
{ '#e': idChunk, kinds: commentKinds, limit: LIMIT } { '#e': idChunk, kinds: commentKinds, limit: THREAD_REPLY_LIMIT }
] ]
const nestedReplies = await queryService.fetchEvents(relayUrlsForThreadReq, nestedFilters, { const nestedReplies = await queryService.fetchEvents(relayUrlsForThreadReq, nestedFilters, {
onevent: (evt: NEvent) => { onevent: (evt: NEvent) => {
@ -1440,11 +1025,11 @@ function ReplyNoteList({
for (let off = 0; off < parentIdsNested.length; off += MAX_PARENT_IDS_PER_NESTED_REQ) { 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 idChunk = parentIdsNested.slice(off, off + MAX_PARENT_IDS_PER_NESTED_REQ)
const nestedFilters: Filter[] = [ const nestedFilters: Filter[] = [
{ '#e': idChunk, kinds: commentKindsNested, limit: LIMIT }, { '#e': idChunk, kinds: commentKindsNested, limit: THREAD_REPLY_LIMIT },
{ {
'#E': idChunk, '#E': idChunk,
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit: LIMIT limit: THREAD_REPLY_LIMIT
} }
] ]
const nestedReplies = await queryService.fetchEvents(relayUrlsForThreadReq, nestedFilters, { const nestedReplies = await queryService.fetchEvents(relayUrlsForThreadReq, nestedFilters, {
@ -1490,8 +1075,6 @@ function ReplyNoteList({
init() init()
}, [ }, [
rootInfo, rootInfo,
currentIndex,
index,
userPubkey, userPubkey,
event.id, event.id,
event.kind, event.kind,
@ -1503,7 +1086,8 @@ function ReplyNoteList({
mutePubkeySet, mutePubkeySet,
hideContentMentioningMutedUsers, hideContentMentioningMutedUsers,
isDiscussionRoot, isDiscussionRoot,
statsForeground statsForeground,
applyAttestedSuperchatWave
]) ])
useEffect(() => { useEffect(() => {
@ -1515,7 +1099,7 @@ function ReplyNoteList({
const observerInstance = new IntersectionObserver((entries) => { const observerInstance = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && showCount < mergedFeed.length) { if (entries[0].isIntersecting && showCount < mergedFeed.length) {
setShowCount((prev) => prev + SHOW_COUNT) setShowCount((prev) => prev + THREAD_REPLY_SHOW_COUNT)
} }
}, options) }, options)
@ -1567,7 +1151,7 @@ function ReplyNoteList({
} }
const isQuote = quoteUiIdSet.has(item.id) const isQuote = quoteUiIdSet.has(item.id)
// Attested superchats are public payment records — always show when they passed mute filters. // 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. // Backlink rows (quotes, highlights, …): show even when author is not in the trust list.
if (isQuote) return true if (isQuote) return true
if (isTrustLoaded && hideUntrustedInteractions && !isUserTrusted(item.pubkey)) { if (isTrustLoaded && hideUntrustedInteractions && !isUserTrusted(item.pubkey)) {

293
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<NEvent[]> {
const filter: Filter = {
kinds: [ExtendedKind.PAYMENT_ATTESTATION],
authors: [recipientPubkey],
limit: 500
}
const byId = new Map<string, NEvent>()
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<string>
): 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<number>(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<string, { events: NEvent[]; eventIdSet: Set<string> }>,
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<string, NEvent>
): 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<NEvent, 'kind'>): 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)
}

10
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

126
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<string[]>,
browsingRelayUrls: string[],
replyFetchGenRef: MutableRefObject<number>
) {
const [attestedPaymentIds, setAttestedPaymentIds] = useState<Set<string>>(() =>
recipientPubkey ? resolveAttestedPaymentIdSetSync(recipientPubkey) : new Set()
)
const mergeAttestedPaymentIds = useCallback((incoming: ReadonlySet<string>) => {
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<NEvent>
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 }
}

101
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<TRootInfo | undefined>(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
}

4
src/hooks/useFetchEvent.tsx

@ -1,6 +1,6 @@
import { getNoteBech32Id } from '@/lib/event' import { getNoteBech32Id } from '@/lib/event'
import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useReply } from '@/providers/ReplyProvider' import { useReplyIngress } from '@/hooks/useReplyIngress'
import { eventService } from '@/services/client.service' import { eventService } from '@/services/client.service'
import { navigationEventStore } from '@/services/navigation-event-store' import { navigationEventStore } from '@/services/navigation-event-store'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
@ -12,7 +12,7 @@ export function useFetchEvent(
fetchOpts?: { relayHints?: string[] } fetchOpts?: { relayHints?: string[] }
) { ) {
const { isEventDeleted } = useDeletedEvent() const { isEventDeleted } = useDeletedEvent()
const { addReplies } = useReply() const { addReplies } = useReplyIngress()
const [error, setError] = useState<Error | null>(null) const [error, setError] = useState<Error | null>(null)
const [event, setEvent] = useState<Event | undefined>(initialEvent) const [event, setEvent] = useState<Event | undefined>(initialEvent)
const [isFetching, setIsFetching] = useState(!initialEvent) const [isFetching, setIsFetching] = useState(!initialEvent)

4
src/hooks/useFetchThreadContextEvent.tsx

@ -4,7 +4,7 @@ import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useReply } from '@/providers/ReplyProvider' import { useReplyIngress } from '@/hooks/useReplyIngress'
import { getNoteBech32Id, getParentETag, getRootETag } from '@/lib/event' import { getNoteBech32Id, getParentETag, getRootETag } from '@/lib/event'
import { buildThreadContextFetchRelayUrls } from '@/lib/thread-context-relays' import { buildThreadContextFetchRelayUrls } from '@/lib/thread-context-relays'
import client, { eventService } from '@/services/client.service' import client, { eventService } from '@/services/client.service'
@ -50,7 +50,7 @@ export function useFetchThreadContextEvent(
const { pubkey: viewerPubkey } = useNostr() const { pubkey: viewerPubkey } = useNostr()
const { blockedRelays } = useFavoriteRelays() const { blockedRelays } = useFavoriteRelays()
const { isEventDeleted } = useDeletedEvent() const { isEventDeleted } = useDeletedEvent()
const { addReplies } = useReply() const { addReplies } = useReplyIngress()
const [error, setError] = useState<Error | null>(null) const [error, setError] = useState<Error | null>(null)
const [event, setEvent] = useState<Event | undefined>(initialEvent) const [event, setEvent] = useState<Event | undefined>(initialEvent)
const [isFetching, setIsFetching] = useState(!initialEvent) const [isFetching, setIsFetching] = useState(!initialEvent)

87
src/hooks/useProfileWall.tsx

@ -19,8 +19,17 @@ import {
type ResolvedProfileBadge type ResolvedProfileBadge
} from '@/lib/nip58-profile-badges' } from '@/lib/nip58-profile-badges'
import { isDirectProfileWallComment } from '@/lib/profile-wall-comments' import { isDirectProfileWallComment } from '@/lib/profile-wall-comments'
import { filterAttestedProfileWallSuperchats, getPaymentAttestationTargetId } from '@/lib/superchat' import {
import { resolveAttestedPaymentIdSet } from '@/lib/payment-attestation-cache' buildAttestedPaymentIdSet,
filterAttestedProfileWallSuperchats,
getPaymentAttestationTargetId
} from '@/lib/superchat'
import {
hydrateAttestedSuperchatTargetEvents,
mergeAttestedPaymentIdSets,
resolveAttestedPaymentIdSet,
resolveAttestedPaymentIdSetSync
} from '@/lib/payment-attestation-cache'
import { isValidPubkey, userIdToPubkey } from '@/lib/pubkey' import { isValidPubkey, userIdToPubkey } from '@/lib/pubkey'
import { normalizeAnyRelayUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
@ -81,52 +90,6 @@ async function fetchBadgeDefinitionOnRelays(
const CACHE_DURATION = 5 * 60 * 1000 const CACHE_DURATION = 5 * 60 * 1000
async function hydrateProfileWallSuperchatTargets(
attestedIds: ReadonlySet<string>,
relayUrls: string[]
): Promise<Event[]> {
const ids = [...attestedIds].filter((id) => /^[0-9a-f]{64}$/i.test(id))
if (ids.length === 0) return []
const byId = new Map<string, Event>()
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 { function normalizeProfileEventId(profileEventId: string | undefined): string | undefined {
const id = profileEventId?.trim().toLowerCase() const id = profileEventId?.trim().toLowerCase()
return id && /^[0-9a-f]{64}$/.test(id) ? id : undefined 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 attestations = [...pool.values()].filter((e) => e.kind === ExtendedKind.PAYMENT_ATTESTATION)
const attestedIds = await resolveAttestedPaymentIdSet(pkNorm, attestations) const attestedIds = mergeAttestedPaymentIdSets(
for (const e of await hydrateProfileWallSuperchatTargets(attestedIds, [])) { 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) pool.set(e.id, e)
} }
@ -235,7 +205,7 @@ async function hydrateProfileWallSuperchatsFromLocalCache(
attestations, attestations,
pkNorm, pkNorm,
profileId, profileId,
attestedIds fullAttestedIds
) )
} }
@ -587,8 +557,19 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
const attestations = [...pool.values()].filter( const attestations = [...pool.values()].filter(
(e) => e.kind === ExtendedKind.PAYMENT_ATTESTATION (e) => e.kind === ExtendedKind.PAYMENT_ATTESTATION
) )
const attestedIds = await resolveAttestedPaymentIdSet(pkNorm, attestations) let attestedIds = mergeAttestedPaymentIdSets(
for (const e of await hydrateProfileWallSuperchatTargets(attestedIds, relayUrls)) { 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) pool.set(e.id, e)
} }

15
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 }
}

6
src/lib/op-reference-tags.ts

@ -5,11 +5,11 @@ import {
normalizeReplaceableCoordinateString normalizeReplaceableCoordinateString
} from '@/lib/event' } from '@/lib/event'
import { getZapInfoFromEvent } from '@/lib/event-metadata' import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { isSuperchatKind } from '@/lib/superchat'
import { isRssArticleUrlThreadInteraction } from '@/lib/rss-web-feed' import { isRssArticleUrlThreadInteraction } from '@/lib/rss-web-feed'
import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article' import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article'
import type { TThreadRootRef } from '@/lib/thread-reply-root-match' import type { TThreadRootRef } from '@/lib/thread-reply-root-match'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { kinds } from 'nostr-tools'
const REF_TAG_NAMES = new Set(['e', 'E', 'a', 'A', 'q', 'Q']) 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 (normalizeReplaceableCoordinateString(v) === coordNorm) return true
} }
} }
if (evt.kind === kinds.Zap) { if (isSuperchatKind(evt.kind) && evt.kind !== ExtendedKind.PAYMENT_NOTIFICATION) {
const zapped = getZapInfoFromEvent(evt)?.originalEventId const zapped = getZapInfoFromEvent(evt)?.originalEventId
if (zapped && /^[0-9a-f]{64}$/i.test(zapped) && zapped.toLowerCase() === eventHex) return true if (zapped && /^[0-9a-f]{64}$/i.test(zapped) && zapped.toLowerCase() === eventHex) return true
const coord = getZapInfoFromEvent(evt)?.eventId const coord = getZapInfoFromEvent(evt)?.eventId
@ -52,7 +52,7 @@ export function eventReferencesThreadTarget(evt: Event, root: TThreadRootRef): b
if (!v) continue if (!v) continue
if (/^[0-9a-f]{64}$/i.test(v) && v.toLowerCase() === hex) return true 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 const zapped = getZapInfoFromEvent(evt)?.originalEventId
if (zapped && /^[0-9a-f]{64}$/i.test(zapped) && zapped.toLowerCase() === hex) return true if (zapped && /^[0-9a-f]{64}$/i.test(zapped) && zapped.toLowerCase() === hex) return true
} }

52
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<Event> & Pick<Event, 'kind' | 'tags'>): 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)
})
})

111
src/lib/payment-attestation-cache.ts

@ -58,6 +58,117 @@ export function readKnownAttestedPaymentTargetsSync(recipientPubkey: string): Se
return new Set(readLocalAttestedIds(recipientPubkey)) return new Set(readLocalAttestedIds(recipientPubkey))
} }
export function mergeAttestedPaymentIdSets(
base: ReadonlySet<string>,
incoming: ReadonlySet<string>
): Set<string> {
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<string> {
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<string>): NostrEvent[] {
const out: NostrEvent[] = []
const seen = new Set<string>()
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<string>,
relayUrls: string[],
options: { foreground?: boolean } = {}
): Promise<NostrEvent[]> {
const ids = [...attestedIds].filter((id) => /^[0-9a-f]{64}$/i.test(id))
if (ids.length === 0) return []
const byId = new Map<string, NostrEvent>()
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. */ /** Drop durable local marks that are not backed by a cached kind 9741 attestation. */
export function pruneUnverifiedLocalAttestationMarks(recipientPubkey: string): void { export function pruneUnverifiedLocalAttestationMarks(recipientPubkey: string): void {
const pk = normalizeHexPubkey(recipientPubkey) const pk = normalizeHexPubkey(recipientPubkey)

113
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<string, { events: Event[]; eventIdSet: Set<string> }>
/** 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<string>()
const newReplyEventMap = new Map<string, Event[]>()
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<string>()
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
}

35
src/lib/superchat.test.ts

@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { import {
buildAttestedPaymentIdSet, buildAttestedPaymentIdSet,
collectAttestedSuperchatsFromRepliesMap,
isValidPaymentAttestation,
filterAttestedProfileWallSuperchats, filterAttestedProfileWallSuperchats,
getPaymentNotificationInfo, getPaymentNotificationInfo,
getSuperchatPaytoType, getSuperchatPaytoType,
@ -108,6 +110,39 @@ describe('buildAttestedPaymentIdSet', () => {
}) })
expect(buildAttestedPaymentIdSet([attestation], RECIPIENT).has(PAYMENT_ID)).toBe(true) 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<string>(),
() => true
)
expect(found.map((e) => e.id)).toEqual([PAYMENT_ID])
})
}) })
describe('isNestedThreadReplyParentKind', () => { describe('isNestedThreadReplyParentKind', () => {

40
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 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. */ /** Event ids (lowercase hex) the recipient has attested as received payment. */
export function buildAttestedPaymentIdSet( export function buildAttestedPaymentIdSet(
attestations: Event[], attestations: Event[],
@ -52,10 +62,9 @@ export function buildAttestedPaymentIdSet(
): Set<string> { ): Set<string> {
const out = new Set<string>() const out = new Set<string>()
for (const attestation of attestations) { for (const attestation of attestations) {
if (!hexPubkeysEqual(attestation.pubkey, recipientPubkey)) continue if (!isValidPaymentAttestation(attestation, recipientPubkey)) continue
const targetId = getPaymentAttestationTargetId(attestation) const targetId = getPaymentAttestationTargetId(attestation)
if (!targetId) continue if (targetId) out.add(targetId)
out.add(targetId)
} }
return out 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<string, { events: Event[] }>,
attestedIds: ReadonlySet<string>,
alreadySeen: ReadonlySet<string>,
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( export function partitionAttestedSuperchats(
items: Event[], items: Event[],
attestedIds: Set<string>, attestedIds: Set<string>,

5
src/pages/secondary/NotePage/index.tsx

@ -52,6 +52,7 @@ import {
} from '@/lib/document-meta' } from '@/lib/document-meta'
import NotFound from './NotFound' import NotFound from './NotFound'
import { ThreadProfileBatchProvider } from '@/providers/ThreadProfileBatchProvider' import { ThreadProfileBatchProvider } from '@/providers/ThreadProfileBatchProvider'
import { ThreadReplyProvider } from '@/providers/ThreadReplyProvider'
// Helper function to get event type name (matching WebPreview) // Helper function to get event type name (matching WebPreview)
function getEventTypeName(kind: number): string { function getEventTypeName(kind: number): string {
@ -515,7 +516,8 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
} }
return ( return (
<ThreadProfileBatchProvider seedEvents={finalEvent ? [finalEvent] : []}> <ThreadReplyProvider threadKey={finalEvent.id}>
<ThreadProfileBatchProvider seedEvents={[finalEvent]}>
<SecondaryPageLayout <SecondaryPageLayout
ref={ref} ref={ref}
index={index} index={index}
@ -586,6 +588,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
</div> </div>
</SecondaryPageLayout> </SecondaryPageLayout>
</ThreadProfileBatchProvider> </ThreadProfileBatchProvider>
</ThreadReplyProvider>
) )
}) })
NotePage.displayName = 'NotePage' NotePage.displayName = 'NotePage'

118
src/providers/ReplyProvider.tsx

@ -1,25 +1,9 @@
import { import { mergeRepliesIntoMap, type TRepliesMap } from '@/lib/reply-index'
canonicalizeRssArticleUrl, import type { Event } from 'nostr-tools'
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 { createContext, useCallback, useContext, useState } from 'react' import { createContext, useCallback, useContext, useState } from 'react'
type TReplyContext = { type TReplyContext = {
repliesMap: Map<string, { events: Event[]; eventIdSet: Set<string> }> repliesMap: TRepliesMap
addReplies: (replies: Event[]) => void addReplies: (replies: Event[]) => void
} }
@ -34,101 +18,11 @@ export const useReply = () => {
} }
export function ReplyProvider({ children }: { children: React.ReactNode }) { export function ReplyProvider({ children }: { children: React.ReactNode }) {
const [repliesMap, setRepliesMap] = useState< const [repliesMap, setRepliesMap] = useState<TRepliesMap>(() => new Map())
Map<string, { events: Event[]; eventIdSet: Set<string> }>
>(new Map())
const addReplies = useCallback((replies: Event[]) => { const addReplies = useCallback((replies: Event[]) => {
const newReplyIdSet = new Set<string>() if (replies.length === 0) return
const newReplyEventMap = new Map<string, Event[]>() setRepliesMap((prev) => mergeRepliesIntoMap(prev, replies))
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<string>()
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
})
}, []) }, [])
return ( return (

42
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<TThreadReplyContext | undefined>(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<TRepliesMap>(() => 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 <ThreadReplyContext.Provider value={value}>{children}</ThreadReplyContext.Provider>
}
export function useThreadReplyOptional() {
return useContext(ThreadReplyContext)
}
Loading…
Cancel
Save