SkateSpots/src/hooks/useBatchSpotCommunityScores.ts

124 lines
4.6 KiB
TypeScript

/**
* 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<string, string>();
for (const s of spots) m.set(`${SKATE_SPOT_KIND}:${s.pubkey}:${s.identifier}`, s.pubkey);
return m;
}, [spots]);
return useQuery<Map<string, SpotCommunityMini>>({
queryKey: ['batch-spot-community', refs.slice().sort().join(','), getQueryKeySuffix()],
enabled: refs.length > 0,
staleTime: 2 * 60 * 1000,
queryFn: async (c) => {
const out = new Map<string, SpotCommunityMini>();
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<string, Map<string, AccountSignals>>();
const seen = new Set<string>();
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;
},
});
}