feat(spots): aggregate community confidence from all engagement signals
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.
This commit is contained in:
parent
4d30240df9
commit
0e4e8c548a
4 changed files with 260 additions and 8 deletions
213
src/hooks/useSpotCommunityScore.ts
Normal file
213
src/hooks/useSpotCommunityScore.ts
Normal file
|
|
@ -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<NostrEvent[]>({
|
||||
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<string, AccountSignals>();
|
||||
if (!spot) return map;
|
||||
const seen = new Set<string>();
|
||||
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<SpotCommunityBreakdown>(() => {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* Why it's community-verified — the non-zero signals, so the badge
|
||||
explains itself instead of being a black box. */}
|
||||
{communityVerified && communityBreakdown && (
|
||||
<p className="mt-2 flex items-center gap-1 text-[11px] text-gray-500">
|
||||
<ShieldCheck className="h-3 w-3 text-emerald-500/70" />
|
||||
{communityBreakdown}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Reported as not a skate spot — visible to everyone, not just admins. */}
|
||||
{notASpotReporters > 0 && (
|
||||
<div className="mt-3 flex items-start gap-2.5 rounded-xl border border-red-500/40 bg-red-500/10 p-3.5">
|
||||
|
|
|
|||
Loading…
Reference in a new issue