You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

203 lines
8.1 KiB

import {
getParentEventHexId,
getQuotedEventHexIdFromQTags,
getRootATag,
getRootEventHexId,
isNip25ReactionKind,
kind1QuotesThreadRoot,
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,
getArticleUrlFromCommentITags,
getHighlightSourceHttpUrl
} from '@/lib/rss-article'
import client from '@/services/client.service'
import type { Event } from 'nostr-tools'
import { kinds } from 'nostr-tools'
const THREAD_PARENT_WALK_MAX = 14
/** Prefer session LRU; use `localByHex` for events in the current relay batch (not yet indexed for parent walks). */
function peekThreadEvent(hexLower: string, localByHex?: ReadonlyMap<string, Event>): Event | undefined {
const k = hexLower.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/i.test(k)) return undefined
return localByHex?.get(k) ?? client.peekSessionCachedEvent(k)
}
/**
* Whether a note (hex id) sits in the thread under `rootHexLower`: it is the root, declares that root,
* or we can reach the root by walking `e` parents in the session cache.
*/
function hexNoteParticipatesInThread(
noteHexLower: string,
rootHexLower: string,
localByHex?: ReadonlyMap<string, Event>
): boolean {
const root = rootHexLower.trim().toLowerCase()
const start = noteHexLower.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/i.test(start)) return false
if (start === root) return true
const seen = new Set<string>()
let curId: string | undefined = start
for (let hop = 0; hop < THREAD_PARENT_WALK_MAX && curId; hop++) {
const k = curId.toLowerCase()
if (seen.has(k)) return false
seen.add(k)
if (k === root) return true
const ev = peekThreadEvent(k, localByHex)
if (!ev) return false
if (ev.id.toLowerCase() === root) return true
const declaredRoot = getRootEventHexId(ev)?.toLowerCase()
if (declaredRoot === root) return true
const parent = getParentEventHexId(ev)?.toLowerCase()
if (!parent || !/^[0-9a-f]{64}$/i.test(parent)) return false
curId = parent
}
return false
}
/** 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>
): boolean {
const parentHex = getParentEventHexId(reply)
if (!parentHex || !/^[0-9a-f]{64}$/i.test(parentHex)) return false
const pl = parentHex.toLowerCase()
if (pl === rootHexLower) return false
const parentEv = peekThreadEvent(pl, 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 {
const fromParent = getParentEventHexId(reaction)
if (fromParent && /^[0-9a-f]{64}$/i.test(fromParent)) return fromParent.toLowerCase()
const first = getFirstHexEventIdFromETags(reaction.tags)
if (first && /^[0-9a-f]{64}$/i.test(first)) return first.toLowerCase()
return undefined
}
/** Reply whose direct parent is a reaction to some note in this thread (OP or a nested reply under OP). */
function replyParentIsReactionToThreadHex(
reply: Event,
rootHexLower: string,
localByHex?: ReadonlyMap<string, Event>
): boolean {
const parentHex = getParentEventHexId(reply)
if (!parentHex || !/^[0-9a-f]{64}$/i.test(parentHex)) return false
const pl = parentHex.toLowerCase()
if (pl === rootHexLower) return false
const parentEv = peekThreadEvent(pl, localByHex)
if (!parentEv || !isNip25ReactionKind(parentEv.kind)) return false
const targetHex = reactionTargetNoteHex(parentEv)
if (!targetHex) return false
return hexNoteParticipatesInThread(targetHex, rootHexLower, localByHex)
}
/** Matches `ReplyNoteList` / discussion thread root shapes. */
export type TThreadRootRef =
| { type: 'E'; id: string; pubkey: string }
| { type: 'A'; id: string; eventId: string; pubkey: string; relay?: string }
| { type: 'I'; id: string }
/** Whether a newly published/fetched reply belongs to the thread rooted at `root`. */
export function eventReplyMatchesThreadRoot(
evt: Event,
root: TThreadRootRef,
localByHex?: ReadonlyMap<string, Event>
): boolean {
if (root.type === 'I') {
const u = getArticleUrlFromCommentITags(evt)
if (u && canonicalizeRssArticleUrl(u) === canonicalizeRssArticleUrl(root.id)) return true
if (evt.kind === kinds.Highlights) {
const hu = getHighlightSourceHttpUrl(evt)
return !!hu && canonicalizeRssArticleUrl(hu) === canonicalizeRssArticleUrl(root.id)
}
// Some clients omit the root I tag on nested replies. Walk one level up via the session
// cache: if the declared root or direct parent is a URL-thread comment, accept this event.
const urlMatchesRoot = (hexId: string | undefined): boolean => {
if (!hexId || !/^[0-9a-f]{64}$/i.test(hexId)) return false
const ancestor = peekThreadEvent(hexId.toLowerCase(), localByHex)
if (!ancestor) return false
const aUrl = getArticleUrlFromCommentITags(ancestor)
return !!aUrl && canonicalizeRssArticleUrl(aUrl) === canonicalizeRssArticleUrl(root.id)
}
if (urlMatchesRoot(getRootEventHexId(evt))) return true
if (urlMatchesRoot(getParentEventHexId(evt))) return true
return false
}
if (root.type === 'A') {
const coord = getRootATag(evt)?.[1]
if (coord === root.id) return true
const rootHex = getRootEventHexId(evt)
if (rootHex && (rootHex === root.eventId || rootHex === root.id)) return true
const parentHex = getParentEventHexId(evt)?.toLowerCase()
const rootEventHex = root.eventId.trim().toLowerCase()
if (
parentHex &&
/^[0-9a-f]{64}$/i.test(rootEventHex) &&
hexNoteParticipatesInThread(parentHex, rootEventHex, localByHex)
) {
return true
}
return kind1QuotesThreadRoot(evt, root)
}
const rid = root.id.trim().toLowerCase()
const evtRootHex = getRootEventHexId(evt)?.toLowerCase()
if (evtRootHex === rid) return true
if (evtRootHex && resolveDeclaredThreadRootEventHex(evtRootHex) === rid) return true
const parentHex = getParentEventHexId(evt)?.toLowerCase()
if (parentHex && hexNoteParticipatesInThread(parentHex, rid, localByHex)) return true
if (replyParentIsSuperchatToThreadHex(evt, rid, localByHex)) return true
if (replyParentIsReactionToThreadHex(evt, rid, localByHex)) return true
return kind1QuotesThreadRoot(evt, root)
}
/**
* Whether `evt` should appear in the reply list for note `opEvent` with thread root `root`.
* Stricter than treating any kind-1 with an `e` tag as a reply: requires thread root / #q to match (so notes that only
* tag the quoted inner note as `e`+`root` do not show under the quoter's thread).
* For quote posts, also drops kind-1 replies whose **parent** is the embedded quoted id but not the OP.
*/
export function replyBelongsToNoteThread(
evt: Event,
opEvent: Event,
root: TThreadRootRef,
localByHex?: ReadonlyMap<string, Event>
): boolean {
if (root.type === 'I') {
return eventReplyMatchesThreadRoot(evt, root, localByHex)
}
if (!eventReplyMatchesThreadRoot(evt, root, localByHex)) return false
if (root.type === 'A') return true
if (opEvent.kind !== kinds.ShortTextNote) return true
const quotedHex = getQuotedEventHexIdFromQTags(opEvent)?.toLowerCase()
if (!quotedHex) return true
const parentHex = getParentEventHexId(evt)?.toLowerCase()
if (!parentHex) return true
const rootId = root.id.trim().toLowerCase()
if (parentHex === quotedHex && parentHex !== rootId) return false
return true
}