13 changed files with 289 additions and 293 deletions
@ -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 @@ |
|||||||
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 @@ |
|||||||
|
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