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:
parent
aede706fd3
commit
f41af8f1ca
4 changed files with 239 additions and 0 deletions
|
|
@ -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() {
|
|||
<QueryClientProvider client={queryClient}>
|
||||
<NostrLoginProvider storageKey='nostr:login'>
|
||||
<NostrProvider>
|
||||
<SplashScreen />
|
||||
<NostrSync />
|
||||
<AmberCallbackHandler />
|
||||
<NWCProvider>
|
||||
|
|
|
|||
|
|
@ -51,6 +51,61 @@ const NostrProvider: React.FC<NostrProviderProps> = (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<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({
|
||||
|
|
|
|||
168
src/components/SplashScreen.tsx
Normal file
168
src/components/SplashScreen.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue