SkateSpots/src/hooks/useZapTotals.ts
SkateSpots 6ae4e674dc feat(leaderboards): zap leaderboards page (top spots + top supporters)
New /leaderboards route (linked from the More menu): aggregates kind-9735
receipts into top-zapped spots and top supporters, with week/month/all-time
tabs. Reuses the batched zap helpers. Also hardens the deploy smoke test to
boot lazy routes (/, /leaderboards, /bounties), since route chunks can fail
independently.
2026-09-26 17:17:21 +07:00

105 lines
3.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 `<ZapButton>`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<Map<string, ZapTotal>>({
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<string>([...ids, ...coords]);
const totals = new Map<string, ZapTotal>();
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;
},
});
}