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:
parent
6dc619ee91
commit
83a3e8ae03
2 changed files with 119 additions and 1 deletions
105
src/hooks/useZapTotals.ts
Normal file
105
src/hooks/useZapTotals.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue