import { BIG_RELAY_URLS } from '@/constants' import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' import { getZapInfoFromEvent } from '@/lib/event-metadata' import { getEmojiInfosFromEmojiTags, tagNameEquals } from '@/lib/tag' import client from '@/services/client.service' import { TEmoji } from '@/types' import dayjs from 'dayjs' import { Event, Filter, kinds } from 'nostr-tools' export type TNoteStats = { likeIdSet: Set likes: { id: string; pubkey: string; created_at: number; emoji: TEmoji | string }[] repostPubkeySet: Set reposts: { id: string; pubkey: string; created_at: number }[] zapPrSet: Set zaps: { pr: string; pubkey: string; amount: number; created_at: number; comment?: string }[] updatedAt?: number } class NoteStatsService { static instance: NoteStatsService private noteStatsMap: Map> = new Map() private noteStatsSubscribers = new Map void>>() constructor() { if (!NoteStatsService.instance) { NoteStatsService.instance = this } return NoteStatsService.instance } async fetchNoteStats(event: Event, pubkey?: string | null) { const oldStats = this.noteStatsMap.get(event.id) let since: number | undefined if (oldStats?.updatedAt) { since = oldStats.updatedAt } // Privacy: Only use current user's relays + defaults, never connect to other users' relays const [relayList, authorProfile] = await Promise.all([ pubkey ? client.fetchRelayList(pubkey) : Promise.resolve({ write: [], read: [] }), client.fetchProfile(event.pubkey) ]) const replaceableCoordinate = isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) : undefined const filters: Filter[] = [ { '#e': [event.id], kinds: [kinds.Reaction], limit: 500 }, { '#e': [event.id], kinds: [kinds.Repost], limit: 100 } ] if (replaceableCoordinate) { filters.push( { '#a': [replaceableCoordinate], kinds: [kinds.Reaction], limit: 500 }, { '#a': [replaceableCoordinate], kinds: [kinds.Repost], limit: 100 } ) } if (authorProfile?.lightningAddress) { filters.push({ '#e': [event.id], kinds: [kinds.Zap], limit: 500 }) if (replaceableCoordinate) { filters.push({ '#a': [replaceableCoordinate], kinds: [kinds.Zap], limit: 500 }) } } if (pubkey) { filters.push({ '#e': [event.id], authors: [pubkey], kinds: [kinds.Reaction, kinds.Repost] }) if (replaceableCoordinate) { filters.push({ '#a': [replaceableCoordinate], authors: [pubkey], kinds: [kinds.Reaction, kinds.Repost] }) } if (authorProfile?.lightningAddress) { filters.push({ '#e': [event.id], '#P': [pubkey], kinds: [kinds.Zap] }) if (replaceableCoordinate) { filters.push({ '#a': [replaceableCoordinate], '#P': [pubkey], kinds: [kinds.Zap] }) } } } if (since) { filters.forEach((filter) => { filter.since = since }) } const events: Event[] = [] await client.fetchEvents(relayList.read.concat(BIG_RELAY_URLS).slice(0, 5), filters, { onevent: (evt) => { this.updateNoteStatsByEvents([evt]) events.push(evt) } }) this.noteStatsMap.set(event.id, { ...(this.noteStatsMap.get(event.id) ?? {}), updatedAt: dayjs().unix() }) return this.noteStatsMap.get(event.id) ?? {} } 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 | 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 } updateNoteStatsByEvents(events: Event[]) { const updatedEventIdSet = new Set() events.forEach((evt) => { let updatedEventId: string | undefined if (evt.kind === kinds.Reaction) { updatedEventId = this.addLikeByEvent(evt) } else if (evt.kind === kinds.Repost) { updatedEventId = this.addRepostByEvent(evt) } else if (evt.kind === kinds.Zap) { updatedEventId = this.addZapByEvent(evt) } if (updatedEventId) { updatedEventIdSet.add(updatedEventId) } }) updatedEventIdSet.forEach((eventId) => { this.notifyNoteStats(eventId) }) } private addLikeByEvent(evt: Event) { const targetEventId = evt.tags.findLast(tagNameEquals('e'))?.[1] 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 let emoji: TEmoji | string = evt.content.trim() if (!emoji) return if (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 { emoji = '+' } } 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 } private addRepostByEvent(evt: Event) { const eventId = evt.tags.find(tagNameEquals('e'))?.[1] 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 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) { const info = getZapInfoFromEvent(evt) if (!info) return const { originalEventId, senderPubkey, invoice, amount, comment } = info if (!originalEventId || !senderPubkey) return return this.addZap( senderPubkey, originalEventId, invoice, amount, comment, evt.created_at, false ) } } const instance = new NoteStatsService() export default instance