SkateSpots/src/lib/geolocation.ts
shakespeare.diy a984084464 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>
2026-09-09 21:58:22 +07:00

363 lines
14 KiB
TypeScript

/**
* Platform-aware geolocation.
*
* THE BUG THIS FIXES
* ------------------
* On the native Android app (ZapStore APK) the in-WebView
* `navigator.geolocation.getCurrentPosition` returns PERMISSION_DENIED, because
* the WebView never gets the OS location permission on its own. The app then
* showed a "your BROWSER blocked location" help sheet — wrong and useless inside
* a native app.
*
* THE FIX
* -------
* On native we go through Capacitor's Geolocation plugin, accessed via
* `registerPlugin('Geolocation')` from the bundled @capacitor/core (same runtime
* pattern used for Camera + the Amber signer — no bare `@capacitor/geolocation`
* import in the JS bundle). We call requestPermissions() first, which triggers
* the real Android location prompt, then getCurrentPosition().
*
* IMPORTANT (native build): @capacitor/geolocation must be installed (it's in
* package.json) and synced with `npx cap sync android` so the native plugin is
* bundled into the APK and registered. On web we just use navigator.geolocation.
*
* CRASH HARDENING (the "Near Me closes the app / bounces to ZapStore" bug)
* -----------------------------------------------------------------------
* A native plugin call that throws on the Android MAIN thread produces a
* `FATAL EXCEPTION: main` that kills the process. No JS try/catch or Promise
* wrapper can intercept that — it happens in Java land, off our promise chain.
* `registerPlugin('Geolocation')` ALWAYS returns a JS proxy, even when the
* native class isn't actually registered in the APK (e.g. after a messy plugin
* upgrade / incomplete `cap sync`, or a missing play-services-location dep).
* Invoking a method on such a "ghost" proxy is exactly what crashes the app.
*
* So before we ever touch the native plugin we verify it is GENUINELY present
* by checking `Capacitor.isPluginAvailable('Geolocation')` and the live
* `window.Capacitor.Plugins.Geolocation` registry. If it's not truly there we
* DO NOT call it — we fall back to the WebView's `navigator.geolocation`
* instead of detonating a native crash. This keeps the app alive no matter what
* state the native project is in.
*/
import { Capacitor, registerPlugin } from '@capacitor/core';
import { saveLastLocation } from '@/lib/lastLocation';
export interface GeoCoords {
lat: number;
lng: number;
}
export type GeoErrorKind = 'denied' | 'unavailable' | 'timeout' | 'no_provider';
export class GeolocationError extends Error {
kind: GeoErrorKind;
constructor(kind: GeoErrorKind, message: string) {
super(message);
this.kind = kind;
this.name = 'GeolocationError';
}
}
// ─── Native plugin proxy ──────────────────────────────────────────────────────
interface CapPermissionStatus {
location: 'granted' | 'denied' | 'prompt' | 'prompt-with-rationale';
coarseLocation?: 'granted' | 'denied' | 'prompt' | 'prompt-with-rationale';
}
interface CapPosition {
coords: { latitude: number; longitude: number };
}
/**
* Permission methods are always safe. getCurrentPosition is attempted (safely
* wrapped) because on GrapheneOS the WebView's navigator.geolocation is blocked
* even after the OS permission is granted — so the native plugin is the only
* way to actually get a fix there. Recent @capacitor/geolocation falls back to
* Android LocationManager when Play Services is absent, so it no longer crashes.
*/
interface CapGeolocationPlugin {
checkPermissions(): Promise<CapPermissionStatus>;
requestPermissions(options?: { permissions?: string[] }): Promise<CapPermissionStatus>;
getCurrentPosition(options?: {
enableHighAccuracy?: boolean;
timeout?: number;
maximumAge?: number;
}): Promise<CapPosition>;
}
/**
* Verify the Geolocation plugin is GENUINELY registered on the native side —
* not just that `registerPlugin` handed us a proxy (it always does). Calling a
* method on a proxy whose native class is missing is what crashes the app, so
* we must be certain the native implementation exists before we ever call it.
*/
function isNativeGeoAvailable(): boolean {
try {
if (!Capacitor?.isNativePlatform?.()) return false;
// Capacitor's own registry check — returns false if the native plugin
// class wasn't compiled/registered into this APK.
if (typeof Capacitor.isPluginAvailable === 'function') {
if (!Capacitor.isPluginAvailable('Geolocation')) return false;
}
// Belt-and-braces: confirm the live plugin registry actually holds a
// Geolocation entry with the methods we intend to call.
const w = globalThis as unknown as {
Capacitor?: { Plugins?: Record<string, unknown> };
};
const registered = w.Capacitor?.Plugins?.Geolocation as
| { getCurrentPosition?: unknown }
| undefined;
if (!registered || typeof registered.getCurrentPosition !== 'function') {
return false;
}
return true;
} catch {
return false;
}
}
let geoPlugin: CapGeolocationPlugin | null = null;
function getNativeGeo(): CapGeolocationPlugin | null {
if (!isNativeGeoAvailable()) return null;
if (!geoPlugin) {
try {
geoPlugin = registerPlugin<CapGeolocationPlugin>('Geolocation');
} catch {
return null;
}
}
return geoPlugin;
}
/**
* True when running inside the native app AND the Geolocation plugin is
* genuinely registered — i.e. the native permission path is the one in use.
* The UI uses this to tailor error copy ("open device Settings" vs. "browser
* settings"). When the native plugin is a ghost proxy we fall back to the web
* path, so this returns false and the UI shows the browser-appropriate help.
*/
export function isNativeGeolocation(): boolean {
return isNativeGeoAvailable();
}
/** True when running inside the native Capacitor app, regardless of plugins. */
export function isNativePlatform(): boolean {
try {
return Capacitor?.isNativePlatform?.() ?? false;
} catch {
return false;
}
}
// ─── Public API ───────────────────────────────────────────────────────────────
/**
* Get the device's current position. Uses the native Geolocation plugin on the
* app (with a proper OS permission prompt) and navigator.geolocation on web.
* Throws a GeolocationError with a `kind` the UI can branch on.
*/
export async function getCurrentPosition(): Promise<GeoCoords> {
const native = getNativeGeo();
const coords = native ? await getNativePosition(native) : await 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;
}
/**
* Native path.
*
* CRITICAL — GrapheneOS / de-Googled devices
* ------------------------------------------
* Capacitor Geolocation v8 fetches the position through Google Play Services
* (`play-services-location` / Ionic's `iongeolocation-android`). On a phone
* WITHOUT Play Services (GrapheneOS, /e/OS, most de-Googled ROMs) the plugin's
* `getCurrentPosition`/`watchPosition` touch Google classes that don't exist on
* the device → `NoClassDefFoundError` on the Android MAIN thread → the whole
* app is killed ("Near Me closes the app"). No JS try/catch can stop that.
*
* THE FIX: we use the native plugin ONLY to request the OS location permission
* (that path is pure Android, no Play Services). For the actual position we use
* the WebView's `navigator.geolocation`, which talks to Android's own
* LocationManager (GPS/network) directly and works fine without Google. This
* keeps a proper native permission prompt while never invoking the
* Play-Services position code that crashes de-Googled devices.
*/
async function getNativePosition(native: CapGeolocationPlugin): Promise<GeoCoords> {
// Wrap EVERY bridge call in Promise.resolve().then(...) so that even a
// SYNCHRONOUS throw from the native proxy becomes a rejected promise we can
// catch here — a sync throw is what can otherwise crash the whole app.
const safeCall = <T>(fn: () => Promise<T>): Promise<T> =>
Promise.resolve().then(fn);
try {
// Ask the OS for permission first — this shows the Android prompt.
// checkPermissions/requestPermissions do NOT use Play Services, so they are
// safe to call on de-Googled devices. Guard each step independently.
let status = await safeCall(() => native.checkPermissions()).catch(() => null);
if (!status || (status.location !== 'granted' && status.coarseLocation !== 'granted')) {
status = await safeCall(() =>
native.requestPermissions({ permissions: ['location', 'coarseLocation'] }),
).catch((permErr) => {
console.warn('[geolocation] requestPermissions failed:', permErr);
return null;
});
}
if (status && status.location !== 'granted' && status.coarseLocation !== 'granted') {
throw new GeolocationError('denied', 'Location permission denied.');
}
// Permission granted natively. Now get the actual position.
//
// On GrapheneOS the WebView's navigator.geolocation is BLOCKED even after the
// OS-level permission is granted (the WebView has its own geolocation gate
// that Capacitor doesn't open) → navigator.geolocation returns
// PERMISSION_DENIED, which is the "granted but still denied" bug users hit.
//
// So we ask the NATIVE plugin for the position first — it talks to Android's
// LocationManager directly and honours the permission we just granted.
// Modern @capacitor/geolocation degrades to LocationManager when Play
// Services is absent, so this no longer crashes de-Googled devices. We wrap
// it defensively and fall back to navigator.geolocation only if it fails.
try {
const pos = await safeCall(() =>
native.getCurrentPosition({ enableHighAccuracy: false, timeout: 15000, maximumAge: 60000 }),
);
const lat = pos?.coords?.latitude;
const lng = pos?.coords?.longitude;
if (typeof lat === 'number' && typeof lng === 'number' && !(lat === 0 && lng === 0)) {
return { lat, lng };
}
// Bad/empty reading — fall through to the web path.
} catch (nativePosErr) {
console.warn('[geolocation] native getCurrentPosition failed, falling back to WebView:', nativePosErr);
}
// Fallback: WebView navigator.geolocation (works on stock Android, shares
// the app's granted permission).
return await getWebPosition();
} catch (err) {
if (err instanceof GeolocationError) throw err;
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
if (msg.includes('denied') || msg.includes('permission')) {
throw new GeolocationError('denied', 'Location permission denied.');
}
// "location disabled" / "provider" → the OS has NO usable location provider.
// On GrapheneOS (no Google Play Services) there's often no network/fused
// provider, so only GPS satellites work — which fail indoors. Surface this
// as its own kind so the UI can give accurate advice.
if (
msg.includes('disabled') ||
msg.includes('provider') ||
msg.includes('settings') ||
msg.includes('unavailable')
) {
throw new GeolocationError(
'no_provider',
err instanceof Error ? err.message : String(err) || 'No location provider available.',
);
}
if (msg.includes('timeout') || msg.includes('timed out')) {
throw new GeolocationError('timeout', 'Location request timed out.');
}
// Anything else — pass the real native message through so it shows in the
// on-screen toast and we can see what actually failed without needing adb.
throw new GeolocationError(
'unavailable',
err instanceof Error ? err.message : String(err) || 'Location unavailable.',
);
}
}
/**
* Web path (also used as a safe fallback when the native plugin isn't genuinely
* registered). Uses the WebView/browser `navigator.geolocation`.
*
* Same fast-fix idea as native: start a low-accuracy watchPosition and resolve
* on the FIRST update (the quick network/wifi fix), racing it against a cheap
* getCurrentPosition. Whichever returns first wins.
*/
function getWebPosition(): Promise<GeoCoords> {
if (!('geolocation' in navigator)) {
return Promise.reject(
new GeolocationError('unavailable', 'Geolocation is not supported by this browser.'),
);
}
return new Promise<GeoCoords>((resolve, reject) => {
let settled = false;
let watchId: number | null = null;
let deadline: ReturnType<typeof setTimeout> | null = null;
const cleanup = () => {
if (deadline) clearTimeout(deadline);
if (watchId !== null) {
try {
navigator.geolocation.clearWatch(watchId);
} catch {
// ignore
}
watchId = null;
}
};
const succeed = (pos: GeolocationPosition) => {
if (settled) return;
settled = true;
cleanup();
resolve({ lat: pos.coords.latitude, lng: pos.coords.longitude });
};
const mapError = (err: GeolocationPositionError): GeolocationError => {
if (err.code === err.PERMISSION_DENIED) {
return new GeolocationError('denied', 'Location permission denied.');
}
if (err.code === err.TIMEOUT) {
return new GeolocationError('timeout', 'Location request timed out.');
}
return new GeolocationError('unavailable', 'Location unavailable.');
};
const fail = (err: GeolocationPositionError) => {
if (settled) return;
// Permission denial is terminal; other errors let the watch/deadline decide.
if (err.code === err.PERMISSION_DENIED) {
settled = true;
cleanup();
reject(mapError(err));
}
};
deadline = setTimeout(() => {
if (settled) return;
settled = true;
cleanup();
reject(new GeolocationError('timeout', 'Location request timed out.'));
}, 25000);
// 1) watchPosition — first update wins (fast network fix).
try {
watchId = navigator.geolocation.watchPosition(
(pos) => succeed(pos),
(err) => fail(err),
{ enableHighAccuracy: false, timeout: 20000, maximumAge: 60000 },
);
} catch {
// ignore — getCurrentPosition below still runs
}
// 2) getCurrentPosition — instant if a recent fix is cached.
navigator.geolocation.getCurrentPosition(
(pos) => succeed(pos),
(err) => fail(err),
{ enableHighAccuracy: false, timeout: 15000, maximumAge: 120000 },
);
});
}