diff --git a/src/hooks/useZapTotals.ts b/src/hooks/useZapTotals.ts new file mode 100644 index 0000000..e94e63b --- /dev/null +++ b/src/hooks/useZapTotals.ts @@ -0,0 +1,105 @@ +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. */ +function receiptSats(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 = receiptSats(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; + }, + }); +} diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx index e404cfb..81c3877 100644 --- a/src/pages/MapPage.tsx +++ b/src/pages/MapPage.tsx @@ -9,6 +9,7 @@ import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard'; import { WeatherToggle } from '@/components/WeatherToggle'; import { TrustFilter, type TrustFilterLevel, filterByTrustLevel } from '@/components/TrustFilter'; import { SortDropdown, type SortOption, calculateDistance } from '@/components/SortDropdown'; +import { useZapTotals, zapTargetKey } from '@/hooks/useZapTotals'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; @@ -853,6 +854,12 @@ export default function MapPage() { return commentCounts?.get(eventId) ?? 0; }; + // Batched zap totals for "Most Zapped" sorting (ONE query for every location). + const locationEvents = useMemo(() => allLocations.map((l) => l.event), [allLocations]); + const { data: zapTotals } = useZapTotals(locationEvents); + const getZapTotal = (event: (typeof locationEvents)[number]) => + zapTotals?.get(zapTargetKey(event))?.totalSats ?? 0; + // Get geohashes for weather fetching (only non-shop spots) const spotGeohashes = useMemo(() => { const geohashes = allLocations @@ -959,6 +966,12 @@ export default function MapPage() { if (commentsB !== commentsA) return commentsB - commentsA; // Most comments first return b.event.created_at - a.event.created_at; } + case 'zaps': { + const zapsA = getZapTotal(a.event); + const zapsB = getZapTotal(b.event); + if (zapsB !== zapsA) return zapsB - zapsA; // Most zapped first + return b.event.created_at - a.event.created_at; + } case 'recent': default: return b.event.created_at - a.event.created_at; // Newest first @@ -966,7 +979,7 @@ export default function MapPage() { }); return filtered; - }, [allLocations, searchQuery, selectedCategories, weatherEnabled, weatherData, weatherFilter, showSpots, showShops, trustFilter, trustScores, sortBy, userLocation, commentCounts]); + }, [allLocations, searchQuery, selectedCategories, weatherEnabled, weatherData, weatherFilter, showSpots, showShops, trustFilter, trustScores, sortBy, userLocation, commentCounts, zapTotals]); // Spots shown as MAP MARKERS. Hide spots the community has confirmed as // "gone" (demolished) so they don't clutter the map — they still appear