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.
 
 
 
 

806 lines
28 KiB

import {
E_TAG_FILTER_BLOCKED_RELAY_URLS,
ExtendedKind,
FAST_READ_RELAY_URLS,
SEARCHABLE_RELAY_URLS
} from '@/constants'
import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content'
import {
getParentEventHexId,
getReplaceableCoordinateFromEvent,
isNip18RepostKind,
isReplaceableEvent
} from '@/lib/event'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger'
import {
canonicalizeRssArticleUrl,
expandArticleUrlThreadQueryValues,
getArticleUrlFromCommentITags,
getHighlightSourceHttpUrl,
getReactionPageUrlFromRTags,
getWebBookmarkArticleUrl,
getWebExternalReactionTargetUrl,
rssArticleStableEventId
} from '@/lib/rss-article'
import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays'
import { getEmojiInfosFromEmojiTags, getFirstHexEventIdFromETags, tagNameEquals } from '@/lib/tag'
import { normalizeUrl } from '@/lib/url'
import client, { eventService } from '@/services/client.service'
import { TEmoji } from '@/types'
import dayjs from 'dayjs'
import { Event, Filter, kinds } from 'nostr-tools'
export type TNoteStats = {
likeIdSet: Set<string>
likes: { id: string; pubkey: string; created_at: number; emoji: TEmoji | string }[]
repostPubkeySet: Set<string>
reposts: { id: string; pubkey: string; created_at: number }[]
zapPrSet: Set<string>
zaps: { pr: string; pubkey: string; amount: number; created_at: number; comment?: string }[]
replyIdSet: Set<string>
replies: { id: string; pubkey: string; created_at: number }[]
quoteIdSet: Set<string>
quotes: { id: string; pubkey: string; created_at: number }[]
highlightIdSet: Set<string>
highlights: { id: string; pubkey: string; created_at: number }[]
/** Pubkeys whose NIP-51 bookmark list includes this note id (`e` tag). */
bookmarkPubkeySet?: Set<string>
updatedAt?: number
}
class NoteStatsService {
static instance: NoteStatsService
private noteStatsMap: Map<string, Partial<TNoteStats>> = new Map()
private noteStatsSubscribers = new Map<string, Set<() => void>>()
private processingCache = new Set<string>()
// Batch processing
private pendingEvents = new Set<string>()
/** Favorite relays passed from the last fetchNoteStats call per note (used in processSingleEvent). */
private pendingFetchFavoriteRelays = new Map<string, string[] | null | undefined>()
/** Merged favorite URLs requested while this note was already in {@link processingCache}. */
private inFlightDeferredFavoriteRelays = new Map<string, string[]>()
private batchTimeout: NodeJS.Timeout | null = null
/** Prevents overlapping processBatch runs (reentrant calls corrupted pendingEvents). */
private processBatchRunning = false
private readonly BATCH_DELAY = 200
private readonly MAX_BATCH_SIZE = 24
/** Client-only RSS/Web thread roots are not on relays; use the event passed into {@link fetchNoteStats}. */
private pendingSyntheticRootById = new Map<string, Event>()
constructor() {
if (!NoteStatsService.instance) {
NoteStatsService.instance = this
}
return NoteStatsService.instance
}
/** Merge extra relay URLs into the pending fetch context for this note (deduped). */
private mergeFavoriteRelaysIntoPending(eventId: string, extra: string[] | null | undefined) {
if (!extra?.length) return
const cur = this.pendingFetchFavoriteRelays.get(eventId)
const merged = new Set<string>([...(cur ?? []), ...extra])
this.pendingFetchFavoriteRelays.set(eventId, [...merged])
}
private mergeFavoriteRelaysIntoDeferred(eventId: string, extra: string[] | null | undefined) {
if (!extra?.length) return
const cur = this.inFlightDeferredFavoriteRelays.get(eventId)
const merged = new Set<string>([...(cur ?? []), ...extra])
this.inFlightDeferredFavoriteRelays.set(eventId, [...merged])
}
private armStatsBatchTimer() {
if (this.batchTimeout) {
clearTimeout(this.batchTimeout)
}
this.batchTimeout = setTimeout(() => {
this.batchTimeout = null
void this.processBatch()
}, this.BATCH_DELAY)
}
async fetchNoteStats(event: Event, _pubkey?: string | null, favoriteRelays?: string[] | null) {
const eventId = event.id
if (this.pendingEvents.has(eventId)) {
this.mergeFavoriteRelaysIntoPending(eventId, favoriteRelays)
return
}
if (this.processingCache.has(eventId)) {
this.mergeFavoriteRelaysIntoDeferred(eventId, favoriteRelays)
return
}
this.pendingFetchFavoriteRelays.set(eventId, favoriteRelays ?? null)
this.pendingEvents.add(eventId)
if (event.kind === ExtendedKind.RSS_THREAD_ROOT) {
this.pendingSyntheticRootById.set(eventId, event)
}
this.armStatsBatchTimer()
if (this.pendingEvents.size >= this.MAX_BATCH_SIZE && !this.processBatchRunning) {
if (this.batchTimeout) {
clearTimeout(this.batchTimeout)
this.batchTimeout = null
}
void this.processBatch()
}
}
private async processBatch() {
if (this.processBatchRunning) {
return
}
if (this.pendingEvents.size === 0) {
return
}
this.processBatchRunning = true
if (this.batchTimeout) {
clearTimeout(this.batchTimeout)
this.batchTimeout = null
}
try {
while (this.pendingEvents.size > 0) {
const eventsToProcess = Array.from(this.pendingEvents).slice(0, this.MAX_BATCH_SIZE)
for (const id of eventsToProcess) {
this.pendingEvents.delete(id)
}
await Promise.all(eventsToProcess.map((eventId) => this.processSingleEvent(eventId)))
}
} finally {
this.processBatchRunning = false
if (this.pendingEvents.size > 0) {
this.armStatsBatchTimer()
}
}
}
private async processSingleEvent(eventId: string) {
if (this.processingCache.has(eventId)) {
logger.debug('[NoteStats] Skipping concurrent fetch for event', eventId.substring(0, 8))
return
}
this.processingCache.add(eventId)
const favoriteRelays = this.pendingFetchFavoriteRelays.get(eventId)
this.pendingFetchFavoriteRelays.delete(eventId)
try {
// Synthetic RSS/Web thread parents are not published; use the instance from fetchNoteStats.
const synthetic = this.pendingSyntheticRootById.get(eventId)
this.pendingSyntheticRootById.delete(eventId)
const event = synthetic ?? (await eventService.fetchEvent(eventId))
if (!event) {
logger.debug('[NoteStats] Event not found:', eventId.substring(0, 8))
return
}
const finalRelayUrls = await this.buildNoteStatsRelayList(event, favoriteRelays)
const replaceableCoordinate = isReplaceableEvent(event.kind)
? getReplaceableCoordinateFromEvent(event)
: undefined
const { nonSocial, social } = this.buildFilterGroups(event, replaceableCoordinate)
const fetchOpts = {
eoseTimeout: 10_000,
globalTimeout: 28_000,
firstRelayResultGraceMs: false as const
}
const events: Event[] = []
logger.debug('[NoteStats] Fetching stats for event', event.id.substring(0, 8), 'from', finalRelayUrls.length, 'relays')
const { queryService } = await import('@/services/client.service')
const onStatsEvent = (evt: Event) => {
this.updateNoteStatsByEvents([evt], event.pubkey)
events.push(evt)
}
if (nonSocial.length > 0) {
await queryService.fetchEvents(finalRelayUrls, nonSocial, {
...fetchOpts,
onevent: onStatsEvent
})
}
if (social.length > 0) {
await queryService.fetchEvents(finalRelayUrls, social, {
...fetchOpts,
onevent: onStatsEvent
})
}
logger.debug('[NoteStats] Fetched', events.length, 'events for stats')
this.noteStatsMap.set(event.id, {
...(this.noteStatsMap.get(event.id) ?? {}),
updatedAt: dayjs().unix()
})
// Always notify: when relays return 0 rows, no updateNoteStatsByEvents ran — subscribers would never re-render.
this.notifyNoteStats(event.id)
} finally {
this.processingCache.delete(eventId)
if (this.inFlightDeferredFavoriteRelays.has(eventId)) {
const deferred = this.inFlightDeferredFavoriteRelays.get(eventId)!
this.inFlightDeferredFavoriteRelays.delete(eventId)
if (deferred.length > 0) {
if (this.pendingEvents.has(eventId)) {
this.mergeFavoriteRelaysIntoPending(eventId, deferred)
} else {
this.pendingFetchFavoriteRelays.set(eventId, deferred)
this.pendingEvents.add(eventId)
}
}
}
}
}
/**
* Build relay list for note stats: SEARCHABLE + FAST_READ + optional user favorites + seen relays +
* `e`-tag hints on the note + hints from session-cached referrers + author NIP-65 read (slice 10).
* Excludes E_TAG_FILTER_BLOCKED_RELAY_URLS (stats use #e filters).
*/
private async buildNoteStatsRelayList(event: Event, favoriteRelays?: string[] | null): Promise<string[]> {
const blocked = new Set(
E_TAG_FILTER_BLOCKED_RELAY_URLS.map((u) => (normalizeUrl(u) || u).toLowerCase()).filter(Boolean)
)
const seen = new Set<string>()
const add = (url: string | undefined) => {
if (!url) return
const n = normalizeUrl(url)
if (!n || blocked.has(n.toLowerCase()) || seen.has(n)) return
seen.add(n)
}
// 1. Search / discovery relay set (includes read-only index mirrors; see READ_ONLY_RELAY_URLS in constants)
SEARCHABLE_RELAY_URLS.forEach(add)
// 2. Default fast read set (includes e.g. theforest — not in SEARCHABLE)
FAST_READ_RELAY_URLS.forEach(add)
// 3. User's favorite relays (spell feed / sidebar) — was previously ignored
favoriteRelays?.forEach(add)
// 4. Relay(s) where the event was seen
client.getSeenEventRelayUrls(event.id).forEach(add)
// 5. NIP-10 `e`-tag relay hints on the note itself (often where replies/reactions to it were published)
for (const t of event.tags) {
if ((t[0] === 'e' || t[0] === 'E') && t[2]?.trim()) {
add(t[2])
}
}
// 6. Session cache (e.g. notifications): events that reference this id with a relay hint
client.eventService.getSessionRelayHintsForHexTarget(event.id).forEach(add)
// 7. Author's inboxes (read relays from kind 10002)
try {
const relayList = await Promise.race([
client.fetchRelayList(event.pubkey),
new Promise<{ read?: string[] }>((r) => setTimeout(() => r({}), 2000))
])
userReadRelaysWithHttp(relayList).slice(0, 10).forEach(add)
} catch {
// ignore
}
return Array.from(seen)
}
/**
* Split REQ batches: filters that include social kinds (1 / 11 / 1111) trigger
* {@link relayFilterIncludesSocialKindBlockedKind} and drop {@link SOCIAL_KIND_BLOCKED_RELAY_URLS}; keep reactions,
* zaps, and `#r` queries in separate batches so read-only index relays ({@link READ_ONLY_RELAY_URLS}) still answer
* where appropriate. RSS URL threads also need `#r` + kind 7 for NIP-73 page-targeted likes.
*/
private buildFilterGroups(
event: Event,
replaceableCoordinate?: string
): { nonSocial: Filter[]; social: Filter[] } {
const reactionLimit = 300
const interactionLimit = 80
const nip18RepostKinds = [kinds.Repost, ExtendedKind.GENERIC_REPOST]
/** Synthetic RSS/Web parents are not on relays; `#e` on the fake id returns nothing. Use only URL-scoped filters. */
if (event.kind === ExtendedKind.RSS_THREAD_ROOT) {
const url = getArticleUrlFromCommentITags(event)
if (!url) {
return { nonSocial: [], social: [] }
}
const canonical = canonicalizeRssArticleUrl(url)
const tagVals = expandArticleUrlThreadQueryValues(canonical)
const iVals = tagVals.length > 0 ? tagVals : [canonical]
const nonSocial: Filter[] = [
{ '#i': iVals, kinds: [ExtendedKind.EXTERNAL_REACTION], limit: reactionLimit },
{ '#I': iVals, kinds: [ExtendedKind.EXTERNAL_REACTION], limit: reactionLimit },
{ '#i': iVals, kinds: [ExtendedKind.WEB_BOOKMARK], limit: 200 },
{ '#I': iVals, kinds: [ExtendedKind.WEB_BOOKMARK], limit: 200 }
]
if (tagVals.length > 0) {
nonSocial.push(
{ '#r': tagVals, kinds: [kinds.Highlights], limit: interactionLimit },
{ '#r': tagVals, kinds: [kinds.Reaction], limit: reactionLimit }
)
}
const social: Filter[] = [
{ '#i': iVals, kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], limit: interactionLimit },
{ '#I': iVals, kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], limit: interactionLimit }
]
return { nonSocial, social }
}
const nonSocial: Filter[] = [
{ '#e': [event.id], kinds: [kinds.Reaction], limit: reactionLimit },
{ '#e': [event.id], kinds: [kinds.Zap], limit: 100 }
]
const social: Filter[] = [
{
'#e': [event.id],
kinds: [
...nip18RepostKinds,
kinds.ShortTextNote,
ExtendedKind.COMMENT,
ExtendedKind.VOICE_COMMENT,
kinds.Highlights
],
limit: interactionLimit
},
{
'#q': [event.id],
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit: 50
}
]
if (replaceableCoordinate) {
nonSocial.push(
{ '#a': [replaceableCoordinate], kinds: [kinds.Reaction], limit: reactionLimit },
{ '#a': [replaceableCoordinate], kinds: [kinds.Zap], limit: 100 }
)
social.push(
{
'#a': [replaceableCoordinate],
kinds: [
...nip18RepostKinds,
kinds.ShortTextNote,
ExtendedKind.COMMENT,
ExtendedKind.VOICE_COMMENT,
kinds.Highlights
],
limit: interactionLimit
},
{
'#q': [replaceableCoordinate],
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit: 50
}
)
}
return { nonSocial, social }
}
subscribeNoteStats(noteId: string, callback: () => void) {
let set = this.noteStatsSubscribers.get(noteId)
if (!set) {
set = new Set()
this.noteStatsSubscribers.set(noteId, set)
}
set.add(callback)
return () => {
set?.delete(callback)
if (set?.size === 0) this.noteStatsSubscribers.delete(noteId)
}
}
private notifyNoteStats(noteId: string) {
const set = this.noteStatsSubscribers.get(noteId)
if (set) {
set.forEach((cb) => cb())
}
}
getNoteStats(id: string): Partial<TNoteStats> | undefined {
return this.noteStatsMap.get(id)
}
addZap(
pubkey: string,
eventId: string,
pr: string,
amount: number,
comment?: string,
created_at: number = dayjs().unix(),
notify: boolean = true
) {
const old = this.noteStatsMap.get(eventId) || {}
const zapPrSet = old.zapPrSet || new Set()
const zaps = old.zaps || []
if (zapPrSet.has(pr)) return
zapPrSet.add(pr)
zaps.push({ pr, pubkey, amount, comment, created_at })
this.noteStatsMap.set(eventId, { ...old, zapPrSet, zaps })
if (notify) {
this.notifyNoteStats(eventId)
}
return eventId
}
/**
* @param mergeOpts When the UI just published a single interaction, pass the note id the user acted on
* so stats merge even if `e` tag shape varies (extensions, multiple ancestors).
*/
updateNoteStatsByEvents(
events: Event[],
originalEventAuthor?: string,
mergeOpts?: {
interactionTargetNoteId?: string
replyParentNoteId?: string
}
) {
const updatedEventIdSet = new Set<string>()
// Process events in batches for better performance
const batchSize = 50
for (let i = 0; i < events.length; i += batchSize) {
const batch = events.slice(i, i + batchSize)
batch.forEach((evt) => {
const updatedEventId = this.processEvent(evt, originalEventAuthor, mergeOpts)
if (updatedEventId) {
updatedEventIdSet.add(updatedEventId)
}
})
}
updatedEventIdSet.forEach((eventId) => {
this.notifyNoteStats(eventId)
})
}
private processEvent(
evt: Event,
originalEventAuthor?: string,
mergeOpts?: { interactionTargetNoteId?: string; replyParentNoteId?: string }
): string | undefined {
let updatedEventId: string | undefined
if (evt.kind === kinds.Reaction) {
updatedEventId = this.addLikeByEvent(evt, originalEventAuthor, mergeOpts?.interactionTargetNoteId)
} else if (evt.kind === ExtendedKind.EXTERNAL_REACTION) {
updatedEventId = this.addLikeByExternalWebReactionEvent(
evt,
originalEventAuthor,
mergeOpts?.interactionTargetNoteId
)
} else if (isNip18RepostKind(evt.kind)) {
updatedEventId = this.addRepostByEvent(evt, originalEventAuthor, mergeOpts?.interactionTargetNoteId)
} else if (evt.kind === kinds.Zap) {
updatedEventId = this.addZapByEvent(evt, originalEventAuthor)
} else if (evt.kind === kinds.ShortTextNote || evt.kind === ExtendedKind.COMMENT || evt.kind === ExtendedKind.VOICE_COMMENT) {
const isQuote = this.isQuoteByEvent(evt)
if (isQuote) {
updatedEventId = this.addQuoteByEvent(evt, originalEventAuthor)
} else if (mergeOpts?.replyParentNoteId) {
updatedEventId = this.addReplyByEvent(evt, originalEventAuthor, mergeOpts.replyParentNoteId)
} else {
updatedEventId = this.addReplyByEvent(evt, originalEventAuthor)
}
} else if (evt.kind === kinds.Highlights) {
updatedEventId = this.addHighlightByEvent(evt, originalEventAuthor)
} else if (evt.kind === ExtendedKind.WEB_BOOKMARK) {
updatedEventId = this.addWebBookmarkByArticleUrlEvent(evt)
} else if (evt.kind === kinds.BookmarkList) {
this.addBookmarkListRefsByEvent(evt)
}
return updatedEventId
}
private reactionEmojiFromEvent(evt: Event): TEmoji | string {
let emoji: TEmoji | string = evt.content.trim()
if (!emoji) {
const fromTags = getEmojiInfosFromEmojiTags(evt.tags)
if (fromTags.length) {
emoji = fromTags[0]
} else {
emoji = '+'
}
}
if (typeof emoji === 'string' && emoji.startsWith(':') && emoji.endsWith(':')) {
const emojiInfos = getEmojiInfosFromEmojiTags(evt.tags)
const shortcode = emoji.split(':')[1]
const emojiInfo = emojiInfos.find((info) => info.shortcode === shortcode)
if (emojiInfo) {
emoji = emojiInfo
} else {
const customCodes = emojiInfos.map((e) => e.shortcode)
const normalized = replaceStandardEmojiShortcodesInContent(emoji, customCodes)
if (normalized !== emoji) {
emoji = normalized
}
// else keep `:custom:` string; UI resolves via reactor profile (ReactionEmojiDisplay)
}
}
return emoji
}
private addLikeByEvent(evt: Event, originalEventAuthor?: string, forcedTargetEventId?: string) {
let targetEventId = forcedTargetEventId ?? getFirstHexEventIdFromETags(evt.tags)
if (!targetEventId && evt.kind === kinds.Reaction) {
const pageUrl = getReactionPageUrlFromRTags(evt)
if (pageUrl) {
targetEventId = rssArticleStableEventId(canonicalizeRssArticleUrl(pageUrl))
}
}
if (!targetEventId) return
const old = this.noteStatsMap.get(targetEventId) || {}
const likeIdSet = old.likeIdSet || new Set()
const likes = old.likes || []
if (likeIdSet.has(evt.id)) return
if (originalEventAuthor && originalEventAuthor === evt.pubkey) {
return
}
const emoji = this.reactionEmojiFromEvent(evt)
likeIdSet.add(evt.id)
likes.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at, emoji })
this.noteStatsMap.set(targetEventId, { ...old, likeIdSet, likes })
return targetEventId
}
/** NIP-25 kind 17 reactions to http(s) URLs; stats key matches synthetic RSS thread root id. */
private addLikeByExternalWebReactionEvent(
evt: Event,
originalEventAuthor?: string,
forcedTargetEventId?: string
) {
const url = getWebExternalReactionTargetUrl(evt)
if (!url) return
const targetEventId =
forcedTargetEventId ?? rssArticleStableEventId(canonicalizeRssArticleUrl(url))
const old = this.noteStatsMap.get(targetEventId) || {}
const likeIdSet = old.likeIdSet || new Set()
const likes = old.likes || []
if (likeIdSet.has(evt.id)) return
if (originalEventAuthor && originalEventAuthor === evt.pubkey) {
return
}
const emoji = this.reactionEmojiFromEvent(evt)
likeIdSet.add(evt.id)
likes.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at, emoji })
this.noteStatsMap.set(targetEventId, { ...old, likeIdSet, likes })
return targetEventId
}
removeLike(eventId: string, reactionEventId: string) {
const old = this.noteStatsMap.get(eventId) || {}
const likeIdSet = old.likeIdSet || new Set()
const likes = old.likes || []
if (!likeIdSet.has(reactionEventId)) return eventId
likeIdSet.delete(reactionEventId)
const newLikes = likes.filter(like => like.id !== reactionEventId)
this.noteStatsMap.set(eventId, { ...old, likeIdSet, likes: newLikes })
this.notifyNoteStats(eventId)
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
}
return undefined
}
private addRepostByEvent(evt: Event, originalEventAuthor?: string, forcedTargetEventId?: string) {
const eventId = this.repostStatsTargetId(evt, forcedTargetEventId)
if (!eventId) return
const old = this.noteStatsMap.get(eventId) || {}
const repostPubkeySet = old.repostPubkeySet || new Set()
const reposts = old.reposts || []
if (repostPubkeySet.has(evt.pubkey)) return
if (originalEventAuthor && originalEventAuthor === evt.pubkey) {
return
}
repostPubkeySet.add(evt.pubkey)
reposts.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at })
this.noteStatsMap.set(eventId, { ...old, repostPubkeySet, reposts })
return eventId
}
private addZapByEvent(evt: Event, originalEventAuthor?: string) {
const info = getZapInfoFromEvent(evt)
if (!info) return
const { originalEventId, senderPubkey, invoice, amount, comment } = info
if (!originalEventId || !senderPubkey) return
if (!amount || amount <= 0) return // Suppress 0 sat zaps (spam)
if (originalEventAuthor && originalEventAuthor === senderPubkey) {
return
}
return this.addZap(
senderPubkey,
originalEventId,
invoice,
amount,
comment,
evt.created_at,
false
)
}
private addReplyByEvent(evt: Event, originalEventAuthor?: string, forcedOriginalEventId?: string) {
let originalEventId: string | undefined = forcedOriginalEventId
if (!originalEventId) {
if (evt.kind === ExtendedKind.COMMENT || evt.kind === ExtendedKind.VOICE_COMMENT) {
const eTag = evt.tags.find(tagNameEquals('e')) ?? evt.tags.find(tagNameEquals('E'))
originalEventId = eTag?.[1]
if (!originalEventId) {
const scopeUrl = getArticleUrlFromCommentITags(evt)
if (scopeUrl) {
originalEventId = rssArticleStableEventId(canonicalizeRssArticleUrl(scopeUrl))
}
}
} else if (evt.kind === kinds.ShortTextNote) {
// Prefer NIP-10 reply parent (matches getParentETag), not the first of reply|root in tag order.
const parentHex = getParentEventHexId(evt)
if (parentHex && /^[0-9a-f]{64}$/i.test(parentHex)) {
originalEventId = parentHex.toLowerCase()
}
if (!originalEventId) {
const aTag = evt.tags.find(tagNameEquals('a'))
if (aTag) {
originalEventId = aTag[1]
}
}
}
}
if (!originalEventId) return
const old = this.noteStatsMap.get(originalEventId) || {}
const replyIdSet = old.replyIdSet || new Set()
const replies = old.replies || []
if (replyIdSet.has(evt.id)) return
if (originalEventAuthor && originalEventAuthor === evt.pubkey) {
return
}
replyIdSet.add(evt.id)
replies.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at })
this.noteStatsMap.set(originalEventId, { ...old, replyIdSet, replies })
return originalEventId
}
private isQuoteByEvent(evt: Event): boolean {
return evt.tags.some(tag => tag[0] === 'q' && tag[1])
}
private addQuoteByEvent(evt: Event, originalEventAuthor?: string) {
const quotedEventId = evt.tags.find(tag => tag[0] === 'q')?.[1]
if (!quotedEventId) return
const old = this.noteStatsMap.get(quotedEventId) || {}
const quoteIdSet = old.quoteIdSet || new Set()
const quotes = old.quotes || []
if (quoteIdSet.has(evt.id)) return
if (originalEventAuthor && originalEventAuthor === evt.pubkey) {
return
}
quoteIdSet.add(evt.id)
quotes.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at })
this.noteStatsMap.set(quotedEventId, { ...old, quoteIdSet, quotes })
return quotedEventId
}
private addHighlightByEvent(evt: Event, originalEventAuthor?: string) {
let highlightedEventId = evt.tags.find((tag) => tag[0] === 'e')?.[1]
if (!highlightedEventId) {
const pageUrl = getHighlightSourceHttpUrl(evt)
if (pageUrl) {
highlightedEventId = rssArticleStableEventId(canonicalizeRssArticleUrl(pageUrl))
}
}
if (!highlightedEventId) return
const old = this.noteStatsMap.get(highlightedEventId) || {}
const highlightIdSet = old.highlightIdSet || new Set()
const highlights = old.highlights || []
if (highlightIdSet.has(evt.id)) return
if (originalEventAuthor && originalEventAuthor === evt.pubkey) {
return
}
highlightIdSet.add(evt.id)
highlights.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at })
this.noteStatsMap.set(highlightedEventId, { ...old, highlightIdSet, highlights })
return highlightedEventId
}
/** Kind 39701: count one bookmark per pubkey for this article URL (synthetic thread id). */
private addWebBookmarkByArticleUrlEvent(evt: Event): string | undefined {
const url = getWebBookmarkArticleUrl(evt)
if (!url) return
const targetId = rssArticleStableEventId(canonicalizeRssArticleUrl(url))
const old = this.noteStatsMap.get(targetId) || {}
const bookmarkPubkeySet = old.bookmarkPubkeySet ?? new Set<string>()
if (bookmarkPubkeySet.has(evt.pubkey)) return targetId
bookmarkPubkeySet.add(evt.pubkey)
this.noteStatsMap.set(targetId, { ...old, bookmarkPubkeySet })
this.notifyNoteStats(targetId)
return targetId
}
/** Each bookmark list author counts once per target `e` id in that list. */
private addBookmarkListRefsByEvent(evt: Event) {
for (const tag of evt.tags) {
if (tag[0] !== 'e' || !tag[1]) continue
const targetId = tag[1]
const old = this.noteStatsMap.get(targetId) || {}
const bookmarkPubkeySet = old.bookmarkPubkeySet ?? new Set<string>()
if (bookmarkPubkeySet.has(evt.pubkey)) continue
bookmarkPubkeySet.add(evt.pubkey)
this.noteStatsMap.set(targetId, { ...old, bookmarkPubkeySet })
this.notifyNoteStats(targetId)
}
}
}
const instance = new NoteStatsService()
export default instance