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.
986 lines
35 KiB
TypeScript
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,
|
|
};
|
|
}
|