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