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 { 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>
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
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; }
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue