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:
parent
77a3d592ea
commit
b285db907d
6 changed files with 177 additions and 15 deletions
|
|
@ -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()}>
|
||||
|
|
|
|||
124
src/hooks/useBatchSpotCommunityScores.ts
Normal file
124
src/hooks/useBatchSpotCommunityScores.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue