From f41af8f1cabc603529fc921cd4dbff5ef1237600 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Wed, 9 Sep 2026 12:05:35 +0700 Subject: [PATCH] Cold-start: pre-warm relays + branded splash screen (1.4.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/App.tsx | 2 + src/components/NostrProvider.tsx | 55 ++++++++++ src/components/SplashScreen.tsx | 168 +++++++++++++++++++++++++++++++ src/index.css | 14 +++ 4 files changed, 239 insertions(+) create mode 100644 src/components/SplashScreen.tsx diff --git a/src/App.tsx b/src/App.tsx index a3d56a9..34f67e8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,7 @@ import { LanguageProvider } from '@/contexts/LanguageContext'; import { AppConfig } from '@/contexts/AppContext'; import { PWAInstallPrompt } from '@/components/PWAInstallPrompt'; import { BetaDisclaimerDialog } from '@/components/BetaDisclaimerDialog'; +import { SplashScreen } from '@/components/SplashScreen'; import { AmberCallbackHandler } from '@/components/auth/AmberCallbackHandler'; import AppRouter from './AppRouter'; @@ -58,6 +59,7 @@ export function App() { + diff --git a/src/components/NostrProvider.tsx b/src/components/NostrProvider.tsx index e42227d..bace358 100644 --- a/src/components/NostrProvider.tsx +++ b/src/components/NostrProvider.tsx @@ -51,6 +51,61 @@ const NostrProvider: React.FC = (props) => { 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. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Initialize NPool only once if (!pool.current) { pool.current = new NPool({ diff --git a/src/components/SplashScreen.tsx b/src/components/SplashScreen.tsx new file mode 100644 index 0000000..642e968 --- /dev/null +++ b/src/components/SplashScreen.tsx @@ -0,0 +1,168 @@ +import { useEffect, useRef, useState } from 'react'; + +/** + * Branded cold-start splash. + * + * Nostr relays connect over WebSockets that NPool opens lazily; on a fresh load + * the first content arrives only after the handshakes complete. Rather than show + * a half-empty map that reads as "is it broken?", we present a short, intentional + * branded moment while the pre-warmed relays connect underneath (see + * NostrProvider's boot pre-warm). It NEVER traps the user: + * + * - Minimum on-screen time keeps it from flickering on fast loads. + * - It dismisses the instant relays signal "warm" (`skatespots:relays-warm`). + * - A hard max timeout guarantees dismissal even on a dead/slow network. + * + * Shown once per browser session (sessionStorage) so returning navigation within + * the same session doesn't replay it. + */ + +const SESSION_KEY = 'splash-shown-session'; +const MIN_MS = 650; // don't flash-and-vanish on instant loads +const MAX_MS = 1600; // hard cap — never block on a slow/offline network +const FADE_MS = 320; + +// Skater-voiced status lines that rotate while we warm up. +const STATUS_LINES = [ + 'Waking the network…', + 'Tuning the relays…', + 'Pulling in spots…', + 'Dropping in…', +]; + +export function SplashScreen() { + // Only show on the first load of a session. + const [visible, setVisible] = useState(() => { + try { + return sessionStorage.getItem(SESSION_KEY) !== 'true'; + } catch { + return true; + } + }); + const [leaving, setLeaving] = useState(false); + const [statusIndex, setStatusIndex] = useState(0); + const mountedAt = useRef(Date.now()); + + const prefersReducedMotion = + typeof window !== 'undefined' && + window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + + // Rotate the status line for a sense of progress. + useEffect(() => { + if (!visible) return; + const id = setInterval(() => { + setStatusIndex((i) => (i + 1) % STATUS_LINES.length); + }, 420); + return () => clearInterval(id); + }, [visible]); + + // Dismissal logic: warm-signal OR max timeout, always respecting the minimum. + useEffect(() => { + if (!visible) return; + + let done = false; + const finish = () => { + if (done) return; + done = true; + const elapsed = Date.now() - mountedAt.current; + const wait = Math.max(0, MIN_MS - elapsed); + setTimeout(() => { + setLeaving(true); + setTimeout(() => { + try { + sessionStorage.setItem(SESSION_KEY, 'true'); + } catch { + // ignore private-mode storage failures + } + setVisible(false); + }, FADE_MS); + }, wait); + }; + + // Relays reported warm → dismiss early (after the minimum). + const onWarm = () => finish(); + window.addEventListener('skatespots:relays-warm', onWarm); + + // Hard cap so we never trap the user. + const cap = setTimeout(finish, MAX_MS); + + return () => { + window.removeEventListener('skatespots:relays-warm', onWarm); + clearTimeout(cap); + }; + }, [visible]); + + if (!visible) return null; + + return ( +
+ {/* Ambient neon glow behind the mark */} +
+ + {/* Logo + wordmark */} +
+
+ SkateSpots +
+ +

+ SKATESPOTS +

+ + {/* Progress sweep */} +
+ {prefersReducedMotion ? ( +
+ ) : ( +
+ )} +
+ + {/* Rotating status line */} +

+ {STATUS_LINES[statusIndex]} +

+
+
+ ); +} + +export default SplashScreen; diff --git a/src/index.css b/src/index.css index ea4af21..8e02eb4 100644 --- a/src/index.css +++ b/src/index.css @@ -307,6 +307,20 @@ 100% { transform: scale(1.6); opacity: 0; } } +/* ── Cold-start splash screen ── */ +@keyframes splashPulse { + 0%, 100% { transform: scale(1); filter: drop-shadow(0 0 12px rgba(255,0,64,0.4)); } + 50% { transform: scale(1.06); filter: drop-shadow(0 0 24px rgba(255,0,64,0.7)); } +} +@keyframes splashSweep { + 0% { transform: translateX(-120%); } + 100% { transform: translateX(240%); } +} +@keyframes splashFade { + from { opacity: 0; transform: translateY(3px); } + to { opacity: 1; transform: translateY(0); } +} + .animate-fadeIn { animation: fadeIn 0.4s ease-out forwards; }