import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; import type { NostrEvent } from '@nostrify/nostrify'; import { getSatoshisFromBolt11 } from '@/lib/bolt11'; export interface ZapTotal { count: number; totalSats: number; } /** * Stable lookup key for the zap receipts that belong to an event. * Addressable events (30000–39999) are zapped via their `a` coordinate; * everything else via its event id. */ export function zapTargetKey(event: NostrEvent): string { if (event.kind >= 30000 && event.kind < 40000) { const d = event.tags.find(([t]) => t === 'd')?.[1] ?? ''; return `${event.kind}:${event.pubkey}:${d}`; } return event.id; } /** Extract the sats from one kind-9735 zap receipt. Mirrors useZaps' logic. */ export function zapReceiptSats(zap: NostrEvent): number { const amountTag = zap.tags.find(([name]) => name === 'amount')?.[1]; if (amountTag) return Math.floor(parseInt(amountTag, 10) / 1000); const bolt11 = zap.tags.find(([name]) => name === 'bolt11')?.[1]; if (bolt11) { try { return getSatoshisFromBolt11(bolt11); } catch { /* fall through */ } } const description = zap.tags.find(([name]) => name === 'description')?.[1]; if (description) { try { const request = JSON.parse(description); const reqAmount = request.tags?.find(([name]: string[]) => name === 'amount')?.[1]; if (reqAmount) return Math.floor(parseInt(reqAmount, 10) / 1000); } catch { /* fall through */ } } return 0; } /** * Batched zap totals for many events in ONE query per relay. * * Individual ``s each issue their own receipt query; that's fine for * a single detail page but ruinous for a list of 100 cards. This fetches all * receipts for a set of targets at once and returns a `Map` keyed by * {@link zapTargetKey} — use it for sorting/filtering lists by zaps. */ export function useZapTotals(events: NostrEvent[]) { const { nostr } = useNostr(); const { ids, coords } = useMemo(() => { const ids: string[] = []; const coords: string[] = []; for (const ev of events) { if (ev.kind >= 30000 && ev.kind < 40000) coords.push(zapTargetKey(ev)); else ids.push(ev.id); } return { ids: [...new Set(ids)], coords: [...new Set(coords)] }; }, [events]); return useQuery>({ queryKey: ['zap-totals', ids.join(','), coords.join(',')], enabled: ids.length > 0 || coords.length > 0, staleTime: 60 * 1000, queryFn: async (c) => { const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]); const filters: { kinds: number[]; '#e'?: string[]; '#a'?: string[] }[] = []; if (ids.length > 0) filters.push({ kinds: [9735], '#e': ids }); if (coords.length > 0) filters.push({ kinds: [9735], '#a': coords }); const receipts = await nostr.query(filters, { signal }); const wanted = new Set([...ids, ...coords]); const totals = new Map(); for (const zap of receipts) { const sats = zapReceiptSats(zap); // A receipt copies the tags it's paying; count each target it points at. for (const [, value] of zap.tags) { if (!wanted.has(value)) continue; const cur = totals.get(value) ?? { count: 0, totalSats: 0 }; cur.count += 1; cur.totalSats += sats; totals.set(value, cur); } } return totals; }, }); }