/** * Spot community confidence — aggregates the engagement a spot has received * from OTHER skaters into a single level + breakdown. * * Design (see the audit discussion): cheap signals are SHOWN; costly/effortful * ones carry more weight. We count DISTINCT accounts (never the mapper), cap * each account's contribution, and weight by the actor's web-of-trust + real * activity so a ring of empty accounts can't inflate a pin. * * One batched relay query (all engagement kinds for the spot), then classify in * JS. No new event kinds. */ import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import type { NostrEvent } from '@nostrify/nostrify'; import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useBatchTrustScores } from '@/hooks/useBatchTrustScores'; import { useBatchActivity } from '@/hooks/useTradeIntegrity'; import { SKATE_SPOT_KIND, SKATE_CREW_KIND, LAB_KINDS } from '@/lib/types'; import { getSatoshisFromBolt11 } from '@/lib/bolt11'; const PHOTO_KIND = 1063; // NIP-94 file metadata (community photo) const STATUS_KIND = 2259; // spot status report const COMMENT_KIND = 1111; // NIP-22 comment / tag suggestion const REACTION_KIND = 7; // NIP-25 reaction const ZAP_KIND = 9735; // NIP-57 zap receipt const COLLECTION_KIND = 30003; // spot collection (bookmark set) /** Distinct-account counts per signal, plus zap sats. */ export interface SpotCommunityBreakdown { skaters: number; photos: number; statusReports: number; comments: number; reactions: number; zaps: number; zapSats: number; collections: number; tagSuggestions: number; crews: number; labRefs: number; } export type SpotCommunityLevel = 'unverified' | 'seen' | 'community'; export interface SpotCommunityScore { level: SpotCommunityLevel; /** Weighted, distinct-account score. Higher = more/stronger engagement. */ score: number; breakdown: SpotCommunityBreakdown; isLoading: boolean; } /** Per-signal weight. Costly signals weigh more than cheap ones. */ export const WEIGHT = { photo: 2, status: 2, zap: 3, // real money crew: 3, // a whole crew listing it as home turf labRef: 2, // a Lab pro (builder/coach/…) linking the spot comment: 1, reaction: 1, collection: 1, tagSuggestion: 1, } as const; /** One account can contribute at most this much, so a single fan can't carry it. */ export const PER_ACCOUNT_CAP = 3; /** Weighted score needed to reach "community" (with ≥2 distinct skaters). */ const COMMUNITY_THRESHOLD = 2; export interface AccountSignals { photos: number; statusReports: number; comments: number; reactions: number; zaps: number; zapSats: number; collections: number; tagSuggestions: number; crews: number; labRefs: number; } export const emptyBreakdown = (): SpotCommunityBreakdown => ({ skaters: 0, photos: 0, statusReports: 0, comments: 0, reactions: 0, zaps: 0, zapSats: 0, collections: 0, tagSuggestions: 0, crews: 0, labRefs: 0, }); export function classifySignal(ev: NostrEvent): keyof AccountSignals | null { switch (ev.kind) { case PHOTO_KIND: return 'photos'; case STATUS_KIND: return 'statusReports'; case COMMENT_KIND: return ev.tags.some(([n, v]) => n === 't' && v === 'skatespot-tags') ? 'tagSuggestions' : 'comments'; case REACTION_KIND: return 'reactions'; case ZAP_KIND: return 'zaps'; case COLLECTION_KIND: return 'collections'; case SKATE_CREW_KIND: return 'crews'; default: return (LAB_KINDS as readonly number[]).includes(ev.kind) ? 'labRefs' : null; } } function zapSatsFrom(ev: NostrEvent): number { const bolt11 = ev.tags.find(([n]) => n === 'bolt11')?.[1]; if (!bolt11) return 0; try { return getSatoshisFromBolt11(bolt11) || 0; } catch { return 0; } } export function communityLevel(breakdown: SpotCommunityBreakdown, score: number): SpotCommunityLevel { if (breakdown.skaters === 0) return 'unverified'; const strong = breakdown.zaps > 0 || breakdown.crews > 0; if ((breakdown.skaters >= 2 && score >= COMMUNITY_THRESHOLD) || strong) return 'community'; return 'seen'; } export function useSpotCommunityScore( spot: Pick<{ pubkey: string; identifier: string; id: string }, 'pubkey' | 'identifier' | 'id'> | null | undefined, ) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const coord = spot ? `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}` : undefined; const query = useQuery({ queryKey: ['spot-community-score', coord, spot?.id, getQueryKeySuffix()], enabled: !!spot && !!coord, staleTime: 2 * 60 * 1000, queryFn: async () => { if (!spot || !coord) return []; const signal = AbortSignal.timeout(9000); return querySkateContent( [ { kinds: [PHOTO_KIND], '#a': [coord], limit: 100 }, { kinds: [STATUS_KIND], '#a': [coord], limit: 100 }, { kinds: [COMMENT_KIND], '#A': [coord], limit: 100 }, { kinds: [COMMENT_KIND], '#a': [coord], limit: 100 }, // legacy lowercase root { kinds: [REACTION_KIND], '#a': [coord], limit: 100 }, { kinds: [REACTION_KIND], '#e': [spot.id], limit: 100 }, { kinds: [ZAP_KIND], '#a': [coord], limit: 100 }, { kinds: [ZAP_KIND], '#e': [spot.id], limit: 100 }, { kinds: [COLLECTION_KIND], '#a': [coord], limit: 100 }, { kinds: [SKATE_CREW_KIND], '#a': [coord], limit: 50 }, { kinds: [...LAB_KINDS], '#a': [coord], limit: 100 }, ], { signal }, ); }, }); // Per-account signal tally (mapper excluded, events deduped by id). const byAccount = useMemo(() => { const map = new Map(); if (!spot) return map; const seen = new Set(); for (const ev of query.data ?? []) { if (ev.pubkey === spot.pubkey) continue; // the mapper's own signals never count if (seen.has(ev.id)) continue; seen.add(ev.id); const key = classifySignal(ev); if (!key) continue; const acc = map.get(ev.pubkey) ?? { photos: 0, statusReports: 0, comments: 0, reactions: 0, zaps: 0, zapSats: 0, collections: 0, tagSuggestions: 0, crews: 0, labRefs: 0, }; acc[key] += 1; if (key === 'zaps') acc.zapSats += zapSatsFrom(ev); map.set(ev.pubkey, acc); } return map; }, [query.data, spot]); const breakdown = useMemo(() => { const b = emptyBreakdown(); for (const a of byAccount.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; } b.skaters = byAccount.size; return b; }, [byAccount]); // Weight only the (bounded) set of actors, by trust + real activity. const actors = useMemo(() => [...byAccount.keys()].slice(0, 30), [byAccount]); const { data: trust } = useBatchTrustScores(actors); const { data: activity } = useBatchActivity(actors); const score = useMemo(() => { let total = 0; for (const [pk, a] of byAccount) { 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; const capped = Math.min(raw, PER_ACCOUNT_CAP); // Trust (0..100) and activity both raise the multiplier; unknown → neutral 1. const trustFactor = trust ? 0.4 + 0.6 * ((trust.get(pk) ?? 0) / 100) : 1; const activityFactor = activity ? 0.5 + 0.5 * Math.min(1, (activity.get(pk) ?? 0) / 3) : 1; total += capped * trustFactor * activityFactor; } return total; }, [byAccount, trust, activity]); const level: SpotCommunityLevel = useMemo( () => communityLevel(breakdown, score), [breakdown, score], ); return { level, score, breakdown, isLoading: query.isLoading, } satisfies SpotCommunityScore; }