feat: zap (#107)

This commit is contained in:
Cody Tseng 2025-03-01 23:52:05 +08:00 committed by GitHub
parent 407a6fb802
commit 249593d547
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
72 changed files with 2582 additions and 818 deletions

View file

@ -1,26 +1,25 @@
import { extractZapInfoFromReceipt } from '@/lib/event'
import { tagNameEquals } from '@/lib/tag'
import client from '@/services/client.service'
import { Event, kinds } from 'nostr-tools'
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 = {
likeCount: number
repostCount: number
likes: Set<string>
reposts: Set<string>
zaps: { pr: string; pubkey: string; amount: number; comment?: string }[]
replyCount: number
hasLiked: boolean
hasReposted: boolean
updatedAt?: number
}
type TNoteStatsContext = {
noteStatsMap: Map<string, Partial<TNoteStats>>
updateNoteReplyCount: (noteId: string, replyCount: number) => void
markNoteAsLiked: (noteId: string) => void
markNoteAsReposted: (noteId: string) => void
fetchNoteLikeCount: (event: Event) => Promise<number>
fetchNoteRepostCount: (event: Event) => Promise<number>
fetchNoteLikedStatus: (event: Event) => Promise<boolean>
fetchNoteRepostedStatus: (event: Event) => Promise<boolean>
addZap: (eventId: string, pr: string, amount: number, comment?: string) => void
updateNoteStatsByEvents: (events: Event[]) => void
fetchNoteStats: (event: Event) => Promise<Partial<TNoteStats> | undefined>
}
const NoteStatsContext = createContext<TNoteStatsContext | undefined>(undefined)
@ -38,145 +37,183 @@ export function NoteStatsProvider({ children }: { children: React.ReactNode }) {
const { pubkey } = useNostr()
useEffect(() => {
setNoteStatsMap((prev) => {
const newMap = new Map()
for (const [noteId, stats] of prev) {
newMap.set(noteId, { ...stats, hasLiked: undefined, hasReposted: undefined })
}
return newMap
})
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 fetchNoteLikeCount = async (event: Event) => {
const relayList = await client.fetchRelayList(event.pubkey)
const events = await client.fetchEvents(relayList.read.slice(0, 3), {
'#e': [event.id],
kinds: [kinds.Reaction],
limit: 500
})
const countMap = new Map<string, number>()
for (const e of events) {
const targetEventId = e.tags.findLast(tagNameEquals('e'))?.[1]
if (targetEventId) {
countMap.set(targetEventId, (countMap.get(targetEventId) || 0) + 1)
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]
})
}
}
setNoteStatsMap((prev) => {
const newMap = new Map(prev)
for (const [eventId, count] of countMap) {
const old = prev.get(eventId)
newMap.set(
eventId,
old ? { ...old, likeCount: Math.max(count, old.likeCount ?? 0) } : { likeCount: count }
)
}
return newMap
})
return countMap.get(event.id) || 0
}
const fetchNoteRepostCount = async (event: Event) => {
const relayList = await client.fetchRelayList(event.pubkey)
const events = await client.fetchEvents(relayList.read.slice(0, 3), {
'#e': [event.id],
kinds: [kinds.Repost],
limit: 100
})
setNoteStatsMap((prev) => {
const newMap = new Map(prev)
const old = prev.get(event.id)
newMap.set(
event.id,
old
? { ...old, repostCount: Math.max(events.length, old.repostCount ?? 0) }
: { repostCount: events.length }
)
return newMap
})
return events.length
}
const fetchNoteLikedStatus = async (event: Event) => {
if (!pubkey) return false
const relayList = await client.fetchRelayList(pubkey)
const events = await client.fetchEvents(relayList.write, {
'#e': [event.id],
authors: [pubkey],
kinds: [kinds.Reaction]
})
const likedEventIds = events
.map((e) => e.tags.findLast(tagNameEquals('e'))?.[1])
.filter(Boolean) as string[]
setNoteStatsMap((prev) => {
const newMap = new Map(prev)
likedEventIds.forEach((eventId) => {
const old = newMap.get(eventId)
newMap.set(eventId, old ? { ...old, hasLiked: true } : { hasLiked: true })
if (since) {
filters.forEach((filter) => {
filter.since = since
})
if (!likedEventIds.includes(event.id)) {
const old = newMap.get(event.id)
newMap.set(event.id, old ? { ...old, hasLiked: false } : { hasLiked: false })
}
return newMap
}
const events = await client.fetchEvents(relayList.read.slice(0, 4), filters)
updateNoteStatsByEvents(events)
let stats: Partial<TNoteStats> | undefined
setNoteStatsMap((prev) => {
const old = prev.get(event.id) || {}
prev.set(event.id, { ...old, updatedAt: dayjs().unix() })
stats = prev.get(event.id)
return new Map(prev)
})
return likedEventIds.includes(event.id)
return stats
}
const fetchNoteRepostedStatus = async (event: Event) => {
if (!pubkey) return false
const updateNoteStatsByEvents = (events: Event[]) => {
const newRepostsMap = new Map<string, Set<string>>()
const newLikesMap = new Map<string, Set<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
}
const relayList = await client.fetchRelayList(pubkey)
const events = await client.fetchEvents(relayList.write, {
'#e': [event.id],
authors: [pubkey],
kinds: [kinds.Repost]
if (evt.kind === kinds.Reaction) {
const targetEventId = evt.tags.findLast(tagNameEquals('e'))?.[1]
if (targetEventId) {
const newLikes = newLikesMap.get(targetEventId) || new Set()
newLikes.add(evt.pubkey)
newLikesMap.set(targetEventId, newLikes)
}
return
}
if (evt.kind === kinds.Zap) {
const info = extractZapInfoFromReceipt(evt)
if (!info) return
const { eventId, senderPubkey, invoice, amount, comment } = info
if (!eventId || !senderPubkey) return
const newZaps = newZapsMap.get(eventId) || []
newZaps.push({ pr: invoice, pubkey: senderPubkey, amount, comment })
newZapsMap.set(eventId, newZaps)
return
}
})
setNoteStatsMap((prev) => {
const hasReposted = events.length > 0
const newMap = new Map(prev)
const old = prev.get(event.id)
newMap.set(event.id, old ? { ...old, hasReposted } : { hasReposted })
return newMap
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 || new Set()
newLikes.forEach((like) => likes.add(like))
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 events.length > 0
return
}
const updateNoteReplyCount = (noteId: string, replyCount: number) => {
setNoteStatsMap((prev) => {
const old = prev.get(noteId)
if (!old) {
return new Map(prev).set(noteId, { replyCount })
prev.set(noteId, { replyCount })
return new Map(prev)
} else if (old.replyCount === undefined || old.replyCount < replyCount) {
return new Map(prev).set(noteId, { ...old, replyCount })
prev.set(noteId, { ...old, replyCount })
return new Map(prev)
}
return prev
})
}
const markNoteAsLiked = (noteId: string) => {
const addZap = (eventId: string, pr: string, amount: number, comment?: string) => {
if (!pubkey) return
setNoteStatsMap((prev) => {
const old = prev.get(noteId)
return new Map(prev).set(
noteId,
old
? { ...old, hasLiked: true, likeCount: (old.likeCount ?? 0) + 1 }
: { hasLiked: true, likeCount: 1 }
)
})
}
const markNoteAsReposted = (noteId: string) => {
setNoteStatsMap((prev) => {
const old = prev.get(noteId)
return new Map(prev).set(
noteId,
old
? { ...old, hasReposted: true, repostCount: (old.repostCount ?? 0) + 1 }
: { hasReposted: true, repostCount: 1 }
)
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)
})
}
@ -184,13 +221,10 @@ export function NoteStatsProvider({ children }: { children: React.ReactNode }) {
<NoteStatsContext.Provider
value={{
noteStatsMap,
fetchNoteLikeCount,
fetchNoteLikedStatus,
fetchNoteRepostCount,
fetchNoteRepostedStatus,
fetchNoteStats,
updateNoteReplyCount,
markNoteAsLiked,
markNoteAsReposted
addZap,
updateNoteStatsByEvents
}}
>
{children}