Instant cold-start: query persistence, optimistic geolocation, skeleton pins
Three UX wins so the app feels "already loaded" the moment it opens:
1. IndexedDB React Query persistence (lib/queryPersister.ts)
- PersistQueryClientProvider rehydrates the last-seen spots, shops,
events, tours, crews, coaches, listings and author profiles from
IndexedDB on cold start, so returning users land on a populated map
instead of a blank one; fresh data refetches quietly in the
background.
- Strict allow-list / deny-list: only public, JSON-serializable,
cold-start-relevant queries are persisted. Private/encrypted data
(stash, DMs, contacts, wallet/nwc/cashu/bitcoin, signer) is NEVER
written to disk. 24h maxAge + version buster keep it fresh & safe.
- Pinned @tanstack/react-query + query-core + persist-client to a
single 5.102.8 to avoid a query-core mismatch (partialMatchKey).
2. Optimistic geolocation (lib/lastLocation.ts)
- getCurrentPosition now caches every successful fix.
- The map opens ALREADY centered on the user's last-known area
(city zoom) instead of the world view; "you are here" + proximity
sort seed from the cache instantly.
- "Near Me" flies to the cached location immediately on tap, then
refines to the fresh GPS fix. Silent background warm-up on web map
mount so a later tap resolves instantly.
3. Cold-start skeleton (components/MapSkeletonPins.tsx)
- When the map is genuinely empty + loading, show elegant pulsing
ghost pins + a "Finding spots" pill instead of a blank map; they
vanish the instant real (or cached) spots render. Splash now
signals content-ready when spots actually resolve.
Respects prefers-reduced-motion throughout. Build + runtime verified
clean (no console errors).
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
96f8c795c4
commit
a984084464
9 changed files with 445 additions and 24 deletions
29
package-lock.json
generated
29
package-lock.json
generated
|
|
@ -57,7 +57,9 @@
|
||||||
"@radix-ui/react-tooltip": "^1.1.4",
|
"@radix-ui/react-tooltip": "^1.1.4",
|
||||||
"@scure/bip32": "^2.0.1",
|
"@scure/bip32": "^2.0.1",
|
||||||
"@scure/bip39": "1.6.0",
|
"@scure/bip39": "1.6.0",
|
||||||
|
"@tanstack/query-core": "^5.102.8",
|
||||||
"@tanstack/react-query": "^5.56.2",
|
"@tanstack/react-query": "^5.56.2",
|
||||||
|
"@tanstack/react-query-persist-client": "^5.102.8",
|
||||||
"@unhead/addons": "^2.0.10",
|
"@unhead/addons": "^2.0.10",
|
||||||
"@unhead/react": "^2.0.10",
|
"@unhead/react": "^2.0.10",
|
||||||
"bitcoinjs-lib": "^7.0.0",
|
"bitcoinjs-lib": "^7.0.0",
|
||||||
|
|
@ -76,6 +78,7 @@
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"html5-qrcode": "^2.3.8",
|
"html5-qrcode": "^2.3.8",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
|
"idb-keyval": "^6.3.0",
|
||||||
"input-otp": "^1.2.4",
|
"input-otp": "^1.2.4",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"leaflet.markercluster": "^1.5.3",
|
"leaflet.markercluster": "^1.5.3",
|
||||||
|
|
@ -3520,13 +3523,13 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.75.0",
|
"version": "5.102.8",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.75.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.8.tgz",
|
||||||
"integrity": "sha512-rk8KQuCdhoRkzjRVF3QxLgAfFUyS0k7+GCQjlGEpEGco+qazJ0eMH6aO1DjDjibH7/ik383nnztua3BG+lOnwg==",
|
"integrity": "sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"url": "https://github.com/sponsors/tannerlinsley",
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-query": {
|
"node_modules/@tanstack/react-query": {
|
||||||
|
|
@ -3545,6 +3548,16 @@
|
||||||
"react": "^18 || ^19"
|
"react": "^18 || ^19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/react-query-persist-client": {
|
||||||
|
"version": "5.102.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query-persist-client/-/react-query-persist-client-5.102.8.tgz",
|
||||||
|
"integrity": "sha512-WOZReC+m9klekPtG7eeZ3txJeMA9lg2R0RytSG3yXcZgJItQAVIyoobJdbdIQtlpgcU2tSzsOEBtJacs2vlCOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@testing-library/dom": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.1",
|
"version": "10.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||||
|
|
@ -5993,6 +6006,12 @@
|
||||||
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
|
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/idb-keyval": {
|
||||||
|
"version": "6.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.3.0.tgz",
|
||||||
|
"integrity": "sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,9 @@
|
||||||
"@radix-ui/react-tooltip": "^1.1.4",
|
"@radix-ui/react-tooltip": "^1.1.4",
|
||||||
"@scure/bip32": "^2.0.1",
|
"@scure/bip32": "^2.0.1",
|
||||||
"@scure/bip39": "1.6.0",
|
"@scure/bip39": "1.6.0",
|
||||||
"@tanstack/react-query": "^5.56.2",
|
"@tanstack/query-core": "^5.102.8",
|
||||||
|
"@tanstack/react-query": "5.102.8",
|
||||||
|
"@tanstack/react-query-persist-client": "5.102.8",
|
||||||
"@unhead/addons": "^2.0.10",
|
"@unhead/addons": "^2.0.10",
|
||||||
"@unhead/react": "^2.0.10",
|
"@unhead/react": "^2.0.10",
|
||||||
"bitcoinjs-lib": "^7.0.0",
|
"bitcoinjs-lib": "^7.0.0",
|
||||||
|
|
@ -83,6 +85,7 @@
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"html5-qrcode": "^2.3.8",
|
"html5-qrcode": "^2.3.8",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
|
"idb-keyval": "^6.3.0",
|
||||||
"input-otp": "^1.2.4",
|
"input-otp": "^1.2.4",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"leaflet.markercluster": "^1.5.3",
|
"leaflet.markercluster": "^1.5.3",
|
||||||
|
|
|
||||||
31
src/App.tsx
31
src/App.tsx
|
|
@ -1,10 +1,17 @@
|
||||||
// NOTE: This file should normally not be modified unless you are adding a new provider.
|
// NOTE: This file should normally not be modified unless you are adding a new provider.
|
||||||
// To add new routes, edit the AppRouter.tsx file.
|
// To add new routes, edit the AppRouter.tsx file.
|
||||||
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
|
||||||
import { createHead, UnheadProvider } from '@unhead/react/client';
|
import { createHead, UnheadProvider } from '@unhead/react/client';
|
||||||
import { InferSeoMetaPlugin } from '@unhead/addons';
|
import { InferSeoMetaPlugin } from '@unhead/addons';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
|
import {
|
||||||
|
createIDBPersister,
|
||||||
|
shouldDehydrateQuery,
|
||||||
|
PERSIST_BUSTER,
|
||||||
|
PERSIST_MAX_AGE,
|
||||||
|
} from '@/lib/queryPersister';
|
||||||
import NostrProvider from '@/components/NostrProvider';
|
import NostrProvider from '@/components/NostrProvider';
|
||||||
import { NostrSync } from '@/components/NostrSync';
|
import { NostrSync } from '@/components/NostrSync';
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
|
|
@ -36,6 +43,10 @@ const queryClient = new QueryClient({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// IndexedDB-backed persistence so the app rehydrates the last-seen spots,
|
||||||
|
// shops, events and profiles instantly on a cold start (see lib/queryPersister).
|
||||||
|
const persister = createIDBPersister();
|
||||||
|
|
||||||
const defaultConfig: AppConfig = {
|
const defaultConfig: AppConfig = {
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
relayMetadata: {
|
relayMetadata: {
|
||||||
|
|
@ -56,7 +67,21 @@ export function App() {
|
||||||
<UnheadProvider head={head}>
|
<UnheadProvider head={head}>
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<AppProvider storageKey="nostr:app-config" defaultConfig={defaultConfig}>
|
<AppProvider storageKey="nostr:app-config" defaultConfig={defaultConfig}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<PersistQueryClientProvider
|
||||||
|
client={queryClient}
|
||||||
|
persistOptions={{
|
||||||
|
persister,
|
||||||
|
// Discard persisted content older than 24h so nothing stale lingers.
|
||||||
|
maxAge: PERSIST_MAX_AGE,
|
||||||
|
// Invalidate the whole cache when the persisted data shape changes.
|
||||||
|
buster: PERSIST_BUSTER,
|
||||||
|
dehydrateOptions: {
|
||||||
|
// Only persist public, cold-start-relevant queries — never
|
||||||
|
// private/encrypted data. See lib/queryPersister.
|
||||||
|
shouldDehydrateQuery,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
<NostrLoginProvider storageKey='nostr:login'>
|
<NostrLoginProvider storageKey='nostr:login'>
|
||||||
<NostrProvider>
|
<NostrProvider>
|
||||||
<SplashScreen />
|
<SplashScreen />
|
||||||
|
|
@ -78,7 +103,7 @@ export function App() {
|
||||||
</NWCProvider>
|
</NWCProvider>
|
||||||
</NostrProvider>
|
</NostrProvider>
|
||||||
</NostrLoginProvider>
|
</NostrLoginProvider>
|
||||||
</QueryClientProvider>
|
</PersistQueryClientProvider>
|
||||||
</AppProvider>
|
</AppProvider>
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
</UnheadProvider>
|
</UnheadProvider>
|
||||||
|
|
|
||||||
87
src/components/MapSkeletonPins.tsx
Normal file
87
src/components/MapSkeletonPins.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
/**
|
||||||
|
* MapSkeletonPins
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* A tasteful "spots are loading" overlay shown ONLY on a genuinely cold, empty
|
||||||
|
* map (no cached spots yet + still fetching). Instead of a blank map that reads
|
||||||
|
* as broken, we scatter a few softly-pulsing ghost pins in the SkateSpots pin
|
||||||
|
* shape, so the moment reads as "finding spots near you…".
|
||||||
|
*
|
||||||
|
* It's purely decorative and non-interactive (`pointer-events-none`), sits above
|
||||||
|
* the tiles but below every real control, and fully respects reduced-motion.
|
||||||
|
* The instant real spots arrive the parent unmounts this — no layout shift.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Ghost {
|
||||||
|
/** left/top as viewport percentages so it scales on any screen. */
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
/** teardrop scale + animation delay for an organic, staggered feel. */
|
||||||
|
scale: number;
|
||||||
|
delay: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hand-placed so they feel scattered-but-balanced (not a rigid grid).
|
||||||
|
const GHOSTS: Ghost[] = [
|
||||||
|
{ left: 22, top: 34, scale: 1.0, delay: 0 },
|
||||||
|
{ left: 48, top: 22, scale: 0.82, delay: 0.35 },
|
||||||
|
{ left: 71, top: 38, scale: 1.1, delay: 0.15 },
|
||||||
|
{ left: 34, top: 58, scale: 0.9, delay: 0.6 },
|
||||||
|
{ left: 62, top: 62, scale: 1.0, delay: 0.45 },
|
||||||
|
{ left: 82, top: 66, scale: 0.78, delay: 0.8 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function MapSkeletonPins() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 z-[900] overflow-hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{GHOSTS.map((g, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="absolute motion-safe:animate-ghost-pin"
|
||||||
|
style={{
|
||||||
|
left: `${g.left}%`,
|
||||||
|
top: `${g.top}%`,
|
||||||
|
animationDelay: `${g.delay}s`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Soft ground shadow / radar ping under each pin */}
|
||||||
|
<span
|
||||||
|
className="absolute left-1/2 top-full h-2 w-8 -translate-x-1/2 -translate-y-1 rounded-[50%] bg-[#ff0040]/20 blur-[3px] motion-safe:animate-ghost-ping"
|
||||||
|
style={{ animationDelay: `${g.delay}s` }}
|
||||||
|
/>
|
||||||
|
{/* SkateSpots teardrop pin outline */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 30 * g.scale,
|
||||||
|
height: 30 * g.scale,
|
||||||
|
borderRadius: '50% 50% 50% 0',
|
||||||
|
transform: 'rotate(-45deg)',
|
||||||
|
background:
|
||||||
|
'linear-gradient(135deg, rgba(255,0,64,0.18), rgba(255,102,0,0.14))',
|
||||||
|
border: '2px solid rgba(255,255,255,0.14)',
|
||||||
|
boxShadow: '0 4px 14px rgba(255,0,64,0.15)',
|
||||||
|
backdropFilter: 'blur(1px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Centered, quiet status label */}
|
||||||
|
<div className="absolute left-1/2 top-[46%] -translate-x-1/2 -translate-y-1/2">
|
||||||
|
<div className="flex items-center gap-2 rounded-full bg-black/45 px-3.5 py-1.5 backdrop-blur-sm ring-1 ring-white/10">
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<span className="absolute inline-flex h-full w-full rounded-full bg-[#ff0040] opacity-70 motion-safe:animate-ping" />
|
||||||
|
<span className="relative inline-flex h-2 w-2 rounded-full bg-[#ff0040]" />
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-white/70">
|
||||||
|
Finding spots
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MapSkeletonPins;
|
||||||
|
|
@ -342,6 +342,24 @@
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Map skeleton "ghost pins" (shown on a cold, empty, loading map) ──
|
||||||
|
The pin gently rises + breathes; the shadow under it pulses like a radar
|
||||||
|
ping. Kept subtle so it reads as "loading", never as real content. */
|
||||||
|
@keyframes ghostPin {
|
||||||
|
0%, 100% { transform: translateY(0) scale(1); opacity: 0.55; }
|
||||||
|
50% { transform: translateY(-6px) scale(1.04); opacity: 0.9; }
|
||||||
|
}
|
||||||
|
@keyframes ghostPing {
|
||||||
|
0%, 100% { transform: translateX(-50%) translateY(-0.25rem) scaleX(1); opacity: 0.35; }
|
||||||
|
50% { transform: translateX(-50%) translateY(-0.25rem) scaleX(1.6); opacity: 0.12; }
|
||||||
|
}
|
||||||
|
.animate-ghost-pin {
|
||||||
|
animation: ghostPin 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.animate-ghost-ping {
|
||||||
|
animation: ghostPing 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.animate-fadeIn {
|
.animate-fadeIn {
|
||||||
animation: fadeIn 0.4s ease-out forwards;
|
animation: fadeIn 0.4s ease-out forwards;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Capacitor, registerPlugin } from '@capacitor/core';
|
import { Capacitor, registerPlugin } from '@capacitor/core';
|
||||||
|
import { saveLastLocation } from '@/lib/lastLocation';
|
||||||
|
|
||||||
export interface GeoCoords {
|
export interface GeoCoords {
|
||||||
lat: number;
|
lat: number;
|
||||||
|
|
@ -161,11 +162,13 @@ export function isNativePlatform(): boolean {
|
||||||
export async function getCurrentPosition(): Promise<GeoCoords> {
|
export async function getCurrentPosition(): Promise<GeoCoords> {
|
||||||
const native = getNativeGeo();
|
const native = getNativeGeo();
|
||||||
|
|
||||||
if (native) {
|
const coords = native ? await getNativePosition(native) : await getWebPosition();
|
||||||
return getNativePosition(native);
|
|
||||||
}
|
|
||||||
|
|
||||||
return getWebPosition();
|
// Remember the fix so the map can open optimistically centered next time and
|
||||||
|
// "Near Me" has something to fly to instantly (see lib/lastLocation).
|
||||||
|
saveLastLocation(coords.lat, coords.lng);
|
||||||
|
|
||||||
|
return coords;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
67
src/lib/lastLocation.ts
Normal file
67
src/lib/lastLocation.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
/**
|
||||||
|
* Last-known-location cache.
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Getting a fresh GPS fix takes time. To make the map feel instant we remember
|
||||||
|
* the user's last successful location in localStorage and use it to:
|
||||||
|
* 1. Open the map ALREADY centered on their area (optimistic initial view).
|
||||||
|
* 2. Give "Near Me" something to fly to immediately while the fresh fix
|
||||||
|
* resolves in the background.
|
||||||
|
*
|
||||||
|
* This is a coarse convenience cache, not a source of truth — a fresh fix always
|
||||||
|
* supersedes it. We never store accuracy or timestamps beyond what's needed and
|
||||||
|
* it lives only in this browser/app.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LastLocation {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
/** Epoch ms when this fix was captured. */
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY = 'skatespots:last-location';
|
||||||
|
|
||||||
|
/** Consider a cached location "usable for centering" for 30 days. */
|
||||||
|
const MAX_AGE = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Persist a successful location fix. Never throws. */
|
||||||
|
export function saveLastLocation(lat: number, lng: number): void {
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
typeof lat !== 'number' ||
|
||||||
|
typeof lng !== 'number' ||
|
||||||
|
Number.isNaN(lat) ||
|
||||||
|
Number.isNaN(lng) ||
|
||||||
|
(lat === 0 && lng === 0)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload: LastLocation = { lat, lng, at: Date.now() };
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(payload));
|
||||||
|
} catch {
|
||||||
|
// ignore private-mode / quota failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the cached location if present and not expired. Returns null otherwise.
|
||||||
|
* Never throws.
|
||||||
|
*/
|
||||||
|
export function getLastLocation(): LastLocation | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as Partial<LastLocation>;
|
||||||
|
if (
|
||||||
|
typeof parsed?.lat !== 'number' ||
|
||||||
|
typeof parsed?.lng !== 'number' ||
|
||||||
|
typeof parsed?.at !== 'number'
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (Date.now() - parsed.at > MAX_AGE) return null;
|
||||||
|
return { lat: parsed.lat, lng: parsed.lng, at: parsed.at };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
134
src/lib/queryPersister.ts
Normal file
134
src/lib/queryPersister.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
/**
|
||||||
|
* IndexedDB-backed React Query persistence.
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* WHY
|
||||||
|
* On a cold start every Nostr query begins from an empty cache, so users land on
|
||||||
|
* a blank map/profile list and watch it populate as the relay round-trips
|
||||||
|
* complete (1–4s). By persisting the React Query cache to IndexedDB we can
|
||||||
|
* REHYDRATE the last-seen spots/shops/events/profiles instantly on the next
|
||||||
|
* launch — the app opens already populated and quietly refetches fresh data in
|
||||||
|
* the background.
|
||||||
|
*
|
||||||
|
* WHAT WE PERSIST (allow-list, see `shouldDehydrateQuery`)
|
||||||
|
* Only PUBLIC, plain-JSON, cold-start-relevant queries: skate spots, shops,
|
||||||
|
* events, tours, crews, coaches, marketplace listings, author profiles, and a
|
||||||
|
* few small stats. We DO NOT persist:
|
||||||
|
* - Private / encrypted data (stash, DMs, private contacts) — must never touch
|
||||||
|
* disk in plaintext.
|
||||||
|
* - Per-interaction / volatile data (zaps, unread counts, RSVPs).
|
||||||
|
* - Errored or still-pending queries.
|
||||||
|
* This keeps the persisted blob small, safe, and genuinely useful.
|
||||||
|
*
|
||||||
|
* SAFETY
|
||||||
|
* - `idb-keyval` writes to IndexedDB off the main thread; failures are swallowed
|
||||||
|
* (private-mode / quota) so persistence can never break app boot.
|
||||||
|
* - A `buster` invalidates the whole cache when the data shape changes.
|
||||||
|
* - `maxAge` discards anything older than 24h so stale content can't linger.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { get, set, del } from 'idb-keyval';
|
||||||
|
import type {
|
||||||
|
PersistedClient,
|
||||||
|
Persister,
|
||||||
|
} from '@tanstack/react-query-persist-client';
|
||||||
|
import type { Query } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
const IDB_KEY = 'skatespots:react-query-cache';
|
||||||
|
|
||||||
|
/** Bump when the persisted data shape changes to invalidate old caches. */
|
||||||
|
export const PERSIST_BUSTER = 'v1';
|
||||||
|
|
||||||
|
/** Discard any persisted cache older than this (ms). */
|
||||||
|
export const PERSIST_MAX_AGE = 24 * 60 * 60 * 1000; // 24h
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query-key prefixes that are SAFE and USEFUL to persist for instant cold-start.
|
||||||
|
* A query is persisted only if its first key segment is in this set (and it has
|
||||||
|
* successfully-fetched data — see `shouldDehydrateQuery`).
|
||||||
|
*/
|
||||||
|
const PERSIST_ALLOW_LIST = new Set<string>([
|
||||||
|
// Map + directory content (the stuff that makes the app look "loaded")
|
||||||
|
'skate-spots',
|
||||||
|
'skate-spot',
|
||||||
|
'user-skate-spots',
|
||||||
|
'skate-shops',
|
||||||
|
'shops',
|
||||||
|
'skate-events',
|
||||||
|
'upcoming-skate-events',
|
||||||
|
'tours',
|
||||||
|
'crews',
|
||||||
|
'coaches',
|
||||||
|
'lab-profiles',
|
||||||
|
'marketplace',
|
||||||
|
'marketplace-items',
|
||||||
|
'skaters',
|
||||||
|
// Author profiles (kind 0) — avatars/names everywhere benefit hugely
|
||||||
|
'author',
|
||||||
|
'authors',
|
||||||
|
// Small, non-sensitive aggregate stats
|
||||||
|
'growth-stats',
|
||||||
|
'community-stats',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query-key prefixes that must NEVER be persisted, even if they somehow match
|
||||||
|
* the allow-list. Private, encrypted, signer-derived, or highly volatile data.
|
||||||
|
*/
|
||||||
|
const PERSIST_DENY_LIST = new Set<string>([
|
||||||
|
'stash',
|
||||||
|
'stash-showcase',
|
||||||
|
'direct-messages',
|
||||||
|
'dms',
|
||||||
|
'private-contacts',
|
||||||
|
'nwc',
|
||||||
|
'wallet',
|
||||||
|
'cashu',
|
||||||
|
'bitcoin',
|
||||||
|
'signer',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether a single query should be written to disk.
|
||||||
|
* Persist only: successful queries, with data, whose key prefix is explicitly
|
||||||
|
* allow-listed and not deny-listed.
|
||||||
|
*/
|
||||||
|
export function shouldDehydrateQuery(query: Query): boolean {
|
||||||
|
if (query.state.status !== 'success') return false;
|
||||||
|
if (query.state.data === undefined || query.state.data === null) return false;
|
||||||
|
|
||||||
|
const prefix = Array.isArray(query.queryKey) ? query.queryKey[0] : query.queryKey;
|
||||||
|
if (typeof prefix !== 'string') return false;
|
||||||
|
|
||||||
|
if (PERSIST_DENY_LIST.has(prefix)) return false;
|
||||||
|
return PERSIST_ALLOW_LIST.has(prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An async Persister backed by IndexedDB (via idb-keyval). All operations are
|
||||||
|
* defensively wrapped so a storage failure never bubbles into React.
|
||||||
|
*/
|
||||||
|
export function createIDBPersister(): Persister {
|
||||||
|
return {
|
||||||
|
persistClient: async (client: PersistedClient) => {
|
||||||
|
try {
|
||||||
|
await set(IDB_KEY, client);
|
||||||
|
} catch {
|
||||||
|
// ignore — private mode / quota exceeded
|
||||||
|
}
|
||||||
|
},
|
||||||
|
restoreClient: async () => {
|
||||||
|
try {
|
||||||
|
return (await get<PersistedClient>(IDB_KEY)) ?? undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeClient: async () => {
|
||||||
|
try {
|
||||||
|
await del(IDB_KEY);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -84,6 +84,8 @@ import { SpotChat } from '@/components/SpotChat';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getCurrentPosition, GeolocationError, isNativeGeolocation } from '@/lib/geolocation';
|
import { getCurrentPosition, GeolocationError, isNativeGeolocation } from '@/lib/geolocation';
|
||||||
|
import { getLastLocation } from '@/lib/lastLocation';
|
||||||
|
import { MapSkeletonPins } from '@/components/MapSkeletonPins';
|
||||||
|
|
||||||
|
|
||||||
type ViewMode = 'map' | 'list';
|
type ViewMode = 'map' | 'list';
|
||||||
|
|
@ -556,14 +558,18 @@ export default function MapPage() {
|
||||||
const { data: shops, isLoading: shopsLoading } = useShops(500);
|
const { data: shops, isLoading: shopsLoading } = useShops(500);
|
||||||
|
|
||||||
// Tell the cold-start splash that real content has arrived, so it can reveal
|
// Tell the cold-start splash that real content has arrived, so it can reveal
|
||||||
// the map already populated (rather than dismissing on bare connection). The
|
// the map already populated (rather than dismissing on bare connection). We
|
||||||
// splash still has its own hard cap, so this only ever makes the reveal feel
|
// signal readiness when the spots query has genuinely resolved — either it
|
||||||
// more "ready" — it can't delay dismissal.
|
// streamed in some spots, or it finished loading (empty relay / offline). With
|
||||||
|
// IndexedDB persistence this often fires on the very first frame (cache hit),
|
||||||
|
// so returning users get an instant, already-populated reveal. The splash
|
||||||
|
// keeps its own hard cap, so this can only ever make the reveal feel readier.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!spotsLoading) {
|
const hasSpots = Array.isArray(spots) && spots.length > 0;
|
||||||
|
if (hasSpots || !spotsLoading) {
|
||||||
window.dispatchEvent(new Event('skatespots:content-ready'));
|
window.dispatchEvent(new Event('skatespots:content-ready'));
|
||||||
}
|
}
|
||||||
}, [spotsLoading]);
|
}, [spots, spotsLoading]);
|
||||||
const { data: marketplaceItems, isLoading: marketplaceLoading } = useMarketplaceItems(200);
|
const { data: marketplaceItems, isLoading: marketplaceLoading } = useMarketplaceItems(200);
|
||||||
const { data: tours, isLoading: toursLoading } = useTours(100);
|
const { data: tours, isLoading: toursLoading } = useTours(100);
|
||||||
// Upcoming events for the (opt-in) Events map layer
|
// Upcoming events for the (opt-in) Events map layer
|
||||||
|
|
@ -588,7 +594,12 @@ export default function MapPage() {
|
||||||
const smartDefault: TrustFilterLevel = (user && followCount >= 5) ? 'trusted' : 'all';
|
const smartDefault: TrustFilterLevel = (user && followCount >= 5) ? 'trusted' : 'all';
|
||||||
const [trustFilter, setTrustFilter] = useState<TrustFilterLevel>(smartDefault);
|
const [trustFilter, setTrustFilter] = useState<TrustFilterLevel>(smartDefault);
|
||||||
const [sortBy, setSortBy] = useState<SortOption>('recent');
|
const [sortBy, setSortBy] = useState<SortOption>('recent');
|
||||||
const [userLocation, setUserLocation] = useState<{lat: number; lon: number} | null>(null);
|
// Seed from the cached last-known location so the "you are here" badge and
|
||||||
|
// proximity sorting are available instantly; a fresh fix updates it.
|
||||||
|
const [userLocation, setUserLocation] = useState<{lat: number; lon: number} | null>(() => {
|
||||||
|
const cached = getLastLocation();
|
||||||
|
return cached ? { lat: cached.lat, lon: cached.lng } : null;
|
||||||
|
});
|
||||||
|
|
||||||
// Map entity type filters - if ?view=gear, only show gear initially
|
// Map entity type filters - if ?view=gear, only show gear initially
|
||||||
const [showSpots, setShowSpots] = useState(!focusGear);
|
const [showSpots, setShowSpots] = useState(!focusGear);
|
||||||
|
|
@ -624,9 +635,26 @@ export default function MapPage() {
|
||||||
const [layersOpen, setLayersOpen] = useState(false);
|
const [layersOpen, setLayersOpen] = useState(false);
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// BTC Map center for fetching places
|
// Optimistic initial view: if we have a cached last-known location, open the
|
||||||
const [mapCenter, setMapCenter] = useState<{ lat: number; lon: number }>({ lat: 40.7128, lon: -74.006 });
|
// map ALREADY centered on the user's area (and zoomed to city level) instead
|
||||||
|
// of the default world view. Resolved once at mount — a fresh fix or a manual
|
||||||
|
// pan supersedes it. Makes the map feel "already yours" the moment it opens.
|
||||||
|
const [initialView] = useState(() => {
|
||||||
|
const cached = getLastLocation();
|
||||||
|
return cached
|
||||||
|
? { center: [cached.lat, cached.lng] as [number, number], zoom: 12, lat: cached.lat, lon: cached.lng }
|
||||||
|
: { center: [40.7128, -74.006] as [number, number], zoom: 3, lat: 40.7128, lon: -74.006 };
|
||||||
|
});
|
||||||
|
const initialCenter = initialView.center;
|
||||||
|
const initialZoom = initialView.zoom;
|
||||||
|
|
||||||
|
// BTC Map center for fetching places — seeded from the cached location so
|
||||||
|
// location-aware layers (BTC Map, weather) query the right area immediately.
|
||||||
|
const [mapCenter, setMapCenter] = useState<{ lat: number; lon: number }>({
|
||||||
|
lat: initialView.lat,
|
||||||
|
lon: initialView.lon,
|
||||||
|
});
|
||||||
|
|
||||||
// Near Me fly-to state
|
// Near Me fly-to state
|
||||||
const [flyTo, setFlyTo] = useState<{ lat: number; lng: number; zoom?: number } | null>(null);
|
const [flyTo, setFlyTo] = useState<{ lat: number; lng: number; zoom?: number } | null>(null);
|
||||||
|
|
@ -641,11 +669,20 @@ export default function MapPage() {
|
||||||
setNearMeError(null);
|
setNearMeError(null);
|
||||||
setNearMeLoading(true);
|
setNearMeLoading(true);
|
||||||
|
|
||||||
|
// OPTIMISTIC: if we already know roughly where they are, fly there IMMEDIATELY
|
||||||
|
// so the map reacts on tap. The fresh fix below refines the exact position.
|
||||||
|
const cached = getLastLocation();
|
||||||
|
if (cached) {
|
||||||
|
setFlyTo({ lat: cached.lat, lng: cached.lng, zoom: 13 });
|
||||||
|
}
|
||||||
|
|
||||||
// Getting a GPS lock (especially first-time / on ROMs without Google's
|
// Getting a GPS lock (especially first-time / on ROMs without Google's
|
||||||
// fused provider) can take a while — let the user know it's working.
|
// fused provider) can take a while — let the user know it's working.
|
||||||
const locatingToast = toast({
|
const locatingToast = toast({
|
||||||
title: 'Locating you…',
|
title: cached ? 'Refining your location…' : 'Locating you…',
|
||||||
description: 'Getting a GPS fix. This can take up to a minute the first time.',
|
description: cached
|
||||||
|
? 'Centered on your last spot — dialing in a fresh GPS fix.'
|
||||||
|
: 'Getting a GPS fix. This can take up to a minute the first time.',
|
||||||
duration: 60000,
|
duration: 60000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -748,6 +785,27 @@ export default function MapPage() {
|
||||||
}
|
}
|
||||||
}, [sortBy, userLocation]);
|
}, [sortBy, userLocation]);
|
||||||
|
|
||||||
|
// Silent background warm-up (once, on mount): quietly refresh the location fix
|
||||||
|
// so it's cached and ready the instant the user taps "Near Me". This never
|
||||||
|
// moves the map or shows a prompt-driven toast — it just updates the badge /
|
||||||
|
// cache in the background. Skipped on native, where it would surface an OS
|
||||||
|
// permission prompt out of context (native users trigger it via "Near Me").
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNativeGeolocation()) return;
|
||||||
|
let cancelled = false;
|
||||||
|
getCurrentPosition()
|
||||||
|
.then(({ lat, lng }) => {
|
||||||
|
if (!cancelled) setUserLocation({ lat, lon: lng });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Silent — user hasn't asked to be located yet.
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Combine spots and shops into unified list for map display
|
// Combine spots and shops into unified list for map display
|
||||||
const allLocations = useMemo(() => {
|
const allLocations = useMemo(() => {
|
||||||
const spotList = (spots || []).map(spot => ({ ...spot, isShop: false }));
|
const spotList = (spots || []).map(spot => ({ ...spot, isShop: false }));
|
||||||
|
|
@ -1520,6 +1578,8 @@ export default function MapPage() {
|
||||||
}>
|
}>
|
||||||
<SpotMap
|
<SpotMap
|
||||||
spots={mapSpots}
|
spots={mapSpots}
|
||||||
|
center={initialCenter}
|
||||||
|
zoom={initialZoom}
|
||||||
marketplaceItems={marketplaceItems || []}
|
marketplaceItems={marketplaceItems || []}
|
||||||
tours={tours || []}
|
tours={tours || []}
|
||||||
btcMapPlaces={btcMapPlaces || []}
|
btcMapPlaces={btcMapPlaces || []}
|
||||||
|
|
@ -1551,6 +1611,11 @@ export default function MapPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Cold-start skeleton: only while genuinely loading with NO
|
||||||
|
markers on the map yet. The instant real (or cached) spots
|
||||||
|
render, mapSpots.length > 0 and this disappears — no blank map. */}
|
||||||
|
{isLoading && mapSpots.length === 0 && <MapSkeletonPins />}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Map attribution — bottom-left, sits ABOVE the mobile bottom
|
{/* Map attribution — bottom-left, sits ABOVE the mobile bottom
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue