import React, { useEffect, useRef } from 'react'; import { NostrEvent, NostrFilter, NPool, NRelay1 } from '@nostrify/nostrify'; import { NostrContext } from '@nostrify/react'; import { useQueryClient } from '@tanstack/react-query'; import { useAppContext } from '@/hooks/useAppContext'; import { useCitrine } from '@/hooks/useCitrine'; import { SKATE_RELAY } from '@/lib/types'; import { resolveRelays } from '@/lib/relays'; interface NostrProviderProps { children: React.ReactNode; } // Default fallback relays - used if no relays are configured const DEFAULT_RELAYS = [ { url: 'wss://auth.nostr1.com', read: true, write: true }, { url: 'wss://relay.ditto.pub', read: true, write: true }, { url: SKATE_RELAY, read: true, write: true }, { url: 'wss://purplepag.es', read: true, write: false }, // Profile aggregator ]; const NostrProvider: React.FC = (props) => { const { children } = props; const { config } = useAppContext(); const citrine = useCitrine(); const queryClient = useQueryClient(); // Create NPool instance only once const pool = useRef(undefined); // Use refs so the pool always has the latest data const relayMetadata = useRef(config.relayMetadata); const useSkateRelay = useRef(config.useSkateRelay ?? true); // Local Citrine relay URL when active (Android + opted-in + reachable), else // null. Kept in a ref so the once-initialized pool's routers always read the // current value without recreating the pool. const citrineUrl = useRef(citrine.url); // Invalidate Nostr queries when relay metadata changes useEffect(() => { relayMetadata.current = config.relayMetadata; useSkateRelay.current = config.useSkateRelay ?? true; queryClient.invalidateQueries({ queryKey: ['nostr'] }); }, [config.relayMetadata, config.useSkateRelay, queryClient]); // Keep the Citrine ref current and refresh queries when the local relay comes // or goes, so reads immediately start (or stop) hitting it. useEffect(() => { citrineUrl.current = citrine.url; queryClient.invalidateQueries({ queryKey: ['nostr'] }); }, [citrine.url, queryClient]); // ── A. Pre-warm relay connections at boot ────────────────────────────── // NPool opens WebSockets lazily on the first query, which pushes the whole // TCP+TLS+WS handshake to whenever the map first renders. We instead kick the // handshakes off the instant the provider mounts, so sockets are already open // (or warming) by the time real queries run and the first events land sooner. // Fire-and-forget: a tiny throwaway query per read relay. Failures are ignored // (a dead relay must never delay or break boot). const warmed = useRef(false); useEffect(() => { if (warmed.current || !pool.current) return; warmed.current = true; const configuredRelays = relayMetadata.current.relays.length > 0 ? relayMetadata.current.relays : DEFAULT_RELAYS; const readUrls = new Set(); if (useSkateRelay.current) readUrls.add(SKATE_RELAY); for (const r of configuredRelays) { if (r.read) readUrls.add(r.url); } // Signal "warm" as soon as the FIRST relay responds, so the splash can // dismiss early. Guarded so it only fires once. let signalled = false; const signalWarm = () => { if (signalled) return; signalled = true; try { window.dispatchEvent(new Event('skatespots:relays-warm')); } catch { // ignore } }; // Open + probe each relay independently. A short filter with limit:0 opens // the socket and returns almost immediately without pulling real data. for (const url of readUrls) { try { const relay = pool.current.relay(url); const signal = AbortSignal.timeout(4000); // Don't await — let all sockets warm in parallel, swallow any error. relay .query([{ kinds: [1], limit: 0 }], { signal }) .then(signalWarm) .catch(() => {}); } catch { // Ignore — never let pre-warming affect boot. } } // Intentionally run once on mount; relay changes are handled by the // invalidation effects above and lazy-open on demand. }, []); // Initialize NPool only once if (!pool.current) { pool.current = new NPool({ open(url: string) { return new NRelay1(url); }, reqRouter(filters: NostrFilter[]) { const routes = new Map(); // Get relays from metadata, fallback to defaults if empty. // `resolveRelays` applies the blocklist and adds local relays. const resolved = resolveRelays(relayMetadata.current); const readRelays = resolved.read.length > 0 ? resolved.read : DEFAULT_RELAYS.filter((r) => r.read).map((r) => r.url); // --- Community "catching" relay: top hierarchy --- // goskatespots.nostr1.com is the app's curated, authoritative relay. // It is ALWAYS queried first (even if a user hasn't added it to their // read list), so curated skate content takes priority. Other relays act // as supplementary sources for redundancy and reach. if (useSkateRelay.current) { routes.set(SKATE_RELAY, filters); } // --- Local Citrine relay (Android, opt-in, reachable) --- // Query the on-device relay alongside remotes. NPool runs routes in // parallel and the localhost hit returns first (~1ms), so reads feel // instant while remotes reconcile in the background. if (citrineUrl.current) { routes.set(citrineUrl.current, filters); } // Ensure we always have at least one relay if (readRelays.length === 0 && routes.size === 0) { console.warn('[NostrProvider] No read relays configured, using defaults'); for (const relay of DEFAULT_RELAYS) { routes.set(relay.url, filters); } } else { for (const url of readRelays) { // Skip re-adding the catching relay (already set above with priority) if (url === SKATE_RELAY) continue; routes.set(url, filters); } } return routes; }, eventRouter(_event: NostrEvent) { // `resolveRelays` applies the blocklist and adds local relays. const resolved = resolveRelays(relayMetadata.current); const writeRelays = resolved.write.length > 0 ? resolved.write : DEFAULT_RELAYS.filter((r) => r.write).map((r) => r.url); const allRelays = new Set(); // Catching relay first: publish curated skate content to the community // relay as the top-priority write target for discoverability. if (useSkateRelay.current) { allRelays.add(SKATE_RELAY); } // Then the user's own write relays for redundancy and reach. for (const url of writeRelays) { allRelays.add(url); } // Local Citrine relay (Android, opt-in, reachable): write here too so // the device keeps a durable copy (backup) and captures the event even // when offline, ready to sync to remotes when connectivity returns. if (citrineUrl.current) { allRelays.add(citrineUrl.current); } // Ensure we always have at least one relay if (allRelays.size === 0) { console.warn('No write relays configured, using defaults'); DEFAULT_RELAYS.forEach(r => allRelays.add(r.url)); } return [...allRelays]; }, }); } return ( {children} ); }; export default NostrProvider;