/** * useCVMClient * * Queries Nostr relays directly from the browser same logic as the CVM * server but without the NIP-44 encrypted round-trip (which was unreliable). * * This gives instant results with no dependency on the CVM server being up. */ import { useCallback } from 'react'; import { labProfilePathFromKind } from '@/lib/labRoutes'; import { useNostr } from '@nostrify/react'; import { verifyEvent } from 'nostr-tools'; // Relay list /** * Fixed set of PUBLIC data relays this discovery client queries. * * Intentionally NOT the user's personal NIP-65 relays: this hook powers the * community content browser (spots, events, Lab, gear, crews, shops, tours), * and that content is spread across the community's shared relays — a user's * own relay set would return an incomplete, inconsistent view. So we always * query the canonical skate-data relays regardless of personal config. * * PRIVACY NOTE: because these are fixed, a query here reveals the user's IP to * these three relay operators even if the user configured different relays * elsewhere in the app. This is disclosed on the Privacy page. All data queried * is public; the private encrypted Stash is never touched by this client. */ const DATA_RELAYS = [ 'wss://relay.skate-spots.org', 'wss://goskatespots.nostr1.com', 'wss://relay.ditto.pub', 'wss://relay.damus.io', ]; /** * How long to wait for relays before giving up. Kept intentionally short: the * useful relays almost always answer within ~2s, so a longer wait just makes a * single slow/dead relay hold the whole response hostage. 4.5s is a good * balance between "catch a slightly slow relay" and "don't make the user wait * on a relay that's never going to answer". */ const CVM_QUERY_TIMEOUT = 4500; /** * Drop events whose signature fails verification. Relays are untrusted: without * this a malicious relay could inject tampered events into Sasquatch's answers. * The standalone CVM server already verifies; this brings the in-app client to * parity (and matches what the release notes claim). */ function verifiedOnly< T extends { id: string; pubkey: string; sig: string; created_at: number; kind: number; tags: string[][]; content: string }, >(events: T[]): T[] { return events.filter((e) => { try { return verifyEvent(e); } catch { return false; } }); } // Tool result types export interface SpotResult { name: string; description: string; city?: string; country?: string; location?: string; difficulty?: string; features: string[]; geohash?: string; image?: string; author_pubkey: string; naddr: string; url: string; created_at: string; } export interface EventResult { title: string; description: string; location?: string; start?: string; end?: string; type: string[]; image?: string; author_pubkey: string; naddr: string; url: string; } /** * A Lab professional. `role` covers every public Lab craft — not just the * original coach/filmer/editor trio. */ export type LabRole = | 'coach' | 'filmer' | 'editor' | 'photographer' | 'builder' | 'artist' | 'graffiti' | 'tattoo' | 'trainer' | 'musician' | 'judge' | 'organizer' | 'barber'; export interface CoachResult { role: LabRole; pubkey: string; bio: string; location?: string; skills: string[]; experience?: string; lightning_address?: string; naddr: string; url: string; } export interface ShopResult { name: string; description: string; location?: string; geohash?: string; image?: string; website?: string; owner_pubkey: string; naddr: string; url: string; created_at: string; } export interface TourResult { title: string; description: string; location?: string; geohash?: string; price?: string; image?: string; guide_pubkey: string; naddr: string; url: string; created_at: string; } export interface GearResult { title: string; description: string; price?: string; currency?: string; condition?: string; categories: string[]; image?: string; seller_pubkey: string; naddr: string; created_at: string; } export interface CrewResult { name: string; description: string; location?: string; vibes: string[]; member_count: number; founded?: string; geohash?: string; image?: string; founder_pubkey: string; naddr: string; url: string; created_at: string; } export interface CommunityStats { spots: number; upcoming_events: number; coaches: number; filmers: number; editors: number; photographers: number; builders: number; artists: number; graffiti: number; tattoo: number; trainers: number; musicians: number; judges: number; organizers: number; barbers: number; /** Total across every Lab craft (coaches..barbers). */ lab_total: number; shops: number; tours: number; gear_listings: number; crews: number; relays_queried: string[]; queried_at: string; } // Helpers function getTag(event: { tags: string[][] }, name: string): string | undefined { return event.tags.find((t) => t[0] === name)?.[1]; } function getTags(event: { tags: string[][] }, name: string): string[] { return event.tags .filter((t) => t[0] === name) .map((t) => t[1]) .filter(Boolean); } function deduplicateAddressable(events: { pubkey: string; kind: number; tags: string[][]; created_at: number }[]) { const seen = new Map(); for (const e of events) { const d = getTag(e, 'd') ?? ''; const key = `${e.pubkey}:${e.kind}:${d}`; const existing = seen.get(key); if (!existing || e.created_at > existing.created_at) { seen.set(key, e); } } return Array.from(seen.values()); } /** Normalise a string for fuzzy matching: lowercase, trim, collapse whitespace. */ function normalizeForDedupe(s: string | undefined): string { return (s ?? '').toLowerCase().replace(/\s+/g, ' ').trim(); } /** Levenshtein edit distance, capped early once it exceeds `max` (cheap). */ function editDistance(a: string, b: string, max = 2): number { if (Math.abs(a.length - b.length) > max) return max + 1; let prev = Array.from({ length: b.length + 1 }, (_, i) => i); for (let i = 1; i <= a.length; i++) { const curr = [i]; let rowMin = i; for (let j = 1; j <= b.length; j++) { const cost = a[i - 1] === b[j - 1] ? 0 : 1; const val = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost); curr[j] = val; if (val < rowMin) rowMin = val; } if (rowMin > max) return max + 1; // whole row exceeded budget → bail prev = curr; } return prev[b.length]; } /** * Tolerant place match: true if the query appears as a substring of the * candidate, OR (for queries ≥4 chars) is within edit-distance 1 of any word * token in the candidate — so a small typo like "bangok" still matches * "Bangkok, Thailand". Short queries require exact substring to avoid * over-matching (e.g. "la" shouldn't fuzzy-hit half the world). */ function placeMatches(candidate: string, query: string): boolean { const c = candidate.toLowerCase(); const q = query.toLowerCase().trim(); if (!q) return false; if (c.includes(q)) return true; if (q.length < 4) return false; return c .split(/[^a-z0-9]+/i) .some((word) => word.length >= 3 && editDistance(word, q, 1) <= 1); } /** * Reduce a spot name to a stable dedupe signature: lowercased, punctuation * stripped, and common generic suffixes ("skatepark", "park", "plaza", "spot", * "diy", etc.) removed — so "Yellow Park", "Yellow Park Skatepark" and * "Yellow Park " all collapse to the same "yellow" core. Kept conservative: * we only strip trailing generic words, never interior ones. */ const GENERIC_SPOT_WORDS = new Set([ 'skatepark', 'skateplaza', 'skate', 'park', 'plaza', 'spot', 'diy', 'bowl', 'ramp', 'the', 'a', ]); function spotNameSignature(name: string): string { const words = normalizeForDedupe(name) .replace(/[^a-z0-9 ]/gi, '') .split(' ') .filter(Boolean); // Drop trailing generic words ("yellow park skatepark" → "yellow"). while (words.length > 1 && GENERIC_SPOT_WORDS.has(words[words.length - 1])) { words.pop(); } // Also drop leading articles ("the yellow" → "yellow"). while (words.length > 1 && GENERIC_SPOT_WORDS.has(words[0])) { words.shift(); } return words.join(' '); } /** * Collapse spots that are the SAME real-world spot published as different * addressable events — e.g. the same place re-posted under a new `d` identifier * or by two different accounts. Coordinate dedupe (kind:pubkey:d) can't catch * these because the coordinates differ. * * Two spots merge when their NAME signatures match AND their places are * compatible. "Compatible" is deliberately loose: a spot with a geohash and a * spot with only a city name should still merge if the names match, because the * same real-world spot is often posted once with precise coords and once with * just "Bangkok". So we treat an EMPTY place on either side as a wildcard, and * otherwise require the two place strings to share a token (e.g. both mention * "bangkok"). This is what was letting Yellow Park duplicate: one copy carried a * geohash, the other a city, so the old geohash-vs-city keys never matched. */ function deduplicateByIdentity(spots: T[]): T[] { const kept: T[] = []; // Human-readable place tokens only (city/location/country) — NOT the geohash, // which is an opaque code that would never token-match a city name. const placeTokens = (e: T): Set => { const raw = [getTag(e, 'location'), getTag(e, 'city'), getTag(e, 'country')] .map((v) => normalizeForDedupe(v)) .filter(Boolean) .join(' '); return new Set(raw.split(/[^a-z0-9]+/i).filter((t) => t.length >= 3)); }; const placesCompatible = (a: Set, b: Set): boolean => { // Empty human-place on either side → can't contradict (e.g. one copy only // carried a geohash). Treat as compatible so a geohash-only copy still // merges with a "Bangkok" copy of the same-named spot. if (a.size === 0 || b.size === 0) return true; for (const t of a) if (b.has(t)) return true; return false; }; for (const e of spots) { const sig = spotNameSignature(getTag(e, 'title') ?? getTag(e, 'name') ?? ''); // No usable name → can't confidently merge; always keep it. if (!sig) { kept.push(e); continue; } const tokens = placeTokens(e); const dupIdx = kept.findIndex( (k) => spotNameSignature(getTag(k, 'title') ?? getTag(k, 'name') ?? '') === sig && placesCompatible(placeTokens(k), tokens) ); if (dupIdx === -1) { kept.push(e); } else if (e.created_at > kept[dupIdx].created_at) { // Same spot, newer copy → replace, preferring the richer place info. kept[dupIdx] = e; } } return kept; } function buildNaddr(event: { kind: number; pubkey: string; tags: string[][] }): string { return `${event.kind}:${event.pubkey}:${getTag(event, 'd') ?? ''}`; } // The hook export function useCVMClient() { const { nostr } = useNostr(); const findSpots = useCallback( async (params: { city?: string; country?: string; difficulty?: 'beginner' | 'intermediate' | 'advanced' | 'all'; features?: string[]; search?: string; limit?: number; }): Promise<{ count: number; spots: SpotResult[] }> => { const signal = AbortSignal.any([AbortSignal.timeout(CVM_QUERY_TIMEOUT)]); const relayGroup = nostr.group(DATA_RELAYS); const raw = verifiedOnly(await relayGroup.query( [{ kinds: [30818], limit: 500 }], { signal } )); let spots = deduplicateAddressable(raw as typeof raw & { tags: string[][] }[]); // Only valid skate spots spots = spots.filter((e) => e.tags.some((t) => t[0] === 't' && t[1] === 'skatespot') ); // Collapse the same real-world spot re-posted under a different id/author // (coordinate dedupe above can't catch those). Keeps the freshest copy. spots = deduplicateByIdentity(spots); if (params.difficulty && params.difficulty !== 'all') { spots = spots.filter( (e) => getTag(e, 'difficulty')?.toLowerCase() === params.difficulty ); } if (params.city) { const q = params.city; spots = spots.filter( (e) => placeMatches(getTag(e, 'city') ?? '', q) || placeMatches(getTag(e, 'location') ?? '', q) || placeMatches(getTag(e, 'title') ?? '', q) || // Content is freeform prose — keep it a strict substring check so a // fuzzy word deep in a description doesn't cause a false match. (e as unknown as { content: string }).content.toLowerCase().includes(q.toLowerCase()) ); } if (params.country) { const q = params.country.toLowerCase(); spots = spots.filter( (e) => (getTag(e, 'country') ?? '').toLowerCase().includes(q) || (getTag(e, 'location') ?? '').toLowerCase().includes(q) || (e as unknown as { content: string }).content.toLowerCase().includes(q) ); } if (params.features && params.features.length > 0) { spots = spots.filter((e) => { const spotFeatures = [ ...getTags(e, 'feature'), ...getTags(e, 't'), ].map((f) => f.toLowerCase()); const content = (e as unknown as { content: string }).content.toLowerCase(); return params.features!.some( (f) => spotFeatures.some((sf) => sf.includes(f.toLowerCase())) || content.includes(f.toLowerCase()) ); }); } if (params.search) { const q = params.search.toLowerCase(); spots = spots.filter( (e) => (getTag(e, 'title') ?? '').toLowerCase().includes(q) || (getTag(e, 'name') ?? '').toLowerCase().includes(q) || (e as unknown as { content: string }).content.toLowerCase().includes(q) ); } spots.sort((a, b) => b.created_at - a.created_at); spots = spots.slice(0, params.limit ?? 10); const result: SpotResult[] = spots.map((e) => { const ev = e as unknown as { content: string; pubkey: string; kind: number; tags: string[][]; created_at: number }; const imeta = ev.tags.find((t) => t[0] === 'imeta'); const imetaUrl = imeta?.find((p) => p.startsWith('url '))?.replace('url ', ''); return { name: getTag(ev, 'title') ?? getTag(ev, 'name') ?? 'Unnamed Spot', description: ev.content.slice(0, 300), city: getTag(ev, 'city'), country: getTag(ev, 'country'), location: getTag(ev, 'location'), difficulty: getTag(ev, 'difficulty'), features: getTags(ev, 't').filter((t) => t !== 'skatespot'), geohash: getTag(ev, 'g'), image: getTag(ev, 'image') ?? imetaUrl, author_pubkey: ev.pubkey, naddr: buildNaddr(ev), url: `https://skate-spots.org/spot/${ev.pubkey}/${getTag(ev, 'd') ?? ''}`, created_at: new Date(ev.created_at * 1000).toISOString(), }; }); return { count: result.length, spots: result }; }, [nostr] ); const findEvents = useCallback( async (params: { city?: string; country?: string; event_type?: string; from_date?: string; to_date?: string; limit?: number; }): Promise<{ count: number; events: EventResult[] }> => { const now = Math.floor(Date.now() / 1000); const signal = AbortSignal.any([AbortSignal.timeout(CVM_QUERY_TIMEOUT)]); const relayGroup = nostr.group(DATA_RELAYS); const raw = verifiedOnly(await relayGroup.query( [{ kinds: [31923], limit: 200, since: now }], { signal } )); let events = deduplicateAddressable(raw as typeof raw & { tags: string[][] }[]); if (params.city) { const q = params.city.toLowerCase(); events = events.filter( (e) => (getTag(e, 'location') ?? '').toLowerCase().includes(q) || (getTag(e, 'city') ?? '').toLowerCase().includes(q) || (e as unknown as { content: string }).content.toLowerCase().includes(q) ); } if (params.event_type) { const q = params.event_type.toLowerCase(); events = events.filter( (e) => getTags(e, 't').some((t) => t.toLowerCase().includes(q)) || (getTag(e, 'title') ?? '').toLowerCase().includes(q) || (e as unknown as { content: string }).content.toLowerCase().includes(q) ); } if (params.to_date) { const toTs = Math.floor(new Date(params.to_date).getTime() / 1000); events = events.filter((e) => { const start = parseInt(getTag(e, 'start') ?? '0'); return !start || start <= toTs; }); } events.sort((a, b) => { const startA = parseInt(getTag(a, 'start') ?? '0'); const startB = parseInt(getTag(b, 'start') ?? '0'); return startA - startB; }); events = events.slice(0, params.limit ?? 10); const result: EventResult[] = events.map((e) => { const ev = e as unknown as { content: string; pubkey: string; kind: number; tags: string[][]; created_at: number }; const startTag = getTag(ev, 'start'); const endTag = getTag(ev, 'end'); return { title: getTag(ev, 'title') ?? 'Untitled Event', description: ev.content.slice(0, 300), location: getTag(ev, 'location'), start: startTag ? new Date(parseInt(startTag) * 1000).toISOString() : undefined, end: endTag ? new Date(parseInt(endTag) * 1000).toISOString() : undefined, type: getTags(ev, 't'), image: getTag(ev, 'image'), author_pubkey: ev.pubkey, naddr: buildNaddr(ev), url: `https://skate-spots.org/event/${ev.pubkey}/${getTag(ev, 'd') ?? ''}`, }; }); return { count: result.length, events: result }; }, [nostr] ); const findCoaches = useCallback( async (params: { // Every public Lab craft is searchable. 'any' searches all of them. role?: LabRole | 'any'; skills?: string[]; location?: string; limit?: number; }): Promise<{ count: number; profiles: CoachResult[] }> => { // Kind ↔ role for every Lab craft (35001..35014, minus 35006 = crews). const ROLE_KIND: Record = { coach: 35001, filmer: 35002, editor: 35003, photographer: 35004, builder: 35005, artist: 35007, graffiti: 35008, tattoo: 35009, trainer: 35010, musician: 35011, judge: 35012, organizer: 35013, barber: 35014, }; const KIND_ROLE: Record = Object.fromEntries( Object.entries(ROLE_KIND).map(([role, kind]) => [kind, role as LabRole]) ); const ALL_LAB_KINDS = Object.values(ROLE_KIND); const kinds = params.role && params.role !== 'any' ? [ROLE_KIND[params.role]] : ALL_LAB_KINDS; const signal = AbortSignal.any([AbortSignal.timeout(CVM_QUERY_TIMEOUT)]); const relayGroup = nostr.group(DATA_RELAYS); const raw = verifiedOnly(await relayGroup.query([{ kinds, limit: 500 }], { signal })); let profiles = deduplicateAddressable(raw as typeof raw & { tags: string[][] }[]); // Skills live in `t` tags (parseCraftBase) — also check legacy `skill` tags + bio. if (params.skills && params.skills.length > 0) { profiles = profiles.filter((e) => { const profileSkills = [...getTags(e, 't'), ...getTags(e, 'skill')].map((s) => s.toLowerCase()); const bio = (e as unknown as { content: string }).content.toLowerCase(); return params.skills!.some( (s) => profileSkills.some((ps) => ps.includes(s.toLowerCase())) || bio.includes(s.toLowerCase()) ); }); } if (params.location) { const q = params.location.toLowerCase(); profiles = profiles.filter( (e) => (getTag(e, 'location') ?? '').toLowerCase().includes(q) || (getTag(e, 'city') ?? '').toLowerCase().includes(q) || (e as unknown as { content: string }).content.toLowerCase().includes(q) ); } profiles.sort((a, b) => b.created_at - a.created_at); profiles = profiles.slice(0, params.limit ?? 10); // Self-tag markers we don't want to surface as "skills". const SELF_TAGS = new Set([ 'skatecoach', 'skatefilmer', 'skateeditor', 'skatephotographer', 'skatebuilder', 'skateartist', 'skategraffiti', 'skatetattoo', 'skatetrainer', 'skatemusician', 'skatejudge', 'skateorganizer', 'skatebarber', ]); const result: CoachResult[] = profiles.map((e) => { const ev = e as unknown as { content: string; pubkey: string; kind: number; tags: string[][]; created_at: number }; const skills = [...getTags(ev, 't'), ...getTags(ev, 'skill')].filter((t) => !SELF_TAGS.has(t)); return { role: KIND_ROLE[ev.kind] ?? 'coach', pubkey: ev.pubkey, bio: ev.content.slice(0, 300), location: getTag(ev, 'location'), skills, experience: getTag(ev, 'experience'), lightning_address: getTag(ev, 'lud16') ?? getTag(ev, 'contact'), naddr: buildNaddr(ev), url: `https://skate-spots.org${labProfilePathFromKind(ev.kind, ev.pubkey, getTag(ev, 'd') ?? '')}`, }; }); return { count: result.length, profiles: result }; }, [nostr] ); const findGear = useCallback( async (params: { category?: string; condition?: 'new' | 'like_new' | 'good' | 'fair' | 'any'; max_price_sats?: number; search?: string; limit?: number; }): Promise<{ count: number; listings: GearResult[] }> => { const signal = AbortSignal.any([AbortSignal.timeout(CVM_QUERY_TIMEOUT)]); const relayGroup = nostr.group(DATA_RELAYS); const raw = verifiedOnly(await relayGroup.query( [{ kinds: [30402], limit: 200, '#t': ['skateboarding'] }], { signal } )); let listings = deduplicateAddressable(raw as typeof raw & { tags: string[][] }[]); if (params.category) { const q = params.category.toLowerCase(); listings = listings.filter( (e) => getTags(e, 't').some((t) => t.toLowerCase().includes(q)) || (getTag(e, 'title') ?? '').toLowerCase().includes(q) || (e as unknown as { content: string }).content.toLowerCase().includes(q) ); } if (params.condition && params.condition !== 'any') { listings = listings.filter((e) => (getTag(e, 'condition') ?? '') .toLowerCase() .replace(/\s+/g, '_') .includes(params.condition!) ); } if (params.search) { const q = params.search.toLowerCase(); listings = listings.filter( (e) => (getTag(e, 'title') ?? '').toLowerCase().includes(q) || (e as unknown as { content: string }).content.toLowerCase().includes(q) ); } listings.sort((a, b) => b.created_at - a.created_at); listings = listings.slice(0, params.limit ?? 10); const result: GearResult[] = listings.map((e) => { const ev = e as unknown as { content: string; pubkey: string; kind: number; tags: string[][]; created_at: number }; return { title: getTag(ev, 'title') ?? 'Untitled Listing', description: ev.content.slice(0, 300), price: getTag(ev, 'price'), currency: getTag(ev, 'currency'), condition: getTag(ev, 'condition'), categories: getTags(ev, 't'), image: getTag(ev, 'image') ?? getTag(ev, 'thumb'), seller_pubkey: ev.pubkey, naddr: buildNaddr(ev), created_at: new Date(ev.created_at * 1000).toISOString(), }; }); return { count: result.length, listings: result }; }, [nostr] ); const findCrews = useCallback( async (params: { city?: string; country?: string; vibe?: string; search?: string; limit?: number; }): Promise<{ count: number; crews: CrewResult[] }> => { const signal = AbortSignal.any([AbortSignal.timeout(CVM_QUERY_TIMEOUT)]); const relayGroup = nostr.group(DATA_RELAYS); const raw = verifiedOnly(await relayGroup.query( [{ kinds: [35006], limit: 500 }], { signal } )); let crews = deduplicateAddressable(raw as typeof raw & { tags: string[][] }[]); // Only valid crews crews = crews.filter((e) => e.tags.some((t) => t[0] === 't' && t[1] === 'skatecrew')); const locMatch = (e: typeof crews[0], q: string) => (getTag(e, 'location') ?? '').toLowerCase().includes(q) || (getTag(e, 'title') ?? '').toLowerCase().includes(q) || (e as unknown as { content: string }).content.toLowerCase().includes(q); if (params.city) { const q = params.city.toLowerCase(); crews = crews.filter((e) => locMatch(e, q)); } if (params.country) { const q = params.country.toLowerCase(); crews = crews.filter((e) => locMatch(e, q)); } if (params.vibe) { const q = params.vibe.toLowerCase(); crews = crews.filter((e) => getTags(e, 't').some((t) => t !== 'skatecrew' && t.toLowerCase().includes(q)) ); } if (params.search) { const q = params.search.toLowerCase(); crews = crews.filter( (e) => (getTag(e, 'title') ?? '').toLowerCase().includes(q) || (e as unknown as { content: string }).content.toLowerCase().includes(q) || getTags(e, 't').some((t) => t.toLowerCase().includes(q)) ); } crews.sort((a, b) => b.created_at - a.created_at); crews = crews.slice(0, params.limit ?? 10); const result: CrewResult[] = crews.map((e) => { const ev = e as unknown as { content: string; pubkey: string; kind: number; tags: string[][]; created_at: number }; return { name: getTag(ev, 'title') ?? getTag(ev, 'name') ?? 'Unnamed Crew', description: ev.content.slice(0, 300), location: getTag(ev, 'location'), vibes: getTags(ev, 't').filter((t) => t !== 'skatecrew'), member_count: ev.tags.filter((t) => t[0] === 'p').length, founded: getTag(ev, 'founded'), geohash: getTag(ev, 'g'), image: getTag(ev, 'image'), founder_pubkey: ev.pubkey, naddr: buildNaddr(ev), url: `https://skate-spots.org/crew/${ev.pubkey}/${getTag(ev, 'd') ?? ''}`, created_at: new Date(ev.created_at * 1000).toISOString(), }; }); return { count: result.length, crews: result }; }, [nostr] ); const findShops = useCallback( async (params: { city?: string; country?: string; search?: string; limit?: number; }): Promise<{ count: number; shops: ShopResult[] }> => { const signal = AbortSignal.any([AbortSignal.timeout(CVM_QUERY_TIMEOUT)]); const relayGroup = nostr.group(DATA_RELAYS); // Kind 30017 = NIP-15 stall; skate shops mark themselves with t=skateshop. const raw = verifiedOnly(await relayGroup.query([{ kinds: [30017], limit: 500 }], { signal })); let shops = deduplicateAddressable(raw as typeof raw & { tags: string[][] }[]); // Keep only skate shops (drops generic NIP-15 stalls from other apps). shops = shops.filter((e) => e.tags.some((t) => t[0] === 't' && t[1] === 'skateshop')); const locMatch = (e: typeof shops[0], q: string) => (getTag(e, 'location') ?? '').toLowerCase().includes(q) || (getTag(e, 'city') ?? '').toLowerCase().includes(q) || (getTag(e, 'name') ?? getTag(e, 'title') ?? '').toLowerCase().includes(q) || (e as unknown as { content: string }).content.toLowerCase().includes(q); if (params.city) shops = shops.filter((e) => locMatch(e, params.city!.toLowerCase())); if (params.country) shops = shops.filter((e) => locMatch(e, params.country!.toLowerCase())); if (params.search) { const q = params.search.toLowerCase(); shops = shops.filter((e) => locMatch(e, q)); } shops.sort((a, b) => b.created_at - a.created_at); shops = shops.slice(0, params.limit ?? 10); const result: ShopResult[] = shops.map((e) => { const ev = e as unknown as { content: string; pubkey: string; kind: number; tags: string[][]; created_at: number }; return { name: getTag(ev, 'name') ?? getTag(ev, 'title') ?? 'Unnamed Shop', description: ev.content.slice(0, 300), location: getTag(ev, 'location'), geohash: getTag(ev, 'g'), image: getTag(ev, 'image') ?? getTag(ev, 'picture'), website: getTag(ev, 'website'), owner_pubkey: ev.pubkey, naddr: buildNaddr(ev), url: `https://skate-spots.org/shop/${ev.pubkey}/${getTag(ev, 'd') ?? ''}`, created_at: new Date(ev.created_at * 1000).toISOString(), }; }); return { count: result.length, shops: result }; }, [nostr] ); const findTours = useCallback( async (params: { city?: string; country?: string; search?: string; limit?: number; }): Promise<{ count: number; tours: TourResult[] }> => { const signal = AbortSignal.any([AbortSignal.timeout(CVM_QUERY_TIMEOUT)]); const relayGroup = nostr.group(DATA_RELAYS); // Kind 30823 = local skate tours (guides showing hidden spots). const raw = verifiedOnly(await relayGroup.query([{ kinds: [30823], limit: 500 }], { signal })); let tours = deduplicateAddressable(raw as typeof raw & { tags: string[][] }[]); const locMatch = (e: typeof tours[0], q: string) => (getTag(e, 'location') ?? '').toLowerCase().includes(q) || (getTag(e, 'city') ?? '').toLowerCase().includes(q) || (getTag(e, 'title') ?? '').toLowerCase().includes(q) || (e as unknown as { content: string }).content.toLowerCase().includes(q); if (params.city) tours = tours.filter((e) => locMatch(e, params.city!.toLowerCase())); if (params.country) tours = tours.filter((e) => locMatch(e, params.country!.toLowerCase())); if (params.search) { const q = params.search.toLowerCase(); tours = tours.filter((e) => locMatch(e, q)); } tours.sort((a, b) => b.created_at - a.created_at); tours = tours.slice(0, params.limit ?? 10); const result: TourResult[] = tours.map((e) => { const ev = e as unknown as { content: string; pubkey: string; kind: number; tags: string[][]; created_at: number }; return { title: getTag(ev, 'title') ?? 'Untitled Tour', description: ev.content.slice(0, 300), location: getTag(ev, 'location'), geohash: getTag(ev, 'g'), price: getTag(ev, 'price'), image: getTag(ev, 'image'), guide_pubkey: ev.pubkey, naddr: buildNaddr(ev), url: `https://skate-spots.org/tour/${ev.pubkey}/${getTag(ev, 'd') ?? ''}`, created_at: new Date(ev.created_at * 1000).toISOString(), }; }); return { count: result.length, tours: result }; }, [nostr] ); const getCommunityStats = useCallback(async (): Promise => { // Stats fan out to ~18 parallel queries and aggregate counts, so give them // a slightly longer window than a single-list fetch — but still far short // of the old 10s so a laggy relay can't stall the whole answer. const signal = AbortSignal.any([AbortSignal.timeout(6000)]); const relayGroup = nostr.group(DATA_RELAYS); const now = Math.floor(Date.now() / 1000); // One query per content type. Lab crafts are counted individually so the // stats match The Lab's per-craft tabs (coaches..judges). Everything here is // PUBLIC data — the private encrypted Stash (kind 30001) is never touched. const [ spots, events, gear, crews, shops, tours, coaches, filmers, editors, photographers, builders, artists, graffiti, tattoo, trainers, musicians, judges, organizers, barbers, ] = await Promise.all([ relayGroup.query([{ kinds: [30818], '#t': ['skatespot'], limit: 2000 }], { signal }), relayGroup.query([{ kinds: [31923], limit: 500, since: now }], { signal }), relayGroup.query([{ kinds: [30402], limit: 500, '#t': ['skateboarding'] }], { signal }), relayGroup.query([{ kinds: [35006], limit: 500 }], { signal }), relayGroup.query([{ kinds: [30017], limit: 500 }], { signal }), relayGroup.query([{ kinds: [30823], limit: 500 }], { signal }), relayGroup.query([{ kinds: [35001], limit: 500 }], { signal }), relayGroup.query([{ kinds: [35002], limit: 500 }], { signal }), relayGroup.query([{ kinds: [35003], limit: 500 }], { signal }), relayGroup.query([{ kinds: [35004], limit: 500 }], { signal }), relayGroup.query([{ kinds: [35005], limit: 500 }], { signal }), relayGroup.query([{ kinds: [35007], limit: 500 }], { signal }), relayGroup.query([{ kinds: [35008], limit: 500 }], { signal }), relayGroup.query([{ kinds: [35009], limit: 500 }], { signal }), relayGroup.query([{ kinds: [35010], limit: 500 }], { signal }), relayGroup.query([{ kinds: [35011], limit: 500 }], { signal }), relayGroup.query([{ kinds: [35012], limit: 500 }], { signal }), relayGroup.query([{ kinds: [35013], limit: 500 }], { signal }), relayGroup.query([{ kinds: [35014], limit: 500 }], { signal }), ]); const uniq = (rows: unknown) => deduplicateAddressable(rows as { pubkey: string; kind: number; tags: string[][]; created_at: number }[]); const coachesN = uniq(coaches).length; const filmersN = uniq(filmers).length; const editorsN = uniq(editors).length; const photographersN = uniq(photographers).length; const buildersN = uniq(builders).length; const artistsN = uniq(artists).length; const graffitiN = uniq(graffiti).length; const tattooN = uniq(tattoo).length; const trainersN = uniq(trainers).length; const musiciansN = uniq(musicians).length; const judgesN = uniq(judges).length; const organizersN = uniq(organizers).length; const barbersN = uniq(barbers).length; return { spots: uniq(spots).filter( (e) => e.tags.some((t) => t[0] === 't' && t[1] === 'skatespot') ).length, upcoming_events: uniq(events).length, coaches: coachesN, filmers: filmersN, editors: editorsN, photographers: photographersN, builders: buildersN, artists: artistsN, graffiti: graffitiN, tattoo: tattooN, trainers: trainersN, musicians: musiciansN, judges: judgesN, organizers: organizersN, barbers: barbersN, lab_total: coachesN + filmersN + editorsN + photographersN + buildersN + artistsN + graffitiN + tattooN + trainersN + musiciansN + judgesN + organizersN + barbersN, shops: uniq(shops).filter( (e) => e.tags.some((t) => t[0] === 't' && t[1] === 'skateshop') ).length, tours: uniq(tours).length, gear_listings: uniq(gear).length, crews: uniq(crews).filter( (e) => e.tags.some((t) => t[0] === 't' && t[1] === 'skatecrew') ).length, relays_queried: DATA_RELAYS, queried_at: new Date().toISOString(), }; }, [nostr]); return { findSpots, findEvents, findCoaches, findGear, findCrews, findShops, findTours, getCommunityStats, }; }