diff --git a/src/constants.ts b/src/constants.ts index 181bc85c..4802d5bd 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -327,7 +327,10 @@ export const ExtendedKind = { RSS_FEED_LIST: 10895, /** Client-only synthetic "parent" for RSS article threads; never published to relays */ RSS_THREAD_ROOT: 99999, - /** NIP-18: generic repost of any event other than kind 1 (kind 6 is kind-1-only). */ + /** + * NIP-18: generic repost (kind 16) for any event **except** kind 1 — zaps (9735), reactions, comments, etc. + * Kind **6** (`kinds.Repost` from nostr-tools) is only for reposting kind 1. See `createRepostDraftEvent`. + */ GENERIC_REPOST: 16, /** NIP-25: reaction to external content (NIP-73 `k` + `i`), e.g. http(s) URLs */ EXTERNAL_REACTION: 17, diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index 2137b4a8..2a11531a 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -122,7 +122,15 @@ export function createReactionDraftEvent(event: Event, emoji: TEmoji | string = } } -// https://github.com/nostr-protocol/nips/blob/master/18.md +/** + * NIP-18 boost / repost. + * - Kind **6** (`kinds.Repost`): only for reposting **kind 1** (short notes). + * - Kind **16** (`ExtendedKind.GENERIC_REPOST`): for every other kind — e.g. zaps (9735), reactions (7), + * comments (1111), long-form, etc. Requires a **`k`** tag with the stringified target kind. + * So boosting a zap receipt always creates **kind 16** with `k` = `"9735"`. + * + * @see https://github.com/nostr-protocol/nips/blob/master/18.md + */ export function createRepostDraftEvent(event: Event): TDraftEvent { const isProtected = isProtectedEvent(event) const tags: string[][] = [buildETag(event.id, event.pubkey), buildPTag(event.pubkey)] diff --git a/src/lib/thread-reply-root-match.ts b/src/lib/thread-reply-root-match.ts index cfa5b877..4e9277cd 100644 --- a/src/lib/thread-reply-root-match.ts +++ b/src/lib/thread-reply-root-match.ts @@ -17,8 +17,43 @@ import client from '@/services/client.service' import type { Event } from 'nostr-tools' import { kinds } from 'nostr-tools' -/** Reply whose direct parent is a zap receipt for this thread root (hex id). */ -function replyParentIsZapToRootHex(reply: Event, rootHexLower: string): boolean { +const THREAD_PARENT_WALK_MAX = 14 + +/** + * 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): 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() + 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 = client.peekSessionCachedEvent(k) + 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 receipt whose zapped note is in this thread (OP or nested under OP). */ +function replyParentIsZapToThreadHex(reply: Event, rootHexLower: string): boolean { const parentHex = getParentEventHexId(reply) if (!parentHex || !/^[0-9a-f]{64}$/i.test(parentHex)) return false const pl = parentHex.toLowerCase() @@ -26,11 +61,8 @@ function replyParentIsZapToRootHex(reply: Event, rootHexLower: string): boolean const parentEv = client.peekSessionCachedEvent(pl) if (!parentEv || parentEv.kind !== kinds.Zap) return false const zapped = getZapInfoFromEvent(parentEv)?.originalEventId - return ( - !!zapped && - /^[0-9a-f]{64}$/i.test(zapped) && - zapped.toLowerCase() === rootHexLower - ) + if (!zapped || !/^[0-9a-f]{64}$/i.test(zapped)) return false + return hexNoteParticipatesInThread(zapped.toLowerCase(), rootHexLower) } function reactionTargetNoteHex(reaction: Event): string | undefined { @@ -41,15 +73,17 @@ function reactionTargetNoteHex(reaction: Event): string | undefined { return undefined } -/** Reply whose direct parent is a NIP-25 / kind-17 reaction to this thread root note. */ -function replyParentIsReactionToRootHex(reply: Event, rootHexLower: string): boolean { +/** 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): 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 = client.peekSessionCachedEvent(pl) if (!parentEv || !isNip25ReactionKind(parentEv.kind)) return false - return reactionTargetNoteHex(parentEv) === rootHexLower + const targetHex = reactionTargetNoteHex(parentEv) + if (!targetHex) return false + return hexNoteParticipatesInThread(targetHex, rootHexLower) } /** Matches `ReplyNoteList` / discussion thread root shapes. */ @@ -79,8 +113,8 @@ export function eventReplyMatchesThreadRoot(evt: Event, root: TThreadRootRef): b const rid = root.id.trim().toLowerCase() const evtRootHex = getRootEventHexId(evt)?.toLowerCase() if (evtRootHex === rid) return true - if (replyParentIsZapToRootHex(evt, rid)) return true - if (replyParentIsReactionToRootHex(evt, rid)) return true + if (replyParentIsZapToThreadHex(evt, rid)) return true + if (replyParentIsReactionToThreadHex(evt, rid)) return true return kind1QuotesThreadRoot(evt, root) } diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index c2284c99..49ffe93b 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -593,26 +593,31 @@ class NoteStatsService { return eventId } + /** Target id for repost stats: `e` first (NIP-18 for both kind 6 and 16), then embedded JSON, then `a` (generic only). */ private repostStatsTargetId(evt: Event, forcedTargetEventId?: string): string | undefined { const forced = forcedTargetEventId?.trim() if (forced) return forced + if (!isNip18RepostKind(evt.kind)) return undefined + const hex = getFirstHexEventIdFromETags(evt.tags) if (hex) return hex.toLowerCase() + + const raw = evt.content?.trim() + if (raw) { + try { + const embedded = JSON.parse(raw) as { id?: string } + if (embedded.id && /^[0-9a-f]{64}$/i.test(embedded.id)) { + return embedded.id.toLowerCase() + } + } catch { + /* ignore */ + } + } + if (evt.kind === ExtendedKind.GENERIC_REPOST) { const aTag = evt.tags.find(tagNameEquals('a')) ?? evt.tags.find(tagNameEquals('A')) const coord = aTag?.[1]?.trim() if (coord) return coord - const raw = evt.content?.trim() - if (raw) { - try { - const embedded = JSON.parse(raw) as { id?: string } - if (embedded.id && /^[0-9a-f]{64}$/i.test(embedded.id)) { - return embedded.id.toLowerCase() - } - } catch { - /* ignore */ - } - } } return undefined }