import { useEffect, useRef, useState } from 'react'; /** * Branded cold-start splash — a choreographed, premium launch moment. * * 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 and the first spots stream * in underneath. It NEVER traps the user: * * - Determinate progress driven by real milestones (mount → warm → content). * - Reveals the instant real content arrives (`skatespots:content-ready`), * with a short grace after connection (`skatespots:relays-warm`). * - A hard max timeout guarantees dismissal even on a dead/slow network. * - A minimum on-screen time prevents a jarring flash on instant loads. * * Shown once per browser session so in-session navigation never replays it. * Fully respects prefers-reduced-motion and safe-area insets; identical on * phone and desktop. */ const SESSION_KEY = 'splash-shown-session'; const MIN_MS = 900; // choreography needs room to breathe const WARM_GRACE_MS = 550; // wait after connection for content to stream in const MAX_MS = 2600; // hard cap — never block on a slow/offline network const FADE_MS = 480; const READY_HOLD_MS = 340; // savour the 100% "ready" beat before revealing const STATUS_LINES = [ 'Waking the network', 'Tuning the relays', 'Pulling in spots', 'Dropping in', ]; export function SplashScreen() { const [visible, setVisible] = useState(() => { try { return sessionStorage.getItem(SESSION_KEY) !== 'true'; } catch { return true; } }); const [leaving, setLeaving] = useState(false); const [ready, setReady] = useState(false); const [statusIndex, setStatusIndex] = useState(0); // Determinate progress (0–100). Creeps forward on its own, snaps toward // milestones, and finishes at 100 the moment we're ready. const [progress, setProgress] = useState(8); const mountedAt = useRef(Date.now()); const prefersReducedMotion = typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; // Rotate the status line (paused once ready — the label swaps to "Ready"). useEffect(() => { if (!visible || ready) return; const id = setInterval(() => { setStatusIndex((i) => (i + 1) % STATUS_LINES.length); }, 640); return () => clearInterval(id); }, [visible, ready]); // Ambient progress creep — asymptotically approaches ~85% so it always feels // like it's making headway without ever "completing" before we're truly ready. useEffect(() => { if (!visible || ready) return; const id = setInterval(() => { setProgress((p) => (p >= 85 ? p : p + Math.max(0.6, (85 - p) * 0.045))); }, 90); return () => clearInterval(id); }, [visible, ready]); // Dismissal logic. Priority: // 1. Real content arrived (`content-ready`) → reveal a populated app. // 2. Relays connected (`relays-warm`) → short grace, then reveal. // 3. Hard cap (MAX_MS) → always dismiss, even offline. Never traps anyone. // All paths respect the MIN_MS floor, then play the 100%→hold→fade sequence. useEffect(() => { if (!visible) return; let done = false; let graceTimer: ReturnType | undefined; const finish = () => { if (done) return; done = true; if (graceTimer) clearTimeout(graceTimer); const elapsed = Date.now() - mountedAt.current; const wait = Math.max(0, MIN_MS - elapsed); setTimeout(() => { // Land on 100% + a satisfying "ready" beat, then crossfade out. setReady(true); setProgress(100); setTimeout(() => { setLeaving(true); setTimeout(() => { try { sessionStorage.setItem(SESSION_KEY, 'true'); } catch { // ignore private-mode storage failures } setVisible(false); }, FADE_MS); }, READY_HOLD_MS); }, wait); }; const onContent = () => finish(); const onWarm = () => { if (done || graceTimer) return; // Nudge progress up on connection so the bar visibly reacts to a milestone. setProgress((p) => Math.max(p, 78)); graceTimer = setTimeout(finish, WARM_GRACE_MS); }; window.addEventListener('skatespots:content-ready', onContent); window.addEventListener('skatespots:relays-warm', onWarm); const cap = setTimeout(finish, MAX_MS); return () => { window.removeEventListener('skatespots:content-ready', onContent); window.removeEventListener('skatespots:relays-warm', onWarm); if (graceTimer) clearTimeout(graceTimer); clearTimeout(cap); }; }, [visible]); if (!visible) return null; const anim = (a: string): React.CSSProperties => prefersReducedMotion ? {} : { animation: a }; return (
{/* Fine grid texture — subtle depth, like a skate map underlay */}
{/* Dual breathing glow — red core with a lime undertone */}
{/* Logo in a glowing conic halo ring */}
{/* Rotating conic ring */}
); } export default SplashScreen;