diff --git a/AGENTS.md b/AGENTS.md index df06ea3..04fab24 100644 --- a/AGENTS.md +++ b/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', '/']`, 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 `/` and + `/.`). - **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/* → diff --git a/src/components/ImageWithFallback.tsx b/src/components/ImageWithFallback.tsx index a9904ef..3b24ae3 100644 --- a/src/components/ImageWithFallback.tsx +++ b/src/components/ImageWithFallback.tsx @@ -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 { /** @@ -14,21 +15,35 @@ interface ImageWithFallbackProps extends React.ImgHTMLAttributes 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 (
setFailed(true)} + onError={(event) => { + if (index + 1 < candidates.length) { + setIndex(index + 1); + return; + } + setFailed(true); + onError?.(event); + }} {...props} /> ); diff --git a/src/components/RouteDialog.tsx b/src/components/RouteDialog.tsx index b4ef27d..027d1cf 100644 --- a/src/components/RouteDialog.tsx +++ b/src/components/RouteDialog.tsx @@ -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, diff --git a/src/components/SpotMap.tsx b/src/components/SpotMap.tsx index 244ad46..b7735ac 100644 --- a/src/components/SpotMap.tsx +++ b/src/components/SpotMap.tsx @@ -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, diff --git a/src/hooks/useUploadFile.ts b/src/hooks/useUploadFile.ts index 6738164..200e53a 100644 --- a/src/hooks/useUploadFile.ts +++ b/src/hooks/useUploadFile.ts @@ -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 `/` 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; }, }); -} \ No newline at end of file +} diff --git a/src/lib/cachedTileLayer.ts b/src/lib/cachedTileLayer.ts new file mode 100644 index 0000000..f2bdf65 --- /dev/null +++ b/src/lib/cachedTileLayer.ts @@ -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); +} diff --git a/src/lib/imageFallback.ts b/src/lib/imageFallback.ts new file mode 100644 index 0000000..8f8e954 --- /dev/null +++ b/src/lib/imageFallback.ts @@ -0,0 +1,51 @@ +/** + * Photo redundancy helpers. + * + * SkateSpots uploads images to Blossom, which is CONTENT-ADDRESSED: a blob's URL + * is `/` (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>.` */ +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; +} diff --git a/src/lib/tileCache.ts b/src/lib/tileCache.ts new file mode 100644 index 0000000..328778d --- /dev/null +++ b/src/lib/tileCache.ts @@ -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; + + 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 { + 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 { + 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 { + 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 { + try { + await getDB().tiles.clear(); + } catch { + // ignore + } +}