feat(map): show community confidence on spot cards

- New `useBatchSpotCommunityScores(spots)` — ONE query for many spots (same
  signals/weights as the single-spot hook; counts distinct accounts, drops the
  mapper, caps per account; skip WoT/activity weighting at this scale).
- SpotCard takes an optional `community` prop and shows a small "Community"
  (green) or "Seen" chip in the footer.
- MapPage scores the visible spot cards (shops excluded — different kind) and
  passes each card its score.
This commit is contained in:
SkateSpots 2026-10-03 19:28:28 +07:00
parent 77a3d592ea
commit b285db907d
6 changed files with 177 additions and 15 deletions

View file

@ -11,10 +11,11 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { MapPin, Store, Bookmark, MoreVertical, Flag } from 'lucide-react';
import { MapPin, Store, Bookmark, MoreVertical, Flag, ShieldCheck } from 'lucide-react';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useVenuesWithPasses } from '@/hooks/useRiderCard';
import type { SpotCommunityMini } from '@/hooks/useBatchSpotCommunityScores';
import { genUserName } from '@/lib/genUserName';
import { ZapButton } from '@/components/ZapButton';
import { TrustBadge } from '@/components/TrustBadge';
@ -34,9 +35,11 @@ interface SpotCardProps {
spot: SkateSpot;
/** Optional community status, passed in batched from the parent list/map. */
status?: AggregatedSpotStatus | null;
/** Optional community confidence, passed in batched from the parent list/map. */
community?: SpotCommunityMini;
}
export function SpotCard({ spot, status }: SpotCardProps) {
export function SpotCard({ spot, status, community }: SpotCardProps) {
const { t } = useTranslation();
const author = useAuthor(spot.pubkey);
const { user } = useCurrentUser();
@ -192,6 +195,20 @@ export function SpotCard({ spot, status }: SpotCardProps) {
<TrustBadge pubkey={spot.pubkey} size="sm" />
{/* Official badge — only shown for verified founder */}
<OfficialBadge pubkey={spot.pubkey} size="sm" />
{/* Community confidence — batched in by the parent list/map. */}
{community?.level === 'community' && (
<span
className="inline-flex items-center gap-0.5 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-bold uppercase text-emerald-400"
title={t('spotUi.communityVerifiedTitle')}
>
<ShieldCheck className="h-2.5 w-2.5" /> {t('spotUi.communityChip')}
</span>
)}
{community?.level === 'seen' && (
<span className="inline-flex items-center rounded-full bg-white/5 px-1.5 py-0.5 text-[9px] font-bold uppercase text-gray-500">
{t('spotUi.seenChip')}
</span>
)}
</div>
<div className="flex items-center gap-2" onClick={(e) => e.preventDefault()}>

View file

@ -0,0 +1,124 @@
/**
* 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 } = 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(',')],
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;
},
});
}

View file

@ -53,7 +53,7 @@ export interface SpotCommunityScore {
}
/** Per-signal weight. Costly signals weigh more than cheap ones. */
const WEIGHT = {
export const WEIGHT = {
photo: 2,
status: 2,
zap: 3, // real money
@ -66,11 +66,11 @@ const WEIGHT = {
} as const;
/** One account can contribute at most this much, so a single fan can't carry it. */
const PER_ACCOUNT_CAP = 3;
export const PER_ACCOUNT_CAP = 3;
/** Weighted score needed to reach "community" (with ≥2 distinct skaters). */
const COMMUNITY_THRESHOLD = 2;
interface AccountSignals {
export interface AccountSignals {
photos: number;
statusReports: number;
comments: number;
@ -83,13 +83,13 @@ interface AccountSignals {
labRefs: number;
}
const emptyBreakdown = (): SpotCommunityBreakdown => ({
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,
});
function classify(ev: NostrEvent): keyof AccountSignals | null {
export function classifySignal(ev: NostrEvent): keyof AccountSignals | null {
switch (ev.kind) {
case PHOTO_KIND: return 'photos';
case STATUS_KIND: return 'statusReports';
@ -114,6 +114,13 @@ function zapSatsFrom(ev: NostrEvent): number {
}
}
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,
) {
@ -155,7 +162,7 @@ export function useSpotCommunityScore(
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);
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,
@ -212,12 +219,10 @@ export function useSpotCommunityScore(
return total;
}, [byAccount, trust, activity]);
const level: SpotCommunityLevel = useMemo(() => {
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';
}, [breakdown, score]);
const level: SpotCommunityLevel = useMemo(
() => communityLevel(breakdown, score),
[breakdown, score],
);
return {
level,

View file

@ -1143,6 +1143,8 @@ spotUi: {
verifyNudgeTitle: 'Be the first to confirm this spot',
verifyNudgeBody: "A photo or an “I've skated here” is how a spot gets verified — help the next skater out.",
verifyNudgeStatus: "I've skated here",
communityChip: 'Community',
seenChip: 'Seen',
noGearListedYet: 'No gear listed yet',
noMessagesYet: 'No messages yet in this local channel. Say hi — "anyone skating tonight?"',
noPhoto: 'No photo',

View file

@ -1143,6 +1143,8 @@ spotUi: {
verifyNudgeTitle: 'Sé el primero en confirmar este spot',
verifyNudgeBody: 'Una foto o un «he patinado aquí» es como se verifica un spot: ayuda al siguiente skater.',
verifyNudgeStatus: 'He patinado aquí',
communityChip: 'Comunidad',
seenChip: 'Visto',
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',

View file

@ -24,6 +24,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { LoginArea } from '@/components/auth/LoginArea';
import { useSkateSpots } from '@/hooks/useSkateSpots';
import { useBatchSpotCommunityScores } from '@/hooks/useBatchSpotCommunityScores';
import { useRelayStatus } from '@/hooks/useRelayStatus';
import { useAutoHiddenSpots } from '@/hooks/useAutoHiddenSpots';
import { useOnlineStatus } from '@/hooks/useOfflineCache';
@ -46,7 +47,7 @@ import { useSatsToUsd } from '@/hooks/useExchangeRate';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { genUserName } from '@/lib/genUserName';
import { useTranslation } from '@/contexts/language';
import { SPOT_CATEGORIES, SHELTER_INFO, SKATE_RELAY } from '@/lib/types';
import { SPOT_CATEGORIES, SHELTER_INFO, SKATE_RELAY, SKATE_SPOT_KIND } from '@/lib/types';
import type { SkateSpot } from '@/lib/types';
import { spotTypeLabel } from '@/lib/spotTypeLabel';
import ngeohash from 'ngeohash';
@ -1018,6 +1019,16 @@ export default function MapPage() {
return filtered;
}, [allLocations, searchQuery, selectedCategories, weatherEnabled, weatherData, weatherFilter, showSpots, showShops, trustFilter, getTrustScore, trustScores, sortBy, userLocation, getCommentCount, getZapTotal]);
// Community confidence for the cards (batched, one query). Shops use a
// different kind, so only spots are scored.
const communitySpots = useMemo(
() => filteredSpots
.filter((s) => !s.isShop)
.map((s) => ({ id: s.id, pubkey: s.pubkey, identifier: s.identifier })),
[filteredSpots],
);
const { data: communityScores } = useBatchSpotCommunityScores(communitySpots);
// Spots shown as MAP MARKERS. Hide spots the community has confirmed as
// "gone" (demolished) so they don't clutter the map — they still appear
// (dimmed, with a badge) in the card list below so the record isn't lost.
@ -2064,6 +2075,7 @@ export default function MapPage() {
key={spot.id}
spot={spot}
status={spot.isShop ? null : getSpotStatus(spot.pubkey, spot.identifier)}
community={communityScores?.get(`${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`)}
/>
))}
</div>