Cold-start: pre-warm relays + branded splash screen (1.4.0)

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>
This commit is contained in:
shakespeare.diy 2026-09-09 12:05:35 +07:00
parent aede706fd3
commit f41af8f1ca
4 changed files with 239 additions and 0 deletions

View file

@ -16,6 +16,7 @@ import { LanguageProvider } from '@/contexts/LanguageContext';
import { AppConfig } from '@/contexts/AppContext'; import { AppConfig } from '@/contexts/AppContext';
import { PWAInstallPrompt } from '@/components/PWAInstallPrompt'; import { PWAInstallPrompt } from '@/components/PWAInstallPrompt';
import { BetaDisclaimerDialog } from '@/components/BetaDisclaimerDialog'; import { BetaDisclaimerDialog } from '@/components/BetaDisclaimerDialog';
import { SplashScreen } from '@/components/SplashScreen';
import { AmberCallbackHandler } from '@/components/auth/AmberCallbackHandler'; import { AmberCallbackHandler } from '@/components/auth/AmberCallbackHandler';
import AppRouter from './AppRouter'; import AppRouter from './AppRouter';
@ -58,6 +59,7 @@ export function App() {
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<NostrLoginProvider storageKey='nostr:login'> <NostrLoginProvider storageKey='nostr:login'>
<NostrProvider> <NostrProvider>
<SplashScreen />
<NostrSync /> <NostrSync />
<AmberCallbackHandler /> <AmberCallbackHandler />
<NWCProvider> <NWCProvider>

View file

@ -51,6 +51,61 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
queryClient.invalidateQueries({ queryKey: ['nostr'] }); queryClient.invalidateQueries({ queryKey: ['nostr'] });
}, [citrine.url, queryClient]); }, [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 // Initialize NPool only once
if (!pool.current) { if (!pool.current) {
pool.current = new NPool({ pool.current = new NPool({

View file

@ -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 (
<div
className="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-black overflow-hidden"
style={{
opacity: leaving ? 0 : 1,
transition: `opacity ${FADE_MS}ms ease-out`,
paddingTop: 'env(safe-area-inset-top, 0px)',
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
}}
role="status"
aria-live="polite"
aria-label="Loading SkateSpots"
>
{/* Ambient neon glow behind the mark */}
<div
className="pointer-events-none absolute left-1/2 top-1/2 h-[420px] w-[420px] -translate-x-1/2 -translate-y-1/2 rounded-full blur-[100px]"
style={{
background:
'radial-gradient(circle, rgba(255,0,64,0.28) 0%, rgba(204,255,0,0.08) 45%, transparent 70%)',
opacity: 0.9,
}}
/>
{/* Logo + wordmark */}
<div className="relative z-10 flex flex-col items-center">
<div
className={
prefersReducedMotion
? 'relative'
: 'relative animate-[splashPulse_1.6s_ease-in-out_infinite]'
}
>
<img
src="/images/icon-1024.png"
alt="SkateSpots"
className="h-20 w-20 rounded-2xl shadow-[0_0_40px_rgba(255,0,64,0.5)]"
/>
</div>
<h1
className="font-display neon-red mt-5 text-3xl tracking-wide"
style={{ textShadow: '0 0 12px #ff0040, 0 0 24px #ff0040' }}
>
SKATESPOTS
</h1>
{/* Progress sweep */}
<div className="mt-6 h-1 w-44 overflow-hidden rounded-full bg-white/10">
{prefersReducedMotion ? (
<div className="h-full w-1/2 rounded-full bg-gradient-to-r from-[#ff0040] to-[#ccff00]" />
) : (
<div
className="h-full w-1/2 rounded-full bg-gradient-to-r from-[#ff0040] to-[#ccff00]"
style={{ animation: 'splashSweep 1.05s ease-in-out infinite' }}
/>
)}
</div>
{/* Rotating status line */}
<p
key={statusIndex}
className="mt-4 font-mono text-[11px] uppercase tracking-[0.2em] text-gray-500"
style={prefersReducedMotion ? undefined : { animation: 'splashFade 420ms ease-out' }}
>
{STATUS_LINES[statusIndex]}
</p>
</div>
</div>
);
}
export default SplashScreen;

View file

@ -307,6 +307,20 @@
100% { transform: scale(1.6); opacity: 0; } 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 { .animate-fadeIn {
animation: fadeIn 0.4s ease-out forwards; animation: fadeIn 0.4s ease-out forwards;
} }