233 lines
8.2 KiB
TypeScript
233 lines
8.2 KiB
TypeScript
/**
|
|
* 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<NostrEvent[]>({
|
|
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<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 = 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<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.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;
|
|
}
|