stuff-stats.service.ts raw

   1  import { ExtendedKind } from '@/constants'
   2  import { getEventKey, getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
   3  import { getZapInfoFromEvent } from '@/lib/event-metadata'
   4  import { getEmojiInfosFromEmojiTags, tagNameEquals } from '@/lib/tag'
   5  import client from '@/services/client.service'
   6  import { TEmoji } from '@/types'
   7  import dayjs from 'dayjs'
   8  import { Event, Filter, kinds } from 'nostr-tools'
   9  
  10  export type TStuffStats = {
  11    likeIdSet: Set<string>
  12    likes: { id: string; pubkey: string; created_at: number; emoji: TEmoji | string }[]
  13    repostPubkeySet: Set<string>
  14    reposts: { id: string; pubkey: string; created_at: number }[]
  15    zapPrSet: Set<string>
  16    zaps: { pr: string; pubkey: string; amount: number; created_at: number; comment?: string }[]
  17    updatedAt?: number
  18  }
  19  
  20  class StuffStatsService {
  21    static instance: StuffStatsService
  22    private stuffStatsMap: Map<string, Partial<TStuffStats>> = new Map()
  23    private stuffStatsSubscribers = new Map<string, Set<() => void>>()
  24  
  25    constructor() {
  26      if (!StuffStatsService.instance) {
  27        StuffStatsService.instance = this
  28      }
  29      return StuffStatsService.instance
  30    }
  31  
  32    async fetchStuffStats(stuff: Event | string, pubkey?: string | null) {
  33      const { event, externalContent } =
  34        typeof stuff === 'string'
  35          ? { event: undefined, externalContent: stuff }
  36          : { event: stuff, externalContent: undefined }
  37      const key = event ? getEventKey(event) : externalContent
  38      const oldStats = this.stuffStatsMap.get(key)
  39      let since: number | undefined
  40      if (oldStats?.updatedAt) {
  41        since = oldStats.updatedAt
  42      }
  43      const [relayList, authorProfile] = event
  44        ? await Promise.all([client.fetchRelayList(event.pubkey), client.fetchProfile(event.pubkey)])
  45        : []
  46  
  47      const replaceableCoordinate =
  48        event && isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) : undefined
  49  
  50      const filters: Filter[] = []
  51  
  52      if (event) {
  53        filters.push(
  54          {
  55            '#e': [event.id],
  56            kinds: [kinds.Reaction],
  57            limit: 500
  58          },
  59          {
  60            '#e': [event.id],
  61            kinds: [kinds.Repost],
  62            limit: 100
  63          }
  64        )
  65      } else {
  66        filters.push({
  67          '#i': [externalContent],
  68          kinds: [ExtendedKind.EXTERNAL_CONTENT_REACTION],
  69          limit: 500
  70        })
  71      }
  72  
  73      if (replaceableCoordinate) {
  74        filters.push(
  75          {
  76            '#a': [replaceableCoordinate],
  77            kinds: [kinds.Reaction],
  78            limit: 500
  79          },
  80          {
  81            '#a': [replaceableCoordinate],
  82            kinds: [kinds.Repost],
  83            limit: 100
  84          }
  85        )
  86      }
  87  
  88      if (event && authorProfile?.lightningAddress) {
  89        filters.push({
  90          '#e': [event.id],
  91          kinds: [kinds.Zap],
  92          limit: 500
  93        })
  94  
  95        if (replaceableCoordinate) {
  96          filters.push({
  97            '#a': [replaceableCoordinate],
  98            kinds: [kinds.Zap],
  99            limit: 500
 100          })
 101        }
 102      }
 103  
 104      if (pubkey) {
 105        filters.push(
 106          event
 107            ? {
 108                '#e': [event.id],
 109                authors: [pubkey],
 110                kinds:
 111                  event.kind === kinds.ShortTextNote
 112                    ? [kinds.Reaction, kinds.Repost]
 113                    : [kinds.Reaction, kinds.Repost, kinds.GenericRepost]
 114              }
 115            : {
 116                '#i': [externalContent],
 117                authors: [pubkey],
 118                kinds: [ExtendedKind.EXTERNAL_CONTENT_REACTION]
 119              }
 120        )
 121  
 122        if (replaceableCoordinate) {
 123          filters.push({
 124            '#a': [replaceableCoordinate],
 125            authors: [pubkey],
 126            kinds: [kinds.Reaction, kinds.Repost, kinds.GenericRepost]
 127          })
 128        }
 129  
 130        if (event && authorProfile?.lightningAddress) {
 131          filters.push({
 132            '#e': [event.id],
 133            '#P': [pubkey],
 134            kinds: [kinds.Zap]
 135          })
 136  
 137          if (replaceableCoordinate) {
 138            filters.push({
 139              '#a': [replaceableCoordinate],
 140              '#P': [pubkey],
 141              kinds: [kinds.Zap]
 142            })
 143          }
 144        }
 145      }
 146  
 147      if (since) {
 148        filters.forEach((filter) => {
 149          filter.since = since
 150        })
 151      }
 152  
 153      const relays = relayList ? relayList.read.concat(client.currentRelays).slice(0, 5) : client.currentRelays
 154  
 155      const events: Event[] = []
 156      await client.fetchEvents(relays, filters, {
 157        onevent: (evt) => {
 158          this.updateStuffStatsByEvents([evt])
 159          events.push(evt)
 160        }
 161      })
 162      this.stuffStatsMap.set(key, {
 163        ...(this.stuffStatsMap.get(key) ?? {}),
 164        updatedAt: dayjs().unix()
 165      })
 166      return this.stuffStatsMap.get(key) ?? {}
 167    }
 168  
 169    subscribeStuffStats(stuffKey: string, callback: () => void) {
 170      let set = this.stuffStatsSubscribers.get(stuffKey)
 171      if (!set) {
 172        set = new Set()
 173        this.stuffStatsSubscribers.set(stuffKey, set)
 174      }
 175      set.add(callback)
 176      return () => {
 177        set?.delete(callback)
 178        if (set?.size === 0) this.stuffStatsSubscribers.delete(stuffKey)
 179      }
 180    }
 181  
 182    private notifyStuffStats(stuffKey: string) {
 183      const set = this.stuffStatsSubscribers.get(stuffKey)
 184      if (set) {
 185        set.forEach((cb) => cb())
 186      }
 187    }
 188  
 189    getStuffStats(stuffKey: string): Partial<TStuffStats> | undefined {
 190      return this.stuffStatsMap.get(stuffKey)
 191    }
 192  
 193    addZap(
 194      pubkey: string,
 195      eventId: string,
 196      pr: string,
 197      amount: number,
 198      comment?: string,
 199      created_at: number = dayjs().unix(),
 200      notify: boolean = true
 201    ) {
 202      const old = this.stuffStatsMap.get(eventId) || {}
 203      const zapPrSet = old.zapPrSet || new Set()
 204      const zaps = old.zaps || []
 205      if (zapPrSet.has(pr)) return
 206  
 207      zapPrSet.add(pr)
 208      zaps.push({ pr, pubkey, amount, comment, created_at })
 209      this.stuffStatsMap.set(eventId, { ...old, zapPrSet, zaps })
 210      if (notify) {
 211        this.notifyStuffStats(eventId)
 212      }
 213      return eventId
 214    }
 215  
 216    updateStuffStatsByEvents(events: Event[]) {
 217      const targetKeySet = new Set<string>()
 218      events.forEach((evt) => {
 219        let targetKey: string | undefined
 220        if (evt.kind === kinds.Reaction) {
 221          targetKey = this.addLikeByEvent(evt)
 222        } else if (evt.kind === ExtendedKind.EXTERNAL_CONTENT_REACTION) {
 223          targetKey = this.addExternalContentLikeByEvent(evt)
 224        } else if (evt.kind === kinds.Repost || evt.kind === kinds.GenericRepost) {
 225          targetKey = this.addRepostByEvent(evt)
 226        } else if (evt.kind === kinds.Zap) {
 227          targetKey = this.addZapByEvent(evt)
 228        }
 229        if (targetKey) {
 230          targetKeySet.add(targetKey)
 231        }
 232      })
 233      targetKeySet.forEach((targetKey) => {
 234        this.notifyStuffStats(targetKey)
 235      })
 236    }
 237  
 238    private addLikeByEvent(evt: Event) {
 239      let targetEventKey
 240      targetEventKey = evt.tags.findLast(tagNameEquals('a'))?.[1]
 241      if (!targetEventKey) {
 242        targetEventKey = evt.tags.findLast(tagNameEquals('e'))?.[1]
 243      }
 244  
 245      if (!targetEventKey) {
 246        return
 247      }
 248  
 249      const old = this.stuffStatsMap.get(targetEventKey) || {}
 250      const likeIdSet = old.likeIdSet || new Set()
 251      const likes = old.likes || []
 252      if (likeIdSet.has(evt.id)) return
 253  
 254      let emoji: TEmoji | string = evt.content.trim()
 255      if (!emoji) return
 256  
 257      if (emoji.startsWith(':') && emoji.endsWith(':')) {
 258        const emojiInfos = getEmojiInfosFromEmojiTags(evt.tags)
 259        const shortcode = emoji.split(':')[1]
 260        const emojiInfo = emojiInfos.find((info) => info.shortcode === shortcode)
 261        if (emojiInfo) {
 262          emoji = emojiInfo
 263        } else {
 264          emoji = '+'
 265        }
 266      }
 267  
 268      likeIdSet.add(evt.id)
 269      likes.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at, emoji })
 270      this.stuffStatsMap.set(targetEventKey, { ...old, likeIdSet, likes })
 271      return targetEventKey
 272    }
 273  
 274    private addExternalContentLikeByEvent(evt: Event) {
 275      const target = evt.tags.findLast(tagNameEquals('i'))?.[1]
 276      if (!target) return
 277  
 278      const old = this.stuffStatsMap.get(target) || {}
 279      const likeIdSet = old.likeIdSet || new Set()
 280      const likes = old.likes || []
 281      if (likeIdSet.has(evt.id)) return
 282  
 283      let emoji: TEmoji | string = evt.content.trim()
 284      if (!emoji) return
 285  
 286      if (emoji.startsWith(':') && emoji.endsWith(':')) {
 287        const emojiInfos = getEmojiInfosFromEmojiTags(evt.tags)
 288        const shortcode = emoji.split(':')[1]
 289        const emojiInfo = emojiInfos.find((info) => info.shortcode === shortcode)
 290        if (emojiInfo) {
 291          emoji = emojiInfo
 292        } else {
 293          emoji = '+'
 294        }
 295      }
 296  
 297      likeIdSet.add(evt.id)
 298      likes.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at, emoji })
 299      this.stuffStatsMap.set(target, { ...old, likeIdSet, likes })
 300      return target
 301    }
 302  
 303    private addRepostByEvent(evt: Event) {
 304      let targetEventKey
 305      targetEventKey = evt.tags.find(tagNameEquals('a'))?.[1]
 306      if (!targetEventKey) {
 307        targetEventKey = evt.tags.find(tagNameEquals('e'))?.[1]
 308      }
 309  
 310      if (!targetEventKey) {
 311        return
 312      }
 313  
 314      const old = this.stuffStatsMap.get(targetEventKey) || {}
 315      const repostPubkeySet = old.repostPubkeySet || new Set()
 316      const reposts = old.reposts || []
 317      if (repostPubkeySet.has(evt.pubkey)) return
 318  
 319      repostPubkeySet.add(evt.pubkey)
 320      reposts.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at })
 321      this.stuffStatsMap.set(targetEventKey, { ...old, repostPubkeySet, reposts })
 322      return targetEventKey
 323    }
 324  
 325    private addZapByEvent(evt: Event) {
 326      const info = getZapInfoFromEvent(evt)
 327      if (!info) return
 328      const { originalEventId, senderPubkey, invoice, amount, comment } = info
 329      if (!originalEventId || !senderPubkey) return
 330  
 331      return this.addZap(
 332        senderPubkey,
 333        originalEventId,
 334        invoice,
 335        amount,
 336        comment,
 337        evt.created_at,
 338        false
 339      )
 340    }
 341  }
 342  
 343  const instance = new StuffStatsService()
 344  
 345  export default instance
 346