feat(spots): crews + Lab links count as strong signals; add a verify nudge

- useSpotCommunityScore now also reads crews (35006, `a` ref, weight 3) and Lab
  profile links (35001-35014, `a` ref, weight 2) as effortful signals — a whole
  crew listing the spot, or a Lab pro linking it, is strong evidence. A crew
  ref alone reaches "community". (Tours/events use geohash, not a spot coord,
  so they're not counted.)
- Spot page: on an unconfirmed spot, a logged-in non-owner now sees a "Be the
  first to confirm this spot" nudge with Add-photo + "I've skated here".
- Breakdown line/tooltip include crew + Lab counts.
This commit is contained in:
SkateSpots 2026-10-03 18:48:19 +07:00
parent 1a9a5ae967
commit 77a3d592ea
4 changed files with 46 additions and 5 deletions

View file

@ -17,7 +17,7 @@ import type { NostrEvent } from '@nostrify/nostrify';
import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores'; import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { useBatchActivity } from '@/hooks/useTradeIntegrity'; import { useBatchActivity } from '@/hooks/useTradeIntegrity';
import { SKATE_SPOT_KIND } from '@/lib/types'; import { SKATE_SPOT_KIND, SKATE_CREW_KIND, LAB_KINDS } from '@/lib/types';
import { getSatoshisFromBolt11 } from '@/lib/bolt11'; import { getSatoshisFromBolt11 } from '@/lib/bolt11';
const PHOTO_KIND = 1063; // NIP-94 file metadata (community photo) const PHOTO_KIND = 1063; // NIP-94 file metadata (community photo)
@ -38,6 +38,8 @@ export interface SpotCommunityBreakdown {
zapSats: number; zapSats: number;
collections: number; collections: number;
tagSuggestions: number; tagSuggestions: number;
crews: number;
labRefs: number;
} }
export type SpotCommunityLevel = 'unverified' | 'seen' | 'community'; export type SpotCommunityLevel = 'unverified' | 'seen' | 'community';
@ -55,6 +57,8 @@ const WEIGHT = {
photo: 2, photo: 2,
status: 2, status: 2,
zap: 3, // real money 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, comment: 1,
reaction: 1, reaction: 1,
collection: 1, collection: 1,
@ -75,11 +79,14 @@ interface AccountSignals {
zapSats: number; zapSats: number;
collections: number; collections: number;
tagSuggestions: number; tagSuggestions: number;
crews: number;
labRefs: number;
} }
const emptyBreakdown = (): SpotCommunityBreakdown => ({ const emptyBreakdown = (): SpotCommunityBreakdown => ({
skaters: 0, photos: 0, statusReports: 0, comments: 0, skaters: 0, photos: 0, statusReports: 0, comments: 0,
reactions: 0, zaps: 0, zapSats: 0, collections: 0, tagSuggestions: 0, reactions: 0, zaps: 0, zapSats: 0, collections: 0, tagSuggestions: 0,
crews: 0, labRefs: 0,
}); });
function classify(ev: NostrEvent): keyof AccountSignals | null { function classify(ev: NostrEvent): keyof AccountSignals | null {
@ -91,7 +98,9 @@ function classify(ev: NostrEvent): keyof AccountSignals | null {
case REACTION_KIND: return 'reactions'; case REACTION_KIND: return 'reactions';
case ZAP_KIND: return 'zaps'; case ZAP_KIND: return 'zaps';
case COLLECTION_KIND: return 'collections'; case COLLECTION_KIND: return 'collections';
default: return null; case SKATE_CREW_KIND: return 'crews';
default:
return (LAB_KINDS as readonly number[]).includes(ev.kind) ? 'labRefs' : null;
} }
} }
@ -129,6 +138,8 @@ export function useSpotCommunityScore(
{ kinds: [ZAP_KIND], '#a': [coord], limit: 100 }, { kinds: [ZAP_KIND], '#a': [coord], limit: 100 },
{ kinds: [ZAP_KIND], '#e': [spot.id], limit: 100 }, { kinds: [ZAP_KIND], '#e': [spot.id], limit: 100 },
{ kinds: [COLLECTION_KIND], '#a': [coord], 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 }, { signal },
); );
@ -147,7 +158,7 @@ export function useSpotCommunityScore(
const key = classify(ev); const key = classify(ev);
if (!key) continue; if (!key) continue;
const acc = map.get(ev.pubkey) ?? { const acc = map.get(ev.pubkey) ?? {
photos: 0, statusReports: 0, comments: 0, reactions: 0, zaps: 0, zapSats: 0, collections: 0, tagSuggestions: 0, photos: 0, statusReports: 0, comments: 0, reactions: 0, zaps: 0, zapSats: 0, collections: 0, tagSuggestions: 0, crews: 0, labRefs: 0,
}; };
acc[key] += 1; acc[key] += 1;
if (key === 'zaps') acc.zapSats += zapSatsFrom(ev); if (key === 'zaps') acc.zapSats += zapSatsFrom(ev);
@ -167,6 +178,8 @@ export function useSpotCommunityScore(
b.zapSats += a.zapSats; b.zapSats += a.zapSats;
b.collections += a.collections; b.collections += a.collections;
b.tagSuggestions += a.tagSuggestions; b.tagSuggestions += a.tagSuggestions;
b.crews += a.crews;
b.labRefs += a.labRefs;
} }
b.skaters = byAccount.size; b.skaters = byAccount.size;
return b; return b;
@ -187,7 +200,9 @@ export function useSpotCommunityScore(
a.comments * WEIGHT.comment + a.comments * WEIGHT.comment +
a.reactions * WEIGHT.reaction + a.reactions * WEIGHT.reaction +
a.collections * WEIGHT.collection + a.collections * WEIGHT.collection +
a.tagSuggestions * WEIGHT.tagSuggestion; a.tagSuggestions * WEIGHT.tagSuggestion +
a.crews * WEIGHT.crew +
a.labRefs * WEIGHT.labRef;
const capped = Math.min(raw, PER_ACCOUNT_CAP); const capped = Math.min(raw, PER_ACCOUNT_CAP);
// Trust (0..100) and activity both raise the multiplier; unknown → neutral 1. // 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 trustFactor = trust ? 0.4 + 0.6 * ((trust.get(pk) ?? 0) / 100) : 1;
@ -199,7 +214,7 @@ export function useSpotCommunityScore(
const level: SpotCommunityLevel = useMemo(() => { const level: SpotCommunityLevel = useMemo(() => {
if (breakdown.skaters === 0) return 'unverified'; if (breakdown.skaters === 0) return 'unverified';
const strong = breakdown.zaps > 0; const strong = breakdown.zaps > 0 || breakdown.crews > 0;
if ((breakdown.skaters >= 2 && score >= COMMUNITY_THRESHOLD) || strong) return 'community'; if ((breakdown.skaters >= 2 && score >= COMMUNITY_THRESHOLD) || strong) return 'community';
return 'seen'; return 'seen';
}, [breakdown, score]); }, [breakdown, score]);

View file

@ -1138,6 +1138,11 @@ spotUi: {
breakdownCollections: '{{count}} save(s)', breakdownCollections: '{{count}} save(s)',
breakdownZaps: '{{count}} zap(s)', breakdownZaps: '{{count}} zap(s)',
breakdownTags: '{{count}} tag suggestion(s)', breakdownTags: '{{count}} tag suggestion(s)',
breakdownCrews: '{{count}} crew(s)',
breakdownLab: '{{count}} pro link(s)',
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",
noGearListedYet: 'No gear listed yet', noGearListedYet: 'No gear listed yet',
noMessagesYet: 'No messages yet in this local channel. Say hi — "anyone skating tonight?"', noMessagesYet: 'No messages yet in this local channel. Say hi — "anyone skating tonight?"',
noPhoto: 'No photo', noPhoto: 'No photo',

View file

@ -1138,6 +1138,11 @@ spotUi: {
breakdownCollections: '{{count}} guardado(s)', breakdownCollections: '{{count}} guardado(s)',
breakdownZaps: '{{count}} zap(s)', breakdownZaps: '{{count}} zap(s)',
breakdownTags: '{{count}} etiqueta(s) sugerida(s)', breakdownTags: '{{count}} etiqueta(s) sugerida(s)',
breakdownCrews: '{{count}} cuadrilla(s)',
breakdownLab: '{{count}} enlace(s) pro',
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í',
noGearListedYet: 'Aún no hay material publicado', noGearListedYet: 'Aún no hay material publicado',
noMessagesYet: 'Aún no hay mensajes en este canal local. Saluda: "¿alguien patina esta noche?"', noMessagesYet: 'Aún no hay mensajes en este canal local. Saluda: "¿alguien patina esta noche?"',
noPhoto: 'Sin foto', noPhoto: 'Sin foto',

View file

@ -197,6 +197,8 @@ export default function SpotDetailPage() {
if (b.collections) parts.push(t('spotUi.breakdownCollections', { count: b.collections })); if (b.collections) parts.push(t('spotUi.breakdownCollections', { count: b.collections }));
if (b.zaps) parts.push(t('spotUi.breakdownZaps', { count: b.zaps })); if (b.zaps) parts.push(t('spotUi.breakdownZaps', { count: b.zaps }));
if (b.tagSuggestions) parts.push(t('spotUi.breakdownTags', { count: b.tagSuggestions })); if (b.tagSuggestions) parts.push(t('spotUi.breakdownTags', { count: b.tagSuggestions }));
if (b.crews) parts.push(t('spotUi.breakdownCrews', { count: b.crews }));
if (b.labRefs) parts.push(t('spotUi.breakdownLab', { count: b.labRefs }));
return parts.join(' · '); return parts.join(' · ');
}, [community.breakdown, t]); }, [community.breakdown, t]);
@ -595,6 +597,20 @@ export default function SpotDetailPage() {
</p> </p>
)} )}
{/* Verify nudge — a logged-in non-owner on an unconfirmed spot. */}
{!isShop && user && !isOwner && !communityVerified && !staffVerified && (
<div className="mt-3 rounded-xl border border-emerald-500/25 bg-emerald-500/[0.06] p-3.5">
<p className="text-sm font-bold text-emerald-300">{t('spotUi.verifyNudgeTitle')}</p>
<p className="mt-0.5 text-xs text-gray-400">{t('spotUi.verifyNudgeBody')}</p>
<div className="mt-2.5 flex flex-wrap items-center gap-2">
<AddPhotoToSpot spotPubkey={spot.pubkey} spotIdentifier={spot.identifier} spotTitle={spot.title} />
<Button type="button" variant="outline" size="sm" onClick={() => setShowStatusDialog(true)}>
{t('spotUi.verifyNudgeStatus')}
</Button>
</div>
</div>
)}
{/* Reported as not a skate spot — visible to everyone, not just admins. */} {/* Reported as not a skate spot — visible to everyone, not just admins. */}
{notASpotReporters > 0 && ( {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"> <div className="mt-3 flex items-start gap-2.5 rounded-xl border border-red-500/40 bg-red-500/10 p-3.5">