feat(offline): cache map tiles on-device + photo mirror fallback
Map tiles: new lib/tileCache.ts (Dexie SkateSpotsTilesDB, bounded to ~3000 tiles) and lib/cachedTileLayer.ts, a Leaflet TileLayer that reads through it (cached -> fetch+cache -> plain URL fallback). Wired into both maps (SpotMap, RouteDialog). Chosen over a service worker so it works in the Capacitor WebView too, where a SW never sees tile requests. Photos: useUploadFile drops from three Blossom hosts to two and records ['fallback', '<mirror>/<sha256>']. BlossomUploader signs once and Promise.any PUTs to every server in parallel, so the old three-host list was triple- uploading every photo (the 'tries them in order' comment was wrong). Read side: lib/imageFallback.ts derives same-hash mirrors and ImageWithFallback retries them before showing the placeholder - one choke point for ~20 photo call sites, so a dead host no longer blanks a spot.
This commit is contained in:
parent
08bc3a8b41
commit
d9ad9e9b29
8 changed files with 305 additions and 21 deletions
23
AGENTS.md
23
AGENTS.md
|
|
@ -536,8 +536,27 @@ session with **no other memory of this project**. So:
|
|||
reuses the existing **`useRelayStatus(url)`** (`lib/relayProbe`, a real
|
||||
Nostr handshake) plus `useOnlineStatus` — NOT `navigator.onLine` alone,
|
||||
which is true on a captive-portal Wi-Fi.
|
||||
- Not done yet: offline **map tiles** (needs a service worker on web / an
|
||||
IndexedDB tile layer for native) and the app shell offline — the next slice.
|
||||
- **Offline map tiles (done 2026-09-28):** `lib/tileCache.ts` (Dexie
|
||||
`SkateSpotsTilesDB`, bounded: ≤3000 tiles, ≤500 KB each, oldest-used pruned)
|
||||
+ `lib/cachedTileLayer.ts` (an `L.TileLayer` subclass that reads through it),
|
||||
used by **both** maps (`SpotMap`, `RouteDialog`). Deliberately NOT a service
|
||||
worker: a SW only helps the web build, while the Android app runs inside
|
||||
Capacitor's WebView where a SW never sees tile requests. Works on both.
|
||||
Still open: offline **app shell** (SW) — unnecessary for native, deferred for
|
||||
web.
|
||||
- **Photo redundancy (2026-09-28):** `useUploadFile` now uses **two** Blossom
|
||||
hosts (`blossom.primal.net`, `blossom.band`) instead of three. ⚠️
|
||||
`BlossomUploader.upload` signs ONE auth event then does
|
||||
`Promise.any(servers.map(fetch))` — it **PUTs to every server in parallel**, so
|
||||
the old 3-host list was silently **triple-uploading every photo** (the code
|
||||
comment claiming "tries them in order" was wrong). Two hosts = less client
|
||||
data, still a single signature (no extra Amber prompt). The returned tags gain
|
||||
`['fallback', '<mirror-host>/<sha256>']`, and **`lib/imageFallback.ts` +
|
||||
`ImageWithFallback` retry the same hash on the other known Blossom host on
|
||||
error** — one choke point covers every photo/logo call site (~20), so a dead
|
||||
host costs a moment, not a blank spot. Blossom is content-addressed, so the
|
||||
mirror URL is derived from the sha256 in the path (handles `/<sha>` and
|
||||
`/<sha>.<ext>`).
|
||||
- **Mints directory**: `/mints` page lists Cashu mints from the community
|
||||
**MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin
|
||||
at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* →
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { imageCandidates } from '@/lib/imageFallback';
|
||||
|
||||
interface ImageWithFallbackProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
/**
|
||||
|
|
@ -14,21 +15,35 @@ interface ImageWithFallbackProps extends React.ImgHTMLAttributes<HTMLImageElemen
|
|||
/**
|
||||
* Drop-in replacement for <img> that degrades gracefully when the source is
|
||||
* missing or 404s. Content images across the app (spots, events, marketplace
|
||||
* listings, crew logos, shop photos, tours, stash gear) come from
|
||||
* user-supplied URLs on arbitrary Blossom/host servers, which routinely go
|
||||
* away — without this they render a broken-image glyph. The fallback fills the
|
||||
* same layout box (className is applied to the wrapper) so nothing shifts.
|
||||
* listings, crew logos, shop photos, tours, stash gear) come from user-supplied
|
||||
* URLs on arbitrary Blossom/host servers, which routinely go away — without this
|
||||
* they render a broken-image glyph. The fallback fills the same layout box
|
||||
* (className is applied to the wrapper) so nothing shifts.
|
||||
*
|
||||
* Photo redundancy: before giving up, it retries the SAME file on the other known
|
||||
* Blossom hosts (see lib/imageFallback — Blossom is content-addressed, so the
|
||||
* mirror URL is derived from the sha256 in the path). A dead host therefore
|
||||
* usually costs a moment, not a missing spot photo.
|
||||
*/
|
||||
export function ImageWithFallback({
|
||||
src,
|
||||
alt = '',
|
||||
className,
|
||||
fallback,
|
||||
onError,
|
||||
...props
|
||||
}: ImageWithFallbackProps) {
|
||||
const candidates = useMemo(() => imageCandidates(src), [src]);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (!src || failed) {
|
||||
// Reset when the src changes — list rows recycle these components.
|
||||
useEffect(() => {
|
||||
setIndex(0);
|
||||
setFailed(false);
|
||||
}, [src]);
|
||||
|
||||
if (!src || failed || candidates.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -45,10 +60,17 @@ export function ImageWithFallback({
|
|||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
src={candidates[Math.min(index, candidates.length - 1)]}
|
||||
alt={alt}
|
||||
className={className}
|
||||
onError={() => setFailed(true)}
|
||||
onError={(event) => {
|
||||
if (index + 1 < candidates.length) {
|
||||
setIndex(index + 1);
|
||||
return;
|
||||
}
|
||||
setFailed(true);
|
||||
onError?.(event);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
} from '@/lib/geolocation';
|
||||
import { getLastLocation } from '@/lib/lastLocation';
|
||||
import { saveRoute, loadRoute, formatAge } from '@/lib/routeCache';
|
||||
import { createCachedTileLayer } from '@/lib/cachedTileLayer';
|
||||
import { calculateDistance } from '@/lib/sortOptions';
|
||||
import {
|
||||
fetchRoute,
|
||||
|
|
@ -336,7 +337,7 @@ export function RouteDialog({
|
|||
minZoom: 2,
|
||||
});
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
createCachedTileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '',
|
||||
subdomains: 'abc',
|
||||
maxZoom: 19,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type { SpotWeatherData } from '@/hooks/useSpotWeather';
|
|||
import type { BtcMapPlace } from '@/hooks/useBtcMapPlaces';
|
||||
import { isRecentlyVerified, isBoosted } from '@/hooks/useBtcMapPlaces';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { createCachedTileLayer } from '@/lib/cachedTileLayer';
|
||||
|
||||
/**
|
||||
* SECURITY: Leaflet popups are built as raw DOM, outside React's auto-escaping.
|
||||
|
|
@ -896,7 +897,8 @@ export function SpotMap({
|
|||
// Free, no-API-key basemap: OpenStreetMap standard tiles, darkened via a CSS
|
||||
// filter (see the `.skatespots-dark-tiles` rule in index.css) so it matches
|
||||
// the app's dark theme without depending on a keyed provider like CARTO.
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
// Wrapped in an on-device tile cache so the basemap still draws at the park.
|
||||
createCachedTileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '',
|
||||
subdomains: 'abc',
|
||||
minZoom: 2,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,23 @@ import { BlossomUploader } from '@nostrify/nostrify/uploaders';
|
|||
|
||||
import { useCurrentUser } from "./useCurrentUser";
|
||||
|
||||
/**
|
||||
* Two Blossom hosts. `BlossomUploader` signs ONE auth event and PUTs to every
|
||||
* server in parallel, resolving with the first success — so the same bytes
|
||||
* normally land on both from a single signature (no double Amber prompt). We then
|
||||
* record a mirror URL on the returned tags so photo redundancy is explicit in the
|
||||
* event; the read side also derives it from the sha256 (lib/imageFallback).
|
||||
*/
|
||||
const UPLOAD_SERVERS = ['https://blossom.primal.net/', 'https://blossom.band/'];
|
||||
|
||||
function hostOf(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function useUploadFile() {
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
|
|
@ -13,19 +30,23 @@ export function useUploadFile() {
|
|||
}
|
||||
|
||||
const uploader = new BlossomUploader({
|
||||
// Multiple servers so a single slow/down Blossom host doesn't hang the
|
||||
// upload (crew logo uploads were stalling when primal.net was slow).
|
||||
// The uploader tries them in order and falls back on failure.
|
||||
servers: [
|
||||
'https://blossom.primal.net/',
|
||||
'https://blossom.band/',
|
||||
'https://cdn.satellite.earth/',
|
||||
],
|
||||
servers: UPLOAD_SERVERS,
|
||||
signer: user.signer,
|
||||
});
|
||||
|
||||
const tags = await uploader.upload(file);
|
||||
|
||||
// Remember the mirror copy of the same blob. Blossom is content-addressed,
|
||||
// so `<other-host>/<sha256>` is the same file.
|
||||
const primaryUrl = tags[0]?.[1];
|
||||
const sha = tags.find(([name]) => name === 'x')?.[1];
|
||||
if (primaryUrl && sha) {
|
||||
const primaryHost = hostOf(primaryUrl);
|
||||
const mirror = UPLOAD_SERVERS.find((s) => hostOf(s) !== primaryHost);
|
||||
if (mirror) tags.push(['fallback', `${mirror.replace(/\/+$/, '')}/${sha}`]);
|
||||
}
|
||||
|
||||
return tags;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
62
src/lib/cachedTileLayer.ts
Normal file
62
src/lib/cachedTileLayer.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import L from 'leaflet';
|
||||
|
||||
import { getTile, putTile } from '@/lib/tileCache';
|
||||
|
||||
/**
|
||||
* A Leaflet tile layer that reads through an on-device IndexedDB cache.
|
||||
*
|
||||
* Behaviour per tile:
|
||||
* 1. cached blob → draw it immediately (works fully offline);
|
||||
* 2. else fetch → cache the bytes → draw them;
|
||||
* 3. offline and uncached → fall back to the plain tile URL, so Leaflet shows
|
||||
* its normal missing-tile state instead of hanging forever.
|
||||
*
|
||||
* Works on web and in the Capacitor WebView alike, unlike a service worker.
|
||||
*/
|
||||
export class CachedTileLayer extends L.TileLayer {
|
||||
protected createTile(coords: L.Coords, done: L.DoneCallback): HTMLElement {
|
||||
const tile = document.createElement('img');
|
||||
tile.alt = '';
|
||||
tile.setAttribute('role', 'presentation');
|
||||
|
||||
const key = `${coords.z}/${coords.x}/${coords.y}`;
|
||||
const url = this.getTileUrl(coords);
|
||||
|
||||
const useSrc = (src: string, revoke: boolean) => {
|
||||
tile.onload = () => {
|
||||
if (revoke) URL.revokeObjectURL(src);
|
||||
done(undefined, tile);
|
||||
};
|
||||
tile.onerror = (e) => done(e as unknown as Error, tile);
|
||||
tile.src = src;
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
const cached = await getTile(key);
|
||||
if (cached) {
|
||||
useSrc(URL.createObjectURL(cached), true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { mode: 'cors' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
if (blob.size > 0) void putTile(key, blob);
|
||||
useSrc(URL.createObjectURL(blob), true);
|
||||
} catch {
|
||||
// No cache and no network: let the browser try the URL directly.
|
||||
useSrc(url, false);
|
||||
}
|
||||
})();
|
||||
|
||||
return tile;
|
||||
}
|
||||
}
|
||||
|
||||
export function createCachedTileLayer(
|
||||
urlTemplate: string,
|
||||
options?: L.TileLayerOptions,
|
||||
): CachedTileLayer {
|
||||
return new CachedTileLayer(urlTemplate, options);
|
||||
}
|
||||
51
src/lib/imageFallback.ts
Normal file
51
src/lib/imageFallback.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Photo redundancy helpers.
|
||||
*
|
||||
* SkateSpots uploads images to Blossom, which is CONTENT-ADDRESSED: a blob's URL
|
||||
* is `<host>/<sha256>` (optionally with a file extension). That means a mirror of
|
||||
* the same file on another known host is derivable from the URL alone — no extra
|
||||
* bookkeeping needed — so a dead photo host never has to leave a blank spot.
|
||||
*
|
||||
* We only derive mirrors for Blossom-style hash paths; anything else is left
|
||||
* untouched (an external image URL has no meaningful alternative location).
|
||||
*/
|
||||
|
||||
/** Hosts the app uploads to, and therefore the hosts we can mirror across. */
|
||||
export const BLOSSOM_MIRROR_HOSTS = ['blossom.primal.net', 'blossom.band'];
|
||||
|
||||
/** `<64-hex>` or `<64-hex>.<ext>` */
|
||||
const HASH_RE = /^([0-9a-f]{64})(\.[a-z0-9]+)?$/i;
|
||||
|
||||
/**
|
||||
* Ordered candidate URLs for an image: the original first, then any explicitly
|
||||
* supplied fallbacks, then same-hash mirrors on the other known Blossom hosts.
|
||||
*/
|
||||
export function imageCandidates(
|
||||
src: string | undefined | null,
|
||||
extra: string[] = [],
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
const add = (url?: string | null) => {
|
||||
if (typeof url === 'string' && url && !out.includes(url)) out.push(url);
|
||||
};
|
||||
|
||||
add(src);
|
||||
for (const url of extra) add(url);
|
||||
if (!src) return out;
|
||||
|
||||
try {
|
||||
const parsed = new URL(src);
|
||||
const last = parsed.pathname.split('/').filter(Boolean).pop() ?? '';
|
||||
const match = HASH_RE.exec(last);
|
||||
if (match) {
|
||||
for (const host of BLOSSOM_MIRROR_HOSTS) {
|
||||
if (host === parsed.hostname) continue;
|
||||
add(`${parsed.protocol}//${host}/${match[1]}${match[2] ?? ''}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Relative or malformed URL — nothing to mirror.
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
106
src/lib/tileCache.ts
Normal file
106
src/lib/tileCache.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import Dexie, { type Table } from 'dexie';
|
||||
|
||||
/**
|
||||
* On-device cache of map tiles, so the basemap survives a dead network.
|
||||
*
|
||||
* Why not a service worker? A SW only helps the web build; the Android app runs
|
||||
* the bundle inside Capacitor's WebView, where a SW never sees tile requests. An
|
||||
* IndexedDB-backed Leaflet layer caches tiles identically on web and native, and
|
||||
* keeps the failure contained to the map (no "stale app shell" class of bug).
|
||||
*
|
||||
* Growth is bounded: tiles are pruned oldest-used-first once the count exceeds
|
||||
* MAX_TILES, and oversized responses are skipped.
|
||||
*/
|
||||
interface CachedTile {
|
||||
/** `${z}/${x}/${y}` */
|
||||
key: string;
|
||||
blob: Blob;
|
||||
cachedAt: number;
|
||||
lastUsed: number;
|
||||
}
|
||||
|
||||
/** Skip caching anything bigger than this (a 256px PNG is normally 5–30 KB). */
|
||||
export const MAX_TILE_BYTES = 500 * 1024;
|
||||
|
||||
/** Roughly a large city's worth of tiles; pruning keeps us under this. */
|
||||
const MAX_TILES = 3000;
|
||||
|
||||
class TileDB extends Dexie {
|
||||
tiles!: Table<CachedTile, string>;
|
||||
|
||||
constructor() {
|
||||
super('SkateSpotsTilesDB');
|
||||
this.version(1).stores({ tiles: 'key, lastUsed, cachedAt' });
|
||||
}
|
||||
}
|
||||
|
||||
let db: TileDB | null = null;
|
||||
|
||||
function getDB(): TileDB {
|
||||
if (!db) db = new TileDB();
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function getTile(key: string): Promise<Blob | null> {
|
||||
try {
|
||||
const row = await getDB().tiles.get(key);
|
||||
if (!row) return null;
|
||||
// Touch lastUsed so pruning keeps tiles you actually look at.
|
||||
void getDB().tiles.update(key, { lastUsed: Date.now() });
|
||||
return row.blob;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function putTile(key: string, blob: Blob): Promise<void> {
|
||||
if (blob.size === 0 || blob.size > MAX_TILE_BYTES) return;
|
||||
try {
|
||||
const now = Date.now();
|
||||
await getDB().tiles.put({ key, blob, cachedAt: now, lastUsed: now });
|
||||
void prune();
|
||||
} catch {
|
||||
// Quota / private mode — caching is best-effort, never fatal.
|
||||
}
|
||||
}
|
||||
|
||||
let pruning = false;
|
||||
|
||||
async function prune(): Promise<void> {
|
||||
if (pruning) return;
|
||||
pruning = true;
|
||||
try {
|
||||
const count = await getDB().tiles.count();
|
||||
if (count <= MAX_TILES) return;
|
||||
const oldest = await getDB()
|
||||
.tiles.orderBy('lastUsed')
|
||||
.limit(count - MAX_TILES)
|
||||
.primaryKeys();
|
||||
await getDB().tiles.bulkDelete(oldest);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
pruning = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTileCacheStats(): Promise<{ tiles: number; bytes: number }> {
|
||||
try {
|
||||
let bytes = 0;
|
||||
const tiles = await getDB().tiles.count();
|
||||
await getDB().tiles.each((t) => {
|
||||
bytes += t.blob?.size ?? 0;
|
||||
});
|
||||
return { tiles, bytes };
|
||||
} catch {
|
||||
return { tiles: 0, bytes: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearTiles(): Promise<void> {
|
||||
try {
|
||||
await getDB().tiles.clear();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue