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:
SkateSpots 2026-09-28 13:01:27 +07:00
parent 08bc3a8b41
commit d9ad9e9b29
8 changed files with 305 additions and 21 deletions

View file

@ -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/* →

View file

@ -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}
/>
);

View file

@ -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,

View file

@ -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,

View file

@ -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,18 +30,22 @@ 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;
},
});

View 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
View 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
View 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
}
}