From 96f7c3192d029b71f0128b13d7634a2f4fd22200 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Mon, 10 Aug 2026 22:02:22 +0700 Subject: [PATCH] =?UTF-8?q?Interconnect=20Music=20=E2=86=94=20Events:=20fe?= =?UTF-8?q?atured=20musician=20on=20events=20+=20gig=20list=20on=20musicia?= =?UTF-8?q?n=20profiles;=20count=20new=20Lab=20crafts=20in=20growth=20stat?= =?UTF-8?q?s;=20document=20visibility/vouch/gig=20conventions=20in=20NIP.m?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: shakespeare.diy --- NIP.md | 73 +++++++++++++++++++++++++++++++ src/components/CrossLinkCards.tsx | 5 +++ src/hooks/useGigs.ts | 55 +++++++++++++++++++++++ src/hooks/useGrowthStats.ts | 14 +++++- src/pages/CoachDetailPage.tsx | 59 +++++++++++++++++++++++++ src/pages/CreateEventPage.tsx | 34 +++++++++++++- src/pages/EventDetailPage.tsx | 45 ++++++++++++++----- 7 files changed, 273 insertions(+), 12 deletions(-) create mode 100644 src/hooks/useGigs.ts diff --git a/NIP.md b/NIP.md index 92f4e67..0a1a2cf 100644 --- a/NIP.md +++ b/NIP.md @@ -1579,6 +1579,53 @@ attestations. A member is: Removing a member: the founder drops the `p` tag, or the member deletes their label event (NIP-09). Either direction flips the member out of "confirmed". +#### Member visibility (opt-in privacy) + +A member controls their own discovery visibility **on their own attestation** +(not the founder's event), via an optional `visibility` tag: + +```json +["visibility", "public" | "crew"] +``` + +| Value | Meaning | +|-------|---------| +| `public` *(default, tag absent)* | Listed everywhere — public crew page, search, discovery. | +| `crew` | Hidden from non-members. To outsiders the member renders as an anonymous "hidden member" slot (`+N hidden`); to the founder and confirmed crewmates they render normally. | + +This is **discovery privacy** at the app layer, controlled by the member's own +key — so a public crew can contain a fully-hidden member. Note that the `p` tag +on the founder's event is public regardless; `visibility` governs how clients +*display* the member, not whether the pubkey exists on relays. Clients determine +"is the viewer a crewmate?" by checking whether the viewer is the founder or a +confirmed member before revealing `crew`-scoped members. + +### Scene vouches (NIP-32 web-of-trust anti-fraud) + +Any user can **vouch** for a Lab profile — an independent, signed attestation +that the provider is legit. This is a lightweight web-of-trust layer to fight +impersonation and fraud without gatekeeping. A vouch is a kind `1985` label +signed with the voucher's own key, referencing the provider's coordinate: + +```json +{ + "kind": 1985, + "content": "Filmed a full part with them, 100% legit.", + "tags": [ + ["L", "org.skatespots.vouch"], + ["l", "vouch", "org.skatespots.vouch"], + ["a", "35011::"], + ["p", ""], + ["alt", "Scene vouch for a SkateSpots Lab provider"] + ] +} +``` + +Clients aggregate vouches for a coordinate and **trust-weight** them using each +voucher's web-of-trust score, so a handful of vouches from trusted skaters +outweighs many from throwaway keys. A provider cannot vouch for themselves +(self-vouches are ignored). Vouches are revocable via NIP-09 deletion. + ### Photographer (kind 35004) Skate photography is a distinct craft from filming — it is shot-, light-, and @@ -1879,6 +1926,32 @@ carries an `a` tag pointing back at the crew coordinate, plus a readable Clients query a crew's events with `{ kinds: [31923], "#a": ["35006::"] }` and display "Hosted by [crew]" on the event, closing the Crews ↔ Events ↔ Zaps loop. +### Events ↔ Musicians (live music) + +An event may feature a **musician** (Lab kind `35011`) — e.g. a band playing a +jam or comp afterparty. This reuses the exact `a`-tag pattern as crew hosting: the +event carries an `a` tag pointing at the musician's coordinate, plus a readable +`music-act` tag. + +```json +{ + "kind": 31923, + "tags": [ + ["d", "bcn-jam-afterparty"], + ["title", "BCN Jam Afterparty"], + ["start", "1730000000"], + ["t", "skateboarding"], + ["a", "35011::"], + ["music-act", "The Curb Grinders"] + ] +} +``` + +The event page shows a "🎵 Live: [act]" pill. Reciprocally, a musician's Lab +profile queries `{ kinds: [31923], "#a": ["35011::"] }` to list +its **upcoming gigs**, closing the Music ↔ Events loop. An event may carry both a +crew `a` tag and a musician `a` tag. + ### Crew Treasury (NIP-75 Zap Goal) A crew may run a transparent, **non-custodial** funding goal for a shared cause diff --git a/src/components/CrossLinkCards.tsx b/src/components/CrossLinkCards.tsx index b619e55..a994452 100644 --- a/src/components/CrossLinkCards.tsx +++ b/src/components/CrossLinkCards.tsx @@ -12,6 +12,11 @@ const LAB_TYPE_LABEL: Record = { 35003: 'Editor', 35004: 'Photographer', 35005: 'Builder', + 35007: 'Artist', + 35008: 'Graffiti', + 35009: 'Tattoo', + 35010: 'Trainer', + 35011: 'Musician', }; /** A single linked-crew row, resolved from a `35006:pubkey:d` coordinate. */ diff --git a/src/hooks/useGigs.ts b/src/hooks/useGigs.ts new file mode 100644 index 0000000..1deb516 --- /dev/null +++ b/src/hooks/useGigs.ts @@ -0,0 +1,55 @@ +import { useQuery } from '@tanstack/react-query'; +import { useSkateRelays } from '@/hooks/useSkateRelays'; +import { + SKATE_MUSICIAN_KIND, + CALENDAR_EVENT_KIND, + parseSkateEvent, + type SkateEvent, +} from '@/lib/types'; + +/** + * Gigs for a musician — calendar events (kind 31923) carrying an `a` tag that + * points back at the musician's Lab coordinate (`35011:pubkey:d`). + * + * This powers the "Upcoming gigs" list on a musician's Lab detail page and + * closes the Music ↔ Events loop: an event promoter tags a musician when + * creating an event, and the gig automatically surfaces on the act's profile. + */ +export function useGigs(musicianPubkey?: string, musicianIdentifier?: string, limit = 30) { + const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + const musicianCoord = + musicianPubkey && musicianIdentifier + ? `${SKATE_MUSICIAN_KIND}:${musicianPubkey}:${musicianIdentifier}` + : ''; + + return useQuery({ + queryKey: ['gigs', musicianCoord, getQueryKeySuffix()], + enabled: !!musicianCoord, + staleTime: 60 * 1000, + gcTime: 30 * 60 * 1000, + queryFn: async () => { + const signal = AbortSignal.timeout(10000); + const events = await querySkateContent( + [{ kinds: [CALENDAR_EVENT_KIND], '#a': [musicianCoord], limit }], + { signal }, + ); + + const parsed: SkateEvent[] = []; + for (const ev of events) { + const skateEvent = parseSkateEvent(ev); + // Guard against loose relay matches — the `a` tag must be present. + const hasRef = ev.tags.some(([t, v]) => t === 'a' && v === musicianCoord); + if (skateEvent && hasRef) parsed.push(skateEvent); + } + + // Soonest upcoming first, then past. + const now = Math.floor(Date.now() / 1000); + return parsed.sort((a, b) => { + const aFuture = a.start >= now ? 0 : 1; + const bFuture = b.start >= now ? 0 : 1; + if (aFuture !== bFuture) return aFuture - bFuture; + return a.start - b.start; + }); + }, + }); +} diff --git a/src/hooks/useGrowthStats.ts b/src/hooks/useGrowthStats.ts index 7e5d3a7..29541b6 100644 --- a/src/hooks/useGrowthStats.ts +++ b/src/hooks/useGrowthStats.ts @@ -9,6 +9,11 @@ import { SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, SKATE_BUILDER_KIND, + SKATE_ARTIST_KIND, + SKATE_GRAFFITI_KIND, + SKATE_TATTOO_KIND, + SKATE_TRAINER_KIND, + SKATE_MUSICIAN_KIND, SKATE_TOUR_KIND, SKATE_CREW_KIND, SKATE_RELAY, @@ -128,6 +133,7 @@ export function useGrowthStats() { builderEvents, tourEvents, crewEvents, + otherLabEvents, ] = await Promise.all([ relay.query([{ kinds: [SKATE_SPOT_KIND], '#t': ['skatespot'], limit: 2000 }], { signal }).catch(() => []), relay.query([{ kinds: [SKATE_SHOP_KIND], limit: 2000 }], { signal }).catch(() => []), @@ -139,6 +145,9 @@ export function useGrowthStats() { relay.query([{ kinds: [SKATE_BUILDER_KIND], limit: 500 }], { signal }).catch(() => []), relay.query([{ kinds: [SKATE_TOUR_KIND], limit: 500 }], { signal }).catch(() => []), relay.query([{ kinds: [SKATE_CREW_KIND], limit: 500 }], { signal }).catch(() => []), + // New Lab crafts (artist, graffiti, tattoo, trainer, musician) — folded + // into community totals/mappers without their own chart series. + relay.query([{ kinds: [SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND, SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND], limit: 1000 }], { signal }).catch(() => []), ]); // Deduplicate addressable events (keep newest per pubkey+kind+d) @@ -163,6 +172,7 @@ export function useGrowthStats() { const builders = dedup(builderEvents); const tours = dedup(tourEvents); const crews = dedup(crewEvents); + const otherLab = dedup(otherLabEvents); // ── Monthly bucketing ─────────────────────────────────────────────── @@ -195,6 +205,8 @@ export function useGrowthStats() { for (const e of builders) { const b = ensureBucket(toMonthKey(e.created_at)); b.builders.add(e.id); b.pubkeys.add(e.pubkey); } for (const e of tours) { const b = ensureBucket(toMonthKey(e.created_at)); b.tours.add(e.id); b.pubkeys.add(e.pubkey); } for (const e of crews) { const b = ensureBucket(toMonthKey(e.created_at)); b.crews.add(e.id); b.pubkeys.add(e.pubkey); } + // Other Lab crafts contribute to active-mapper counts (no dedicated series). + for (const e of otherLab) { const b = ensureBucket(toMonthKey(e.created_at)); b.pubkeys.add(e.pubkey); } // Fill any gaps between first and last month so charts have continuous X axis const allKeys = fillMonthGaps(Array.from(bucketMap.keys())); @@ -235,7 +247,7 @@ export function useGrowthStats() { const mapperTotal = new Map(); for (const e of spots) { mapperSpots.set(e.pubkey, (mapperSpots.get(e.pubkey) ?? 0) + 1); } - const allContent = [...spots, ...shops, ...events, ...coaches, ...filmers, ...editors, ...photographers, ...builders, ...tours, ...crews]; + const allContent = [...spots, ...shops, ...events, ...coaches, ...filmers, ...editors, ...photographers, ...builders, ...tours, ...crews, ...otherLab]; for (const e of allContent) { mapperTotal.set(e.pubkey, (mapperTotal.get(e.pubkey) ?? 0) + 1); } const topMappers: TopMapper[] = Array.from(mapperSpots.entries()) diff --git a/src/pages/CoachDetailPage.tsx b/src/pages/CoachDetailPage.tsx index 2dec737..e5c88af 100644 --- a/src/pages/CoachDetailPage.tsx +++ b/src/pages/CoachDetailPage.tsx @@ -14,6 +14,7 @@ import { VouchCard } from '@/components/VouchCard'; import { LinkedCrewCard } from '@/components/CrossLinkCards'; import { TrustBadge } from '@/components/TrustBadge'; import { useLabProfile } from '@/hooks/useLabProfiles'; +import { useGigs } from '@/hooks/useGigs'; import { useAuthor } from '@/hooks/useAuthor'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { DeleteEntityDialog } from '@/components/DeleteEntityDialog'; @@ -83,6 +84,12 @@ export default function CoachDetailPage() { const { user } = useCurrentUser(); const navigate = useNavigate(); + // Gigs — only relevant for musicians. The hook is disabled for other types. + const { data: gigs = [] } = useGigs( + profile?.profileType === 'musician' ? profile.pubkey : undefined, + profile?.profileType === 'musician' ? profile.identifier : undefined, + ); + const isCoach = profile?.profileType === 'coach'; const isFilmer = profile?.profileType === 'filmer'; const isEditor = profile?.profileType === 'editor'; @@ -396,6 +403,42 @@ export default function CoachDetailPage() { )} + {/* Upcoming gigs — events that feature this musician */} + {isMusician && gigs.length > 0 && ( +
+

