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 { 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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue