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.
274 lines
7.4 KiB
274 lines
7.4 KiB
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 |
|
} |
|
// 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<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
|
|
|