From b285db907dfa185f1c43dfc7d16ea48372a1c672 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sat, 3 Oct 2026 19:28:28 +0700 Subject: [PATCH] feat(map): show community confidence on spot cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- src/components/SpotCard.tsx | 21 +++- src/hooks/useBatchSpotCommunityScores.ts | 124 +++++++++++++++++++++++ src/hooks/useSpotCommunityScore.ts | 29 +++--- src/i18n/en.ts | 2 + src/i18n/es.ts | 2 + src/pages/MapPage.tsx | 14 ++- 6 files changed, 177 insertions(+), 15 deletions(-) create mode 100644 src/hooks/useBatchSpotCommunityScores.ts diff --git a/src/components/SpotCard.tsx b/src/components/SpotCard.tsx index d2ee8d5..8b9d69a 100644 --- a/src/components/SpotCard.tsx +++ b/src/components/SpotCard.tsx @@ -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) { {/* Official badge — only shown for verified founder */} + {/* Community confidence — batched in by the parent list/map. */} + {community?.level === 'community' && ( + + {t('spotUi.communityChip')} + + )} + {community?.level === 'seen' && ( + + {t('spotUi.seenChip')} + + )}
e.preventDefault()}> diff --git a/src/hooks/useBatchSpotCommunityScores.ts b/src/hooks/useBatchSpotCommunityScores.ts new file mode 100644 index 0000000..23e1693 --- /dev/null +++ b/src/hooks/useBatchSpotCommunityScores.ts @@ -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(); + for (const s of spots) m.set(`${SKATE_SPOT_KIND}:${s.pubkey}:${s.identifier}`, s.pubkey); + return m; + }, [spots]); + + return useQuery>({ + queryKey: ['batch-spot-community', refs.slice().sort().join(',')], + enabled: refs.length > 0, + staleTime: 2 * 60 * 1000, + queryFn: async (c) => { + const out = new Map(); + 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>(); + const seen = new Set(); + 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; + }, + }); +} diff --git a/src/hooks/useSpotCommunityScore.ts b/src/hooks/useSpotCommunityScore.ts index 8809b23..ed7bd4c 100644 --- a/src/hooks/useSpotCommunityScore.ts +++ b/src/hooks/useSpotCommunityScore.ts @@ -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, diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 8159798..3e440a7 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index b570e8d..38c5b91 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -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', diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx index 91f678b..60aa3b4 100644 --- a/src/pages/MapPage.tsx +++ b/src/pages/MapPage.tsx @@ -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}`)} /> ))}