+ + Upcoming Gigs +

+
+ {gigs.slice(0, 6).map((gig) => { + const isUpcoming = gig.start >= Math.floor(Date.now() / 1000); + return ( + +
+ +
+
+

{gig.title}

+

+ {new Date(gig.start * 1000).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} + {gig.location ? ` · ${gig.location}` : ''} +

+
+ {!isUpcoming && ( + Past + )} + + ); + })} +
+
+ )} + {/* Experience & Rate */}
{profile.experience && ( @@ -436,6 +479,22 @@ export default function CoachDetailPage() { Send Message + {/* Book this act for an event — deep-links to event creation + pre-tagged with this musician (Music → Events). */} + {isMusician && ( + + )} + {/* Contact Info from Profile */} {sanitizeTel(profile.phone) && ( diff --git a/src/pages/CreateEventPage.tsx b/src/pages/CreateEventPage.tsx index 82794a2..a107b62 100644 --- a/src/pages/CreateEventPage.tsx +++ b/src/pages/CreateEventPage.tsx @@ -19,8 +19,9 @@ import { useSkateSpots } from '@/hooks/useSkateSpots'; import { useToast } from '@/hooks/useToast'; import { useTranslation } from '@/contexts/LanguageContext'; import { LoginArea } from '@/components/auth/LoginArea'; -import { CALENDAR_EVENT_KIND, EVENT_TYPE_LABELS, TOUR_CATEGORIES, SKATE_TOUR_KIND, SKATE_SPOT_KIND, SKATE_CREW_KIND, SPOT_CATEGORIES, type SkateTour, type SkateSpot } from '@/lib/types'; +import { CALENDAR_EVENT_KIND, EVENT_TYPE_LABELS, TOUR_CATEGORIES, SKATE_TOUR_KIND, SKATE_SPOT_KIND, SKATE_CREW_KIND, SKATE_MUSICIAN_KIND, SPOT_CATEGORIES, type SkateTour, type SkateSpot } from '@/lib/types'; import { useCrew } from '@/hooks/useCrews'; +import { useLabProfile } from '@/hooks/useLabProfiles'; import { Users } from 'lucide-react'; import { cn } from '@/lib/utils'; import { @@ -37,6 +38,7 @@ import { Globe, Map, Zap, + Music, } from 'lucide-react'; import { FeaturedEventUpgrade } from '@/components/FeaturedEventUpgrade'; import { usePlatformFee } from '@/hooks/usePlatformFee'; @@ -60,6 +62,13 @@ export default function CreateEventPage() { ? crewParam.split(':') : ['', '']; const { data: hostCrew } = useCrew(crewHostIdentifier, crewHostPubkey); + // Optional featured musician: `?musician=:` — a music act + // (Lab kind 35011) playing this event. Connects Music ↔ Events. + const musicianParam = searchParams.get('musician') ?? ''; + const [musicianPubkey, musicianIdentifier] = musicianParam.includes(':') + ? musicianParam.split(':') + : ['', '']; + const { data: musicAct } = useLabProfile(musicianIdentifier, musicianPubkey); // Optional deep-link prefill: `?type=concert&title=...` — used by e.g. the // "Book for a gig" action on a crew that has a band. const prefillType = searchParams.get('type') ?? ''; @@ -351,6 +360,13 @@ export default function CreateEventPage() { tags.push(['crew-name', hostCrew.name]); } + // Featured musician — link a music act (Lab kind 35011) to the event so it + // shows "🎸 Live: [act]" and surfaces on the musician's gig list. + if (musicAct && musicAct.profileType === 'musician') { + tags.push(['a', `${SKATE_MUSICIAN_KIND}:${musicAct.pubkey}:${musicAct.identifier}`]); + tags.push(['music-act', musicAct.title]); + } + await createEvent({ kind: CALENDAR_EVENT_KIND, content: description.trim(), @@ -424,6 +440,22 @@ export default function CreateEventPage() { )} + {musicAct && musicAct.profileType === 'musician' && ( + + +
+ +
+
+

Featuring {musicAct.title}

+

+ This act will be tagged as live music and surfaced on their gig list. +

+
+
+
+ )} + {!hasNip05 && ( diff --git a/src/pages/EventDetailPage.tsx b/src/pages/EventDetailPage.tsx index 6c2e8c6..f66c7cc 100644 --- a/src/pages/EventDetailPage.tsx +++ b/src/pages/EventDetailPage.tsx @@ -14,7 +14,7 @@ import { CommentsSection } from '@/components/comments/CommentsSection'; import { useSkateEvent } from '@/hooks/useSkateEvents'; import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; -import { EVENT_TYPE_LABELS, SKATE_CREW_KIND } from '@/lib/types'; +import { EVENT_TYPE_LABELS, SKATE_CREW_KIND, SKATE_MUSICIAN_KIND } from '@/lib/types'; import { Users } from 'lucide-react'; import NotFound from './NotFound'; import { @@ -26,6 +26,7 @@ import { Navigation, CalendarPlus, Pencil, + Music, } from 'lucide-react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; @@ -112,6 +113,19 @@ export default function EventDetailPage() { return hPubkey && hId ? { pubkey: hPubkey, identifier: hId, name } : null; }, [event]); + // Featured musician — an `a` tag pointing at a musician coordinate (35011:pubkey:d). + const musicAct = useMemo(() => { + if (!event) return null; + const ref = event.event.tags + .filter(([t]) => t === 'a') + .map(([, v]) => v) + .find((v) => v?.startsWith(`${SKATE_MUSICIAN_KIND}:`)); + if (!ref) return null; + const [, mPubkey, mId] = ref.split(':'); + const name = event.event.tags.find(([t]) => t === 'music-act')?.[1]; + return mPubkey && mId ? { pubkey: mPubkey, identifier: mId, name } : null; + }, [event]); + const handleShare = async () => { if (navigator.share) { try { @@ -220,15 +234,26 @@ export default function EventDetailPage() { )}

{event.title}

- {crewHost && ( - - - Hosted by {crewHost.name || 'a crew'} - - )} +
+ {crewHost && ( + + + Hosted by {crewHost.name || 'a crew'} + + )} + {musicAct && ( + + + Live: {musicAct.name || 'live music'} + + )} +
{isOwner && (