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>
363 lines
14 KiB
TypeScript
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 },
|
|
);
|
|
});
|
|
}
|