SkateSpots/src/hooks/useCVMClient.ts
SkateSpots 6407310213 feat(relay): cut over community relay to self-hosted Newlay
Point SKATE_RELAY, the default relay pools, the DM/CVM/zap relay arrays, the cvm-server + push-service defaults, and the archive default at wss://relay.skate-spots.org, keeping the legacy hosted relay as a fallback during the client-update window. Restored 115/115 archived events; the relay runs behind Caddy with TLS. Docs + AGENTS.md updated.
2026-10-12 12:38:13 +07:00

986 lines
35 KiB
TypeScript

/**
* 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<string, typeof events[0]>();
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<T extends { tags: string[][]; created_at: number }>(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<string> => {
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<string>, b: Set<string>): 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<LabRole, number> = {
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<number, LabRole> = 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<CommunityStats> => {
// 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,
};
}