13 changed files with 289 additions and 293 deletions
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
import noteStats from '@/services/note-stats.service' |
||||
import { useSyncExternalStore } from 'react' |
||||
|
||||
export function useNoteStatsById(noteId: string) { |
||||
return useSyncExternalStore( |
||||
(cb) => noteStats.subscribeNoteStats(noteId, cb), |
||||
() => noteStats.getNoteStats(noteId) |
||||
) |
||||
} |
||||
@ -1,243 +0,0 @@
@@ -1,243 +0,0 @@
|
||||
import { extractEmojiInfosFromTags, extractZapInfoFromReceipt } from '@/lib/event' |
||||
import { 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' |
||||
import { createContext, useContext, useEffect, useState } from 'react' |
||||
import { useNostr } from './NostrProvider' |
||||
|
||||
export type TNoteStats = { |
||||
likes: { id: string; pubkey: string; created_at: number; emoji: TEmoji | string }[] |
||||
reposts: Set<string> |
||||
zaps: { pr: string; pubkey: string; amount: number; comment?: string }[] |
||||
updatedAt?: number |
||||
} |
||||
|
||||
type TNoteStatsContext = { |
||||
noteStatsMap: Map<string, Partial<TNoteStats>> |
||||
addZap: (eventId: string, pr: string, amount: number, comment?: string) => void |
||||
updateNoteStatsByEvents: (events: Event[]) => void |
||||
fetchNoteStats: (event: Event) => Promise<Event[]> |
||||
} |
||||
|
||||
const NoteStatsContext = createContext<TNoteStatsContext | undefined>(undefined) |
||||
|
||||
export const useNoteStats = () => { |
||||
const context = useContext(NoteStatsContext) |
||||
if (!context) { |
||||
throw new Error('useNoteStats must be used within a NoteStatsProvider') |
||||
} |
||||
return context |
||||
} |
||||
|
||||
export function NoteStatsProvider({ children }: { children: React.ReactNode }) { |
||||
const [noteStatsMap, setNoteStatsMap] = useState<Map<string, Partial<TNoteStats>>>(new Map()) |
||||
const { pubkey } = useNostr() |
||||
|
||||
useEffect(() => { |
||||
const init = async () => { |
||||
if (!pubkey) return |
||||
const relayList = await client.fetchRelayList(pubkey) |
||||
const events = await client.fetchEvents(relayList.write.slice(0, 4), [ |
||||
{ |
||||
authors: [pubkey], |
||||
kinds: [kinds.Reaction, kinds.Repost], |
||||
limit: 100 |
||||
}, |
||||
{ |
||||
'#P': [pubkey], |
||||
kinds: [kinds.Zap], |
||||
limit: 100 |
||||
} |
||||
]) |
||||
updateNoteStatsByEvents(events) |
||||
} |
||||
init() |
||||
}, [pubkey]) |
||||
|
||||
const fetchNoteStats = async (event: Event) => { |
||||
const oldStats = 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 filters: Filter[] = [ |
||||
{ |
||||
'#e': [event.id], |
||||
kinds: [kinds.Reaction], |
||||
limit: 500 |
||||
}, |
||||
{ |
||||
'#e': [event.id], |
||||
kinds: [kinds.Repost], |
||||
limit: 100 |
||||
} |
||||
] |
||||
|
||||
if (authorProfile?.lightningAddress) { |
||||
filters.push({ |
||||
'#e': [event.id], |
||||
kinds: [kinds.Zap], |
||||
limit: 500 |
||||
}) |
||||
} |
||||
|
||||
if (pubkey) { |
||||
filters.push({ |
||||
'#e': [event.id], |
||||
authors: [pubkey], |
||||
kinds: [kinds.Reaction, kinds.Repost] |
||||
}) |
||||
|
||||
if (authorProfile?.lightningAddress) { |
||||
filters.push({ |
||||
'#e': [event.id], |
||||
'#P': [pubkey], |
||||
kinds: [kinds.Zap] |
||||
}) |
||||
} |
||||
} |
||||
|
||||
if (since) { |
||||
filters.forEach((filter) => { |
||||
filter.since = since |
||||
}) |
||||
} |
||||
const events: Event[] = [] |
||||
await client.fetchEvents(relayList.read.slice(0, 5), filters, { |
||||
onevent(evt) { |
||||
updateNoteStatsByEvents([evt]) |
||||
events.push(evt) |
||||
} |
||||
}) |
||||
setNoteStatsMap((prev) => { |
||||
prev.set(event.id, { ...(prev.get(event.id) ?? {}), updatedAt: dayjs().unix() }) |
||||
return new Map(prev) |
||||
}) |
||||
return events |
||||
} |
||||
|
||||
const updateNoteStatsByEvents = (events: Event[]) => { |
||||
const newRepostsMap = new Map<string, Set<string>>() |
||||
const newLikesMap = new Map< |
||||
string, |
||||
{ id: string; pubkey: string; created_at: number; emoji: TEmoji | string }[] |
||||
>() |
||||
const newZapsMap = new Map< |
||||
string, |
||||
{ pr: string; pubkey: string; amount: number; comment?: string }[] |
||||
>() |
||||
events.forEach((evt) => { |
||||
if (evt.kind === kinds.Repost) { |
||||
const eventId = evt.tags.find(tagNameEquals('e'))?.[1] |
||||
if (!eventId) return |
||||
const newReposts = newRepostsMap.get(eventId) || new Set() |
||||
newReposts.add(evt.pubkey) |
||||
newRepostsMap.set(eventId, newReposts) |
||||
return |
||||
} |
||||
|
||||
if (evt.kind === kinds.Reaction) { |
||||
const targetEventId = evt.tags.findLast(tagNameEquals('e'))?.[1] |
||||
if (targetEventId) { |
||||
const newLikes = newLikesMap.get(targetEventId) || [] |
||||
if (newLikes.some((like) => like.id === evt.id)) return |
||||
|
||||
let emoji: TEmoji | string = evt.content.trim() |
||||
if (!emoji) return |
||||
|
||||
if (/^:[a-zA-Z0-9_-]+:$/.test(evt.content)) { |
||||
const emojiInfos = extractEmojiInfosFromTags(evt.tags) |
||||
const shortcode = evt.content.split(':')[1] |
||||
const emojiInfo = emojiInfos.find((info) => info.shortcode === shortcode) |
||||
if (emojiInfo) { |
||||
emoji = emojiInfo |
||||
} else { |
||||
console.log(`Emoji not found for shortcode: ${shortcode}`, emojiInfos) |
||||
} |
||||
} |
||||
newLikes.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at, emoji }) |
||||
newLikesMap.set(targetEventId, newLikes) |
||||
} |
||||
return |
||||
} |
||||
|
||||
if (evt.kind === kinds.Zap) { |
||||
const info = extractZapInfoFromReceipt(evt) |
||||
if (!info) return |
||||
const { originalEventId, senderPubkey, invoice, amount, comment } = info |
||||
if (!originalEventId || !senderPubkey) return |
||||
const newZaps = newZapsMap.get(originalEventId) || [] |
||||
newZaps.push({ pr: invoice, pubkey: senderPubkey, amount, comment }) |
||||
newZapsMap.set(originalEventId, newZaps) |
||||
return |
||||
} |
||||
}) |
||||
setNoteStatsMap((prev) => { |
||||
newRepostsMap.forEach((newReposts, eventId) => { |
||||
const old = prev.get(eventId) || {} |
||||
const reposts = old.reposts || new Set() |
||||
newReposts.forEach((repost) => reposts.add(repost)) |
||||
prev.set(eventId, { ...old, reposts }) |
||||
}) |
||||
newLikesMap.forEach((newLikes, eventId) => { |
||||
const old = prev.get(eventId) || {} |
||||
const likes = old.likes || [] |
||||
newLikes.forEach((like) => { |
||||
const exists = likes.find((l) => l.id === like.id) |
||||
if (!exists) { |
||||
likes.push(like) |
||||
} |
||||
}) |
||||
likes.sort((a, b) => b.created_at - a.created_at) |
||||
prev.set(eventId, { ...old, likes }) |
||||
}) |
||||
newZapsMap.forEach((newZaps, eventId) => { |
||||
const old = prev.get(eventId) || {} |
||||
const zaps = old.zaps || [] |
||||
const exists = new Set(zaps.map((zap) => zap.pr)) |
||||
newZaps.forEach((zap) => { |
||||
if (!exists.has(zap.pr)) { |
||||
exists.add(zap.pr) |
||||
zaps.push(zap) |
||||
} |
||||
}) |
||||
zaps.sort((a, b) => b.amount - a.amount) |
||||
prev.set(eventId, { ...old, zaps }) |
||||
}) |
||||
return new Map(prev) |
||||
}) |
||||
return |
||||
} |
||||
|
||||
const addZap = (eventId: string, pr: string, amount: number, comment?: string) => { |
||||
if (!pubkey) return |
||||
setNoteStatsMap((prev) => { |
||||
const old = prev.get(eventId) |
||||
const zaps = old?.zaps || [] |
||||
prev.set(eventId, { |
||||
...old, |
||||
zaps: [...zaps, { pr, pubkey, amount, comment }].sort((a, b) => b.amount - a.amount) |
||||
}) |
||||
return new Map(prev) |
||||
}) |
||||
} |
||||
|
||||
return ( |
||||
<NoteStatsContext.Provider |
||||
value={{ |
||||
noteStatsMap, |
||||
fetchNoteStats, |
||||
addZap, |
||||
updateNoteStatsByEvents |
||||
}} |
||||
> |
||||
{children} |
||||
</NoteStatsContext.Provider> |
||||
) |
||||
} |
||||
@ -0,0 +1,205 @@
@@ -0,0 +1,205 @@
|
||||
import { extractEmojiInfosFromTags, extractZapInfoFromReceipt } from '@/lib/event' |
||||
import { 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 = { |
||||
likes: { id: string; pubkey: string; created_at: number; emoji: TEmoji | string }[] |
||||
reposts: Set<string> |
||||
zaps: { pr: string; pubkey: string; amount: 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 filters: Filter[] = [ |
||||
{ |
||||
'#e': [event.id], |
||||
kinds: [kinds.Reaction], |
||||
limit: 500 |
||||
}, |
||||
{ |
||||
'#e': [event.id], |
||||
kinds: [kinds.Repost], |
||||
limit: 100 |
||||
} |
||||
] |
||||
|
||||
if (authorProfile?.lightningAddress) { |
||||
filters.push({ |
||||
'#e': [event.id], |
||||
kinds: [kinds.Zap], |
||||
limit: 500 |
||||
}) |
||||
} |
||||
|
||||
if (pubkey) { |
||||
filters.push({ |
||||
'#e': [event.id], |
||||
authors: [pubkey], |
||||
kinds: [kinds.Reaction, kinds.Repost] |
||||
}) |
||||
|
||||
if (authorProfile?.lightningAddress) { |
||||
filters.push({ |
||||
'#e': [event.id], |
||||
'#P': [pubkey], |
||||
kinds: [kinds.Zap] |
||||
}) |
||||
} |
||||
} |
||||
|
||||
if (since) { |
||||
filters.forEach((filter) => { |
||||
filter.since = since |
||||
}) |
||||
} |
||||
const events: Event[] = [] |
||||
await client.fetchEvents(relayList.read.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 events |
||||
} |
||||
|
||||
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) { |
||||
const old = this.noteStatsMap.get(eventId) |
||||
const zaps = old?.zaps || [] |
||||
this.noteStatsMap.set(eventId, { |
||||
...old, |
||||
zaps: [...zaps, { pr, pubkey, amount, comment }].sort((a, b) => b.amount - a.amount) |
||||
}) |
||||
return this.noteStatsMap |
||||
} |
||||
|
||||
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 likes = old.likes || [] |
||||
const exists = likes.find((l) => l.id === evt.id) |
||||
if (exists) return |
||||
|
||||
let emoji: TEmoji | string = evt.content.trim() |
||||
if (!emoji) return |
||||
|
||||
if (/^:[a-zA-Z0-9_-]+:$/.test(evt.content)) { |
||||
const emojiInfos = extractEmojiInfosFromTags(evt.tags) |
||||
const shortcode = evt.content.split(':')[1] |
||||
const emojiInfo = emojiInfos.find((info) => info.shortcode === shortcode) |
||||
if (emojiInfo) { |
||||
emoji = emojiInfo |
||||
} else { |
||||
console.log(`Emoji not found for shortcode: ${shortcode}`, emojiInfos) |
||||
} |
||||
} |
||||
|
||||
likes.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at, emoji }) |
||||
this.noteStatsMap.set(targetEventId, { ...old, 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 reposts = old.reposts || new Set() |
||||
reposts.add(evt.id) |
||||
this.noteStatsMap.set(eventId, { ...old, reposts }) |
||||
return eventId |
||||
} |
||||
|
||||
private addZapByEvent(evt: Event) { |
||||
const info = extractZapInfoFromReceipt(evt) |
||||
if (!info) return |
||||
const { originalEventId, senderPubkey, invoice, amount, comment } = info |
||||
if (!originalEventId || !senderPubkey) return |
||||
|
||||
const old = this.noteStatsMap.get(originalEventId) || {} |
||||
const zaps = old.zaps || [] |
||||
const exists = zaps.find((zap) => zap.pr === invoice) |
||||
if (exists) return |
||||
|
||||
zaps.push({ pr: invoice, pubkey: senderPubkey, amount, comment }) |
||||
this.noteStatsMap.set(originalEventId, { ...old, zaps }) |
||||
return originalEventId |
||||
} |
||||
} |
||||
|
||||
const instance = new NoteStatsService() |
||||
|
||||
export default instance |
||||
Loading…
Reference in new issue