import { useEffect, useRef, useState } from 'react'; import { useTranslation } from '@/contexts/language'; /** * 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 = 380; // keep the brand beat, lose the artificial wait const WARM_GRACE_MS = 180; // a short breath after relays connect const MAX_MS = 900; // hard cap — reveal the shell even on a dead network const FADE_MS = 280; // iris-close exit const READY_HOLD_MS = 120; // show the ready check, then move const SPARKS = [ { sx: '-8px', sy: '18px', ex: '-54px', ey: '44px', delay: '0ms' }, { sx: '4px', sy: '22px', ex: '38px', ey: '58px', delay: '20ms' }, { sx: '-16px', sy: '10px', ex: '-72px', ey: '18px', delay: '40ms' }, { sx: '12px', sy: '14px', ex: '66px', ey: '30px', delay: '60ms' }, { sx: '-4px', sy: '24px', ex: '-28px', ey: '76px', delay: '80ms' }, { sx: '8px', sy: '24px', ex: '22px', ey: '78px', delay: '100ms' }, { sx: '-12px', sy: '2px', ex: '-48px', ey: '-16px', delay: '120ms' }, { sx: '14px', sy: '4px', ex: '52px', ey: '-12px', delay: '140ms' }, { sx: '-2px', sy: '28px', ex: '-10px', ey: '88px', delay: '160ms' }, { sx: '2px', sy: '28px', ex: '12px', ey: '90px', delay: '180ms' }, ]; const STATUS_KEYS = [ 'bootUi.statusWaking', 'bootUi.statusTuning', 'bootUi.statusPulling', 'bootUi.statusDropping', ]; export function SplashScreen() { const { t } = useTranslation(); 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_KEYS.length); }, 380); 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.9, (85 - p) * 0.065))); }, 60); 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 */}
{/* The mark arrives from the native splash's centre and settles into the column. No rotation: a quick, grounded drop with one landing burst. */}
{/* Static conic halo — colour and breathing glow, no spin */}
); } export default SplashScreen;