From 0e4e8c548a18a5342953bfce8c14c8d84a0b3a78 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sat, 3 Oct 2026 18:39:54 +0700 Subject: [PATCH] feat(spots): aggregate community confidence from all engagement signals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `useSpotCommunityScore(spot)` — ONE batched relay query covering photos, status reports, comments, reactions, zaps, collections and tag suggestions for a spot, then a distinct-account, tier-weighted score: - counts DISTINCT accounts and never the mapper's own signals; - weights costly signals higher (zap 3, photo/status 2, comment/reaction/ collection/tag 1) and caps each account's contribution at 3; - multiplies by the actor's web-of-trust + real-activity (reusing useBatchTrustScores/useBatchActivity) so a ring of empty accounts can't move it; - returns level (unverified | seen | community) + a breakdown. SpotDetailPage now derives the Community-verified badge from this (not just photos+status) and shows a small "why" line (e.g. "2 photos · 1 zap · 3 comments") under the badges. No new event kinds. --- src/hooks/useSpotCommunityScore.ts | 213 +++++++++++++++++++++++++++++ src/i18n/en.ts | 7 + src/i18n/es.ts | 7 + src/pages/SpotDetailPage.tsx | 41 ++++-- 4 files changed, 260 insertions(+), 8 deletions(-) create mode 100644 src/hooks/useSpotCommunityScore.ts diff --git a/src/hooks/useSpotCommunityScore.ts b/src/hooks/useSpotCommunityScore.ts new file mode 100644 index 0000000..3418c67 --- /dev/null +++ b/src/hooks/useSpotCommunityScore.ts @@ -0,0 +1,213 @@ +/** + * 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 } 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; +} + +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. */ +const WEIGHT = { + photo: 2, + status: 2, + zap: 3, // real money + 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. */ +const PER_ACCOUNT_CAP = 3; +/** Weighted score needed to reach "community" (with ≥2 distinct skaters). */ +const COMMUNITY_THRESHOLD = 2; + +interface AccountSignals { + photos: number; + statusReports: number; + comments: number; + reactions: number; + zaps: number; + zapSats: number; + collections: number; + tagSuggestions: number; +} + +const emptyBreakdown = (): SpotCommunityBreakdown => ({ + skaters: 0, photos: 0, statusReports: 0, comments: 0, + reactions: 0, zaps: 0, zapSats: 0, collections: 0, tagSuggestions: 0, +}); + +function classify(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'; + default: return 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 useSpotCommunityScore( + spot: Pick<{ pubkey: string; identifier: string; id: string }, 'pubkey' | 'identifier' | 'id'> | null | undefined, +) { + const { querySkateContent } = useSkateRelays(); + const coord = spot ? `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}` : undefined; + + const query = useQuery({ + queryKey: ['spot-community-score', coord, spot?.id], + 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 }, + ], + { 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 = classify(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, + }; + 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.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; + 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(() => { + if (breakdown.skaters === 0) return 'unverified'; + const strong = breakdown.zaps > 0; + if ((breakdown.skaters >= 2 && score >= COMMUNITY_THRESHOLD) || strong) return 'community'; + return 'seen'; + }, [breakdown, score]); + + return { + level, + score, + breakdown, + isLoading: query.isLoading, + } satisfies SpotCommunityScore; +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 219d3fa..d7c49ac 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -1131,6 +1131,13 @@ spotUi: { newOwnerNpubHint: 'Enter the npub of the user you want to transfer this spot to.', nip05RequiredToClaim: '⚠️ NIP-05 verification required to claim shops', noCommunitySignalTitle: 'No community photo or status report yet — nobody has confirmed this place.', + breakdownPhotos: '{{count}} photo(s)', + breakdownStatus: '{{count}} status report(s)', + breakdownComments: '{{count}} comment(s)', + breakdownReactions: '{{count}} reaction(s)', + breakdownCollections: '{{count}} save(s)', + breakdownZaps: '{{count}} zap(s)', + breakdownTags: '{{count}} tag suggestion(s)', noGearListedYet: 'No gear listed yet', noMessagesYet: 'No messages yet in this local channel. Say hi — "anyone skating tonight?"', noPhoto: 'No photo', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 41dba4b..a3905d5 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -1131,6 +1131,13 @@ spotUi: { newOwnerNpubHint: 'Introduce el npub del usuario al que quieres transferir este spot.', nip05RequiredToClaim: '⚠️ Se requiere verificación NIP-05 para reclamar tiendas', noCommunitySignalTitle: 'Aún no hay foto de la comunidad ni reporte de estado: nadie ha confirmado este lugar.', + breakdownPhotos: '{{count}} foto(s)', + breakdownStatus: '{{count}} reporte(s) de estado', + breakdownComments: '{{count}} comentario(s)', + breakdownReactions: '{{count}} reacción(es)', + breakdownCollections: '{{count}} guardado(s)', + breakdownZaps: '{{count}} zap(s)', + breakdownTags: '{{count}} etiqueta(s) sugerida(s)', noGearListedYet: 'Aún no hay material publicado', noMessagesYet: 'Aún no hay mensajes en este canal local. Saluda: "¿alguien patina esta noche?"', noPhoto: 'Sin foto', diff --git a/src/pages/SpotDetailPage.tsx b/src/pages/SpotDetailPage.tsx index abaad72..406a896 100644 --- a/src/pages/SpotDetailPage.tsx +++ b/src/pages/SpotDetailPage.tsx @@ -91,6 +91,7 @@ import { ReportSpotStatusDialog } from '@/components/ReportSpotStatusDialog'; import { useSpotStatus } from '@/hooks/useSpotStatus'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useSpotPhotos, useAggregatedSpotTags } from '@/hooks/useSpotContributions'; +import { useSpotCommunityScore } from '@/hooks/useSpotCommunityScore'; import { AlertDialog, AlertDialogAction, @@ -175,14 +176,29 @@ export default function SpotDetailPage() { const { aggregated: spotStatus } = useSpotStatus(pubkey || '', identifier || ''); /** - * Community confidence — a signal INDEPENDENT of the weather score, so a - * test/off-topic entry can't look endorsed just because the sun is out. - * Counts things only other skaters can produce: a community photo, or a - * status report ("I was here / bust / skate-stopped"). + * Community confidence — aggregated from engagement by OTHER skaters (photos, + * status reports, comments, reactions, zaps, collections, tag suggestions), + * distinct-account counted and weighted by trust/activity. NOT a weather/quality + * score; it answers "have other skaters engaged with this pin?". See + * `useSpotCommunityScore`. */ - const communitySignalCount = - (communityPhotos?.length ?? 0) + (spotStatus?.reporterCount ?? 0); - const communityVerified = communitySignalCount > 0; + const community = useSpotCommunityScore(spot); + const communitySignalCount = community.breakdown.skaters; + const communityVerified = community.level === 'community'; + + // Human-readable "why" — the non-zero signals, strongest first-ish. + const communityBreakdown = useMemo(() => { + const b = community.breakdown; + const parts: string[] = []; + if (b.photos) parts.push(t('spotUi.breakdownPhotos', { count: b.photos })); + if (b.statusReports) parts.push(t('spotUi.breakdownStatus', { count: b.statusReports })); + if (b.comments) parts.push(t('spotUi.breakdownComments', { count: b.comments })); + if (b.reactions) parts.push(t('spotUi.breakdownReactions', { count: b.reactions })); + if (b.collections) parts.push(t('spotUi.breakdownCollections', { count: b.collections })); + if (b.zaps) parts.push(t('spotUi.breakdownZaps', { count: b.zaps })); + if (b.tagSuggestions) parts.push(t('spotUi.breakdownTags', { count: b.tagSuggestions })); + return parts.join(' · '); + }, [community.breakdown, t]); // "Not a skate spot" reports — shown to EVERYONE, so the one-tap report has a // visible effect instead of only creating an entry in the admin queue. @@ -533,7 +549,7 @@ export default function SpotDetailPage() { )} title={ communityVerified - ? t('spotUi.communityVerifiedTitle') + ? `${t('spotUi.communityVerifiedTitle')}${communityBreakdown ? ` — ${communityBreakdown}` : ''}` : t('spotUi.noCommunitySignalTitle') } > @@ -570,6 +586,15 @@ export default function SpotDetailPage() { ))} + {/* Why it's community-verified — the non-zero signals, so the badge + explains itself instead of being a black box. */} + {communityVerified && communityBreakdown && ( +

+ + {communityBreakdown} +

+ )} + {/* Reported as not a skate spot — visible to everyone, not just admins. */} {notASpotReporters > 0 && (