diff --git a/src/App.tsx b/src/App.tsx index 22eb285b..d461dfe5 100644 --- a/src/App.tsx +++ b/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 { - - - - - - + + + + diff --git a/src/components/NoteStats/LikeButton.tsx b/src/components/NoteStats/LikeButton.tsx index 8d1dec24..291a046d 100644 --- a/src/components/NoteStats/LikeButton.tsx +++ b/src/components/NoteStats/LikeButton.tsx @@ -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 { diff --git a/src/components/NoteStats/Likes.tsx b/src/components/NoteStats/Likes.tsx index 621ce3b8..4b62875a 100644 --- a/src/components/NoteStats/Likes.tsx +++ b/src/components/NoteStats/Likes.tsx @@ -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(null) const likes = useMemo(() => { - const _likes = noteStatsMap.get(event.id)?.likes + const _likes = noteStats?.likes if (!_likes) return [] const stats = new Map }>() @@ -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 { diff --git a/src/components/NoteStats/RepostButton.tsx b/src/components/NoteStats/RepostButton.tsx index f27c8789..10a41757 100644 --- a/src/components/NoteStats/RepostButton.tsx +++ b/src/components/NoteStats/RepostButton.tsx @@ -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 { diff --git a/src/components/NoteStats/TopZaps.tsx b/src/components/NoteStats/TopZaps.tsx index 560b08fe..6378fead 100644 --- a/src/components/NoteStats/TopZaps.tsx +++ b/src/components/NoteStats/TopZaps.tsx @@ -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 diff --git a/src/components/NoteStats/ZapButton.tsx b/src/components/NoteStats/ZapButton.tsx index 4faed6c7..6889fee2 100644 --- a/src/components/NoteStats/ZapButton.tsx +++ b/src/components/NoteStats/ZapButton.tsx @@ -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 | 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 { diff --git a/src/components/NoteStats/index.tsx b/src/components/NoteStats/index.tsx index f9017962..bb27cb1a 100644 --- a/src/components/NoteStats/index.tsx +++ b/src/components/NoteStats/index.tsx @@ -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) { diff --git a/src/components/NotificationList/index.tsx b/src/components/NotificationList/index.tsx index ec6ae072..151e31fe 100644 --- a/src/components/NotificationList/index.tsx +++ b/src/components/NotificationList/index.tsx @@ -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('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]) } } ) diff --git a/src/components/ZapDialog/index.tsx b/src/components/ZapDialog/index.tsx index e7e1d253..33fc6225 100644 --- a/src/components/ZapDialog/index.tsx +++ b/src/components/ZapDialog/index.tsx @@ -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}`) diff --git a/src/hooks/useNoteStatsById.tsx b/src/hooks/useNoteStatsById.tsx new file mode 100644 index 00000000..4d4ef74b --- /dev/null +++ b/src/hooks/useNoteStatsById.tsx @@ -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) + ) +} diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 75061c42..88376927 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -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 diff --git a/src/providers/NoteStatsProvider.tsx b/src/providers/NoteStatsProvider.tsx deleted file mode 100644 index 6f33b6c4..00000000 --- a/src/providers/NoteStatsProvider.tsx +++ /dev/null @@ -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 - zaps: { pr: string; pubkey: string; amount: number; comment?: string }[] - updatedAt?: number -} - -type TNoteStatsContext = { - noteStatsMap: Map> - addZap: (eventId: string, pr: string, amount: number, comment?: string) => void - updateNoteStatsByEvents: (events: Event[]) => void - fetchNoteStats: (event: Event) => Promise -} - -const NoteStatsContext = createContext(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>>(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>() - 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 ( - - {children} - - ) -} diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts new file mode 100644 index 00000000..c355ea11 --- /dev/null +++ b/src/services/note-stats.service.ts @@ -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 + zaps: { pr: string; pubkey: string; amount: number; comment?: string }[] + updatedAt?: number +} + +class NoteStatsService { + static instance: NoteStatsService + private noteStatsMap: Map> = new Map() + private noteStatsSubscribers = new Map 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 | 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() + 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