refactor: optimize note interaction data processing
This commit is contained in:
11
src/App.tsx
11
src/App.tsx
@@ -12,7 +12,6 @@ import { FollowListProvider } from './providers/FollowListProvider'
|
||||
import { MediaUploadServiceProvider } from './providers/MediaUploadServiceProvider'
|
||||
import { MuteListProvider } from './providers/MuteListProvider'
|
||||
import { NostrProvider } from './providers/NostrProvider'
|
||||
import { NoteStatsProvider } from './providers/NoteStatsProvider'
|
||||
import { ReplyProvider } from './providers/ReplyProvider'
|
||||
import { ScreenSizeProvider } from './providers/ScreenSizeProvider'
|
||||
import { TranslationServiceProvider } from './providers/TranslationServiceProvider'
|
||||
@@ -34,12 +33,10 @@ export default function App(): JSX.Element {
|
||||
<BookmarksProvider>
|
||||
<FeedProvider>
|
||||
<ReplyProvider>
|
||||
<NoteStatsProvider>
|
||||
<MediaUploadServiceProvider>
|
||||
<PageManager />
|
||||
<Toaster />
|
||||
</MediaUploadServiceProvider>
|
||||
</NoteStatsProvider>
|
||||
<MediaUploadServiceProvider>
|
||||
<PageManager />
|
||||
<Toaster />
|
||||
</MediaUploadServiceProvider>
|
||||
</ReplyProvider>
|
||||
</FeedProvider>
|
||||
</BookmarksProvider>
|
||||
|
||||
@@ -4,10 +4,11 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
|
||||
import { createReactionDraftEvent } from '@/lib/draft-event'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import noteStatsService from '@/services/note-stats.service'
|
||||
import { Loader, SmilePlus } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo, useState } from 'react'
|
||||
@@ -21,15 +22,15 @@ export default function LikeButton({ event }: { event: Event }) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { pubkey, publish, checkLogin } = useNostr()
|
||||
const { noteStatsMap, updateNoteStatsByEvents, fetchNoteStats } = useNoteStats()
|
||||
const [liking, setLiking] = useState(false)
|
||||
const [isEmojiReactionsOpen, setIsEmojiReactionsOpen] = useState(false)
|
||||
const [isPickerOpen, setIsPickerOpen] = useState(false)
|
||||
const noteStats = useNoteStatsById(event.id)
|
||||
const { myLastEmoji, likeCount } = useMemo(() => {
|
||||
const stats = noteStatsMap.get(event.id) || {}
|
||||
const stats = noteStats || {}
|
||||
const like = stats.likes?.find((like) => like.pubkey === pubkey)
|
||||
return { myLastEmoji: like?.emoji, likeCount: stats.likes?.length }
|
||||
}, [noteStatsMap, event, pubkey])
|
||||
}, [noteStats, pubkey])
|
||||
|
||||
const like = async (emoji: string) => {
|
||||
checkLogin(async () => {
|
||||
@@ -39,14 +40,13 @@ export default function LikeButton({ event }: { event: Event }) {
|
||||
const timer = setTimeout(() => setLiking(false), 10_000)
|
||||
|
||||
try {
|
||||
const noteStats = noteStatsMap.get(event.id)
|
||||
if (!noteStats?.updatedAt) {
|
||||
await fetchNoteStats(event)
|
||||
await noteStatsService.fetchNoteStats(event, pubkey)
|
||||
}
|
||||
|
||||
const reaction = createReactionDraftEvent(event, emoji)
|
||||
const evt = await publish(reaction)
|
||||
updateNoteStatsByEvents([evt])
|
||||
noteStatsService.updateNoteStatsByEvents([evt])
|
||||
} catch (error) {
|
||||
console.error('like failed', error)
|
||||
} finally {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
||||
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
|
||||
import { createReactionDraftEvent } from '@/lib/draft-event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
||||
import noteStatsService from '@/services/note-stats.service'
|
||||
import { TEmoji } from '@/types'
|
||||
import { Loader } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
@@ -11,10 +12,10 @@ import Emoji from '../Emoji'
|
||||
|
||||
export default function Likes({ event }: { event: Event }) {
|
||||
const { pubkey, checkLogin, publish } = useNostr()
|
||||
const { noteStatsMap, updateNoteStatsByEvents } = useNoteStats()
|
||||
const noteStats = useNoteStatsById(event.id)
|
||||
const [liking, setLiking] = useState<string | null>(null)
|
||||
const likes = useMemo(() => {
|
||||
const _likes = noteStatsMap.get(event.id)?.likes
|
||||
const _likes = noteStats?.likes
|
||||
if (!_likes) return []
|
||||
|
||||
const stats = new Map<string, { key: string; emoji: TEmoji | string; pubkeys: Set<string> }>()
|
||||
@@ -26,7 +27,7 @@ export default function Likes({ event }: { event: Event }) {
|
||||
stats.get(key)?.pubkeys.add(item.pubkey)
|
||||
})
|
||||
return Array.from(stats.values()).sort((a, b) => b.pubkeys.size - a.pubkeys.size)
|
||||
}, [noteStatsMap, event])
|
||||
}, [noteStats, event])
|
||||
|
||||
if (!likes.length) return null
|
||||
|
||||
@@ -40,7 +41,7 @@ export default function Likes({ event }: { event: Event }) {
|
||||
try {
|
||||
const reaction = createReactionDraftEvent(event, emoji)
|
||||
const evt = await publish(reaction)
|
||||
updateNoteStatsByEvents([evt])
|
||||
noteStatsService.updateNoteStatsByEvents([evt])
|
||||
} catch (error) {
|
||||
console.error('like failed', error)
|
||||
} finally {
|
||||
|
||||
@@ -6,12 +6,13 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
|
||||
import { createRepostDraftEvent } from '@/lib/draft-event'
|
||||
import { getSharableEventId } from '@/lib/event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import noteStatsService from '@/services/note-stats.service'
|
||||
import { Loader, PencilLine, Repeat } from 'lucide-react'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { useMemo, useState } from 'react'
|
||||
@@ -23,17 +24,16 @@ export default function RepostButton({ event }: { event: Event }) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { publish, checkLogin, pubkey } = useNostr()
|
||||
const { noteStatsMap, updateNoteStatsByEvents, fetchNoteStats } = useNoteStats()
|
||||
const noteStats = useNoteStatsById(event.id)
|
||||
const [reposting, setReposting] = useState(false)
|
||||
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false)
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
|
||||
const { repostCount, hasReposted } = useMemo(() => {
|
||||
const stats = noteStatsMap.get(event.id) || {}
|
||||
return {
|
||||
repostCount: stats.reposts?.size,
|
||||
hasReposted: pubkey ? stats.reposts?.has(pubkey) : false
|
||||
repostCount: noteStats?.reposts?.size,
|
||||
hasReposted: pubkey ? noteStats?.reposts?.has(pubkey) : false
|
||||
}
|
||||
}, [noteStatsMap, event.id])
|
||||
}, [noteStats, event.id])
|
||||
const canRepost = !hasReposted && !reposting
|
||||
|
||||
const repost = async () => {
|
||||
@@ -44,11 +44,10 @@ export default function RepostButton({ event }: { event: Event }) {
|
||||
const timer = setTimeout(() => setReposting(false), 5000)
|
||||
|
||||
try {
|
||||
const noteStats = noteStatsMap.get(event.id)
|
||||
const hasReposted = noteStats?.reposts?.has(pubkey)
|
||||
if (hasReposted) return
|
||||
if (!noteStats?.updatedAt) {
|
||||
const events = await fetchNoteStats(event)
|
||||
const events = await noteStatsService.fetchNoteStats(event, pubkey)
|
||||
if (events.some((e) => e.kind === kinds.Repost && e.pubkey === pubkey)) {
|
||||
return
|
||||
}
|
||||
@@ -56,7 +55,7 @@ export default function RepostButton({ event }: { event: Event }) {
|
||||
|
||||
const repost = createRepostDraftEvent(event)
|
||||
const evt = await publish(repost)
|
||||
updateNoteStatsByEvents([evt])
|
||||
noteStatsService.updateNoteStatsByEvents([evt])
|
||||
} catch (error) {
|
||||
console.error('repost failed', error)
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
||||
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
|
||||
import { formatAmount } from '@/lib/lightning'
|
||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
||||
import { Zap } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo, useState } from 'react'
|
||||
@@ -8,12 +8,11 @@ import { SimpleUserAvatar } from '../UserAvatar'
|
||||
import ZapDialog from '../ZapDialog'
|
||||
|
||||
export default function TopZaps({ event }: { event: Event }) {
|
||||
const { noteStatsMap } = useNoteStats()
|
||||
const noteStats = useNoteStatsById(event.id)
|
||||
const [zapIndex, setZapIndex] = useState(-1)
|
||||
const topZaps = useMemo(() => {
|
||||
const stats = noteStatsMap.get(event.id) || {}
|
||||
return stats.zaps?.slice(0, 10) || []
|
||||
}, [noteStatsMap, event])
|
||||
return noteStats?.zaps?.sort((a, b) => b.amount - a.amount).slice(0, 10) || []
|
||||
}, [noteStats])
|
||||
|
||||
if (!topZaps.length) return null
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
|
||||
import { getLightningAddressFromProfile } from '@/lib/lightning'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
||||
import { useZap } from '@/providers/ZapProvider'
|
||||
import client from '@/services/client.service'
|
||||
import lightning from '@/services/lightning.service'
|
||||
import noteStatsService from '@/services/note-stats.service'
|
||||
import { Loader, Zap } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { MouseEvent, TouchEvent, useEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -15,18 +16,17 @@ import ZapDialog from '../ZapDialog'
|
||||
export default function ZapButton({ event }: { event: Event }) {
|
||||
const { t } = useTranslation()
|
||||
const { checkLogin, pubkey } = useNostr()
|
||||
const { noteStatsMap, addZap } = useNoteStats()
|
||||
const noteStats = useNoteStatsById(event.id)
|
||||
const { defaultZapSats, defaultZapComment, quickZap } = useZap()
|
||||
const [touchStart, setTouchStart] = useState<{ x: number; y: number } | null>(null)
|
||||
const [openZapDialog, setOpenZapDialog] = useState(false)
|
||||
const [zapping, setZapping] = useState(false)
|
||||
const { zapAmount, hasZapped } = useMemo(() => {
|
||||
const stats = noteStatsMap.get(event.id) || {}
|
||||
return {
|
||||
zapAmount: stats.zaps?.reduce((acc, zap) => acc + zap.amount, 0),
|
||||
hasZapped: pubkey ? stats.zaps?.some((zap) => zap.pubkey === pubkey) : false
|
||||
zapAmount: noteStats?.zaps?.reduce((acc, zap) => acc + zap.amount, 0),
|
||||
hasZapped: pubkey ? noteStats?.zaps?.some((zap) => zap.pubkey === pubkey) : false
|
||||
}
|
||||
}, [noteStatsMap, event, pubkey])
|
||||
}, [noteStats, pubkey])
|
||||
const [disable, setDisable] = useState(true)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const isLongPressRef = useRef(false)
|
||||
@@ -57,7 +57,13 @@ export default function ZapButton({ event }: { event: Event }) {
|
||||
if (!zapResult) {
|
||||
return
|
||||
}
|
||||
addZap(event.id, zapResult.invoice, defaultZapSats, defaultZapComment)
|
||||
noteStatsService.addZap(
|
||||
pubkey,
|
||||
event.id,
|
||||
zapResult.invoice,
|
||||
defaultZapSats,
|
||||
defaultZapComment
|
||||
)
|
||||
} catch (error) {
|
||||
toast.error(`${t('Zap failed')}: ${(error as Error).message}`)
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import noteStatsService from '@/services/note-stats.service'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useEffect, useState } from 'react'
|
||||
import BookmarkButton from '../BookmarkButton'
|
||||
@@ -28,13 +29,13 @@ export default function NoteStats({
|
||||
displayTopZapsAndLikes?: boolean
|
||||
}) {
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { fetchNoteStats } = useNoteStats()
|
||||
const { pubkey } = useNostr()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetchIfNotExisting) return
|
||||
setLoading(true)
|
||||
fetchNoteStats(event).finally(() => setLoading(false))
|
||||
noteStatsService.fetchNoteStats(event, pubkey).finally(() => setLoading(false))
|
||||
}, [event, fetchIfNotExisting])
|
||||
|
||||
if (isSmallScreen) {
|
||||
|
||||
@@ -3,10 +3,10 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
|
||||
import { usePrimaryPage } from '@/PageManager'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
||||
import { useNotification } from '@/providers/NotificationProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
import client from '@/services/client.service'
|
||||
import noteStatsService from '@/services/note-stats.service'
|
||||
import { TNotificationType } from '@/types'
|
||||
import dayjs from 'dayjs'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
@@ -25,7 +25,6 @@ const NotificationList = forwardRef((_, ref) => {
|
||||
const { pubkey } = useNostr()
|
||||
const { hideUntrustedNotifications, isUserTrusted } = useUserTrust()
|
||||
const { clearNewNotifications, getNotificationsSeenAt } = useNotification()
|
||||
const { updateNoteStatsByEvents } = useNoteStats()
|
||||
const [notificationType, setNotificationType] = useState<TNotificationType>('all')
|
||||
const [lastReadTime, setLastReadTime] = useState(0)
|
||||
const [refreshCount, setRefreshCount] = useState(0)
|
||||
@@ -95,7 +94,7 @@ const NotificationList = forwardRef((_, ref) => {
|
||||
if (eosed) {
|
||||
setLoading(false)
|
||||
setUntil(events.length > 0 ? events[events.length - 1].created_at - 1 : undefined)
|
||||
updateNoteStatsByEvents(events)
|
||||
noteStatsService.updateNoteStatsByEvents(events)
|
||||
}
|
||||
},
|
||||
onNew: (event) => {
|
||||
@@ -109,7 +108,7 @@ const NotificationList = forwardRef((_, ref) => {
|
||||
}
|
||||
return [...oldEvents.slice(0, index), event, ...oldEvents.slice(index)]
|
||||
})
|
||||
updateNoteStatsByEvents([event])
|
||||
noteStatsService.updateNoteStatsByEvents([event])
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -16,10 +16,10 @@ import {
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { useZap } from '@/providers/ZapProvider'
|
||||
import lightning from '@/services/lightning.service'
|
||||
import noteStatsService from '@/services/note-stats.service'
|
||||
import { Loader } from 'lucide-react'
|
||||
import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -136,7 +136,6 @@ function ZapDialogContent({
|
||||
const { t } = useTranslation()
|
||||
const { pubkey } = useNostr()
|
||||
const { defaultZapSats, defaultZapComment } = useZap()
|
||||
const { addZap } = useNoteStats()
|
||||
const [sats, setSats] = useState(defaultAmount ?? defaultZapSats)
|
||||
const [comment, setComment] = useState(defaultComment ?? defaultZapComment)
|
||||
const [zapping, setZapping] = useState(false)
|
||||
@@ -155,7 +154,7 @@ function ZapDialogContent({
|
||||
return
|
||||
}
|
||||
if (eventId) {
|
||||
addZap(eventId, zapResult.invoice, sats, comment)
|
||||
noteStatsService.addZap(pubkey, eventId, zapResult.invoice, sats, comment)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`${t('Zap failed')}: ${(error as Error).message}`)
|
||||
|
||||
9
src/hooks/useNoteStatsById.tsx
Normal file
9
src/hooks/useNoteStatsById.tsx
Normal file
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { formatPubkey, isValidPubkey, pubkeyToNpub } from '@/lib/pubkey'
|
||||
import client from '@/services/client.service'
|
||||
import indexedDb from '@/services/indexed-db.service'
|
||||
import storage from '@/services/local-storage.service'
|
||||
import noteStatsService from '@/services/note-stats.service'
|
||||
import { ISigner, TAccount, TAccountPointer, TDraftEvent, TProfile, TRelayList } from '@/types'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -276,6 +277,29 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}, [account])
|
||||
|
||||
useEffect(() => {
|
||||
if (!account) return
|
||||
|
||||
const initInteractions = async () => {
|
||||
const pubkey = account.pubkey
|
||||
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
|
||||
}
|
||||
])
|
||||
noteStatsService.updateNoteStatsByEvents(events)
|
||||
}
|
||||
initInteractions()
|
||||
}, [account])
|
||||
|
||||
useEffect(() => {
|
||||
if (signer) {
|
||||
client.signer = signer
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
205
src/services/note-stats.service.ts
Normal file
205
src/services/note-stats.service.ts
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user