feat(map): make 'Most Zapped' sort actually work + batched zap totals

SortDropdown offered 'Most Zapped' but MapPage's sort switch had no zaps case,
so picking it silently fell back to newest-first. Adds a batched useZapTotals
hook (ONE receipt query for every location) and implements the sort.
This commit is contained in:
SkateSpots 2026-09-26 17:12:14 +07:00
parent 6dc619ee91
commit 83a3e8ae03
2 changed files with 119 additions and 1 deletions

105
src/hooks/useZapTotals.ts Normal file
View file

@ -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 `<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 = 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;
},
});
}

View file

@ -9,6 +9,7 @@ import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
import { WeatherToggle } from '@/components/WeatherToggle'; import { WeatherToggle } from '@/components/WeatherToggle';
import { TrustFilter, type TrustFilterLevel, filterByTrustLevel } from '@/components/TrustFilter'; import { TrustFilter, type TrustFilterLevel, filterByTrustLevel } from '@/components/TrustFilter';
import { SortDropdown, type SortOption, calculateDistance } from '@/components/SortDropdown'; import { SortDropdown, type SortOption, calculateDistance } from '@/components/SortDropdown';
import { useZapTotals, zapTargetKey } from '@/hooks/useZapTotals';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -853,6 +854,12 @@ export default function MapPage() {
return commentCounts?.get(eventId) ?? 0; 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) // Get geohashes for weather fetching (only non-shop spots)
const spotGeohashes = useMemo(() => { const spotGeohashes = useMemo(() => {
const geohashes = allLocations const geohashes = allLocations
@ -959,6 +966,12 @@ export default function MapPage() {
if (commentsB !== commentsA) return commentsB - commentsA; // Most comments first if (commentsB !== commentsA) return commentsB - commentsA; // Most comments first
return b.event.created_at - a.event.created_at; 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': case 'recent':
default: default:
return b.event.created_at - a.event.created_at; // Newest first return b.event.created_at - a.event.created_at; // Newest first
@ -966,7 +979,7 @@ export default function MapPage() {
}); });
return filtered; 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 // Spots shown as MAP MARKERS. Hide spots the community has confirmed as
// "gone" (demolished) so they don't clutter the map — they still appear // "gone" (demolished) so they don't clutter the map — they still appear