273 lines
7.3 KiB
TypeScript
273 lines
7.3 KiB
TypeScript
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<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 }[]
|
|
updatedAt?: number
|
|
}
|
|
|
|
class NoteStatsService {
|
|
static instance: NoteStatsService
|
|
private noteStatsMap: Map<string, Partial<TNoteStats>> = new Map()
|
|
private noteStatsSubscribers = new Map<string, Set<() => 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
|
|
}
|
|
const [relayList, authorProfile] = await Promise.all([
|
|
client.fetchRelayList(event.pubkey),
|
|
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<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
|
|
}
|
|
|
|
updateNoteStatsByEvents(events: Event[]) {
|
|
const updatedEventIdSet = new Set<string>()
|
|
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
|