A. Relay pre-warming — NostrProvider now opens the read-relay WebSockets the moment it mounts (fire-and-forget limit:0 probes with a 4s abort), instead of NPool's lazy open on first query. Sockets are already connected by the time the map queries run, so first events land sooner. Failures are swallowed so a dead relay never delays or breaks boot. B. Branded SplashScreen — an intentional cold-start moment (logo with a neon glow pulse, a red→lime progress sweep, and rotating skater-voiced status lines) that replaces the "is it broken?" empty-map flash. It dismisses the instant relays report warm (`skatespots:relays-warm` event fired on the first relay response) OR at a hard 1.6s cap — whichever comes first — with a 650ms minimum to avoid flicker. Shown once per session. Fully respects prefers-reduced-motion and safe-area insets; works identically on phone and desktop, never blocks the user. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
212 lines
No EOL
7.8 KiB
TypeScript
212 lines
No EOL
7.8 KiB
TypeScript
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';
|
|
|
|
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://wotr.relatr.xyz', 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<NostrProviderProps> = (props) => {
|
|
const { children } = props;
|
|
const { config } = useAppContext();
|
|
const citrine = useCitrine();
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
// Create NPool instance only once
|
|
const pool = useRef<NPool | undefined>(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<string | null>(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<string>();
|
|
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.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// 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<string, NostrFilter[]>();
|
|
|
|
// Get relays from metadata, fallback to defaults if empty
|
|
const configuredRelays = relayMetadata.current.relays.length > 0
|
|
? relayMetadata.current.relays
|
|
: DEFAULT_RELAYS;
|
|
|
|
// Route to all read relays
|
|
const readRelays = configuredRelays
|
|
.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) {
|
|
// Get relays from metadata, fallback to defaults if empty
|
|
const configuredRelays = relayMetadata.current.relays.length > 0
|
|
? relayMetadata.current.relays
|
|
: DEFAULT_RELAYS;
|
|
|
|
// Get write relays from metadata
|
|
const writeRelays = configuredRelays
|
|
.filter(r => r.write)
|
|
.map(r => r.url);
|
|
|
|
const allRelays = new Set<string>();
|
|
|
|
// 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 (
|
|
<NostrContext.Provider value={{ nostr: pool.current }}>
|
|
{children}
|
|
</NostrContext.Provider>
|
|
);
|
|
};
|
|
|
|
export default NostrProvider; |