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.
105 lines
3.5 KiB
TypeScript
105 lines
3.5 KiB
TypeScript
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;
|
||
},
|
||
});
|
||
}
|