/** * Batch community confidence for many spots (map grid / lists). One relay query * for all coords, then a distinct-account, tier-weighted level per spot. * * ⚠️ Unlike the single-spot `useSpotCommunityScore`, the batch does NOT apply * web-of-trust / activity weighting — resolving that for hundreds of actors * would be its own fan-out. It still counts distinct accounts, drops the * mapper's own signals, and caps each account's contribution. */ import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import type { NostrEvent } from '@nostrify/nostrify'; import { useSkateRelays } from '@/hooks/useSkateRelays'; import { SKATE_SPOT_KIND, SKATE_CREW_KIND, LAB_KINDS } from '@/lib/types'; import { WEIGHT, PER_ACCOUNT_CAP, emptyBreakdown, classifySignal, communityLevel, type AccountSignals, type SpotCommunityBreakdown, type SpotCommunityLevel, } from '@/hooks/useSpotCommunityScore'; export interface SpotCommunityMini { level: SpotCommunityLevel; breakdown: SpotCommunityBreakdown; } const blankSignals = (): AccountSignals => ({ photos: 0, statusReports: 0, comments: 0, reactions: 0, zaps: 0, zapSats: 0, collections: 0, tagSuggestions: 0, crews: 0, labRefs: 0, }); export function useBatchSpotCommunityScores( spots: Array<{ id: string; pubkey: string; identifier: string }>, ) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const refs = useMemo( () => spots.map((s) => `${SKATE_SPOT_KIND}:${s.pubkey}:${s.identifier}`), [spots], ); const mapperByRef = useMemo(() => { const m = new Map(); for (const s of spots) m.set(`${SKATE_SPOT_KIND}:${s.pubkey}:${s.identifier}`, s.pubkey); return m; }, [spots]); return useQuery>({ queryKey: ['batch-spot-community', refs.slice().sort().join(','), getQueryKeySuffix()], enabled: refs.length > 0, staleTime: 2 * 60 * 1000, queryFn: async (c) => { const out = new Map(); if (refs.length === 0) return out; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]); const events = await querySkateContent( [ { kinds: [1063], '#a': refs, limit: 400 }, { kinds: [2259], '#a': refs, limit: 400 }, { kinds: [1111], '#A': refs, limit: 400 }, { kinds: [7], '#a': refs, limit: 400 }, { kinds: [9735], '#a': refs, limit: 400 }, { kinds: [30003], '#a': refs, limit: 400 }, { kinds: [SKATE_CREW_KIND], '#a': refs, limit: 300 }, { kinds: [...LAB_KINDS], '#a': refs, limit: 400 }, ], { signal }, ).catch(() => [] as NostrEvent[]); // coord → (account → signals) const perCoord = new Map>(); const seen = new Set(); for (const ev of events) { if (seen.has(ev.id)) continue; seen.add(ev.id); const ref = ev.tags.find(([n, v]) => n === 'a' && v?.startsWith(`${SKATE_SPOT_KIND}:`))?.[1]; if (!ref || !mapperByRef.has(ref)) continue; if (ev.pubkey === mapperByRef.get(ref)) continue; // the mapper's own signals never count const key = classifySignal(ev); if (!key) continue; let accounts = perCoord.get(ref); if (!accounts) { accounts = new Map(); perCoord.set(ref, accounts); } const acc = accounts.get(ev.pubkey) ?? blankSignals(); acc[key] += 1; accounts.set(ev.pubkey, acc); } for (const [ref, accounts] of perCoord) { const b = emptyBreakdown(); let score = 0; for (const a of accounts.values()) { b.photos += a.photos; b.statusReports += a.statusReports; b.comments += a.comments; b.reactions += a.reactions; b.zaps += a.zaps; b.zapSats += a.zapSats; b.collections += a.collections; b.tagSuggestions += a.tagSuggestions; b.crews += a.crews; b.labRefs += a.labRefs; const raw = a.photos * WEIGHT.photo + a.statusReports * WEIGHT.status + a.zaps * WEIGHT.zap + a.comments * WEIGHT.comment + a.reactions * WEIGHT.reaction + a.collections * WEIGHT.collection + a.tagSuggestions * WEIGHT.tagSuggestion + a.crews * WEIGHT.crew + a.labRefs * WEIGHT.labRef; score += Math.min(raw, PER_ACCOUNT_CAP); } b.skaters = accounts.size; out.set(ref, { level: communityLevel(b, score), breakdown: b }); } return out; }, }); }