From c79373ed05759b8eb8080995b14d026801504182 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Mon, 28 Sep 2026 12:48:54 +0700 Subject: [PATCH] feat(offline): the map survives a dead relay (and says so) + skater@skate-spots.org MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relay outages no longer look like 'no spots'. querySkateContent swallowed every relay error and returned [], so a dead relay would blank the map AND overwrite good cached data with an empty list (then persist it). Now: - querySkateContent/queryCanonical take an opt-in 'requireAny' that throws when NO relay answered. Only the map's own feeds use it, so the blast radius is small (79 other call sites keep today's behaviour). - useSkateSpots/useSkateSpot fall back to the local IndexedDB cache on failure, so the map still works at the park; they only error when nothing is saved. - The persister refuses to persist an empty array, so an outage can't wipe the last-known map on the next cold start. - MapPage shows an honest 'Offline · showing saved spots' banner (reusing the existing relayProbe/useRelayStatus) and a 'Can't reach the relays' card instead of falsely claiming 'No spots mapped yet'. Also seeds skater@skate-spots.org for the second admin (caba497f…) and reserves the name so nobody else can claim it. --- netlify/functions/nip05.mjs | 6 ++- src/hooks/useMarketplace.ts | 2 +- src/hooks/useShops.ts | 2 +- src/hooks/useSkateRelays.ts | 24 ++++++++-- src/hooks/useSkateSpots.ts | 92 +++++++++++++++++++++++-------------- src/hooks/useTours.ts | 2 +- src/lib/queryPersister.ts | 4 ++ src/lib/skateNip05.ts | 2 +- src/pages/MapPage.tsx | 69 ++++++++++++++++++++++++++-- 9 files changed, 154 insertions(+), 49 deletions(-) diff --git a/netlify/functions/nip05.mjs b/netlify/functions/nip05.mjs index 3629f59..88f6d75 100644 --- a/netlify/functions/nip05.mjs +++ b/netlify/functions/nip05.mjs @@ -29,11 +29,13 @@ const OWNERS = new Set([ 'caba497f7beda0650d6b78ab211216e4d2ebabeec9ca6eb2a05cee8e55c6871b', ]); -// Names that answer even with no stored entry — the platform's own identity. -// Claimable by nobody; released by nobody. +// Names that answer even with no stored entry — the platform's own identities. +// Claimable by nobody; released by nobody. Keep in sync with src/lib/admins.ts. const SEED = { 'skate-spots': PLATFORM_PUBKEY, 'skatespots': PLATFORM_PUBKEY, + // Second admin — npub1e2ayjlmmaksx2rtt0z4jzyskunfwh2lwe89xav4qtnhgu4wxsudsp98a4l + 'skater': 'caba497f7beda0650d6b78ab211216e4d2ebabeec9ca6eb2a05cee8e55c6871b', }; // Never claimable by users (impersonation / infrastructure words). diff --git a/src/hooks/useMarketplace.ts b/src/hooks/useMarketplace.ts index d447114..3b16416 100644 --- a/src/hooks/useMarketplace.ts +++ b/src/hooks/useMarketplace.ts @@ -28,7 +28,7 @@ export function useMarketplaceItems(limit = 100, options: { includeHidden?: bool limit, }, ], - { signal } + { signal, requireAny: true } ); const items: MarketplaceItem[] = []; diff --git a/src/hooks/useShops.ts b/src/hooks/useShops.ts index 05ae996..de7bff1 100644 --- a/src/hooks/useShops.ts +++ b/src/hooks/useShops.ts @@ -19,7 +19,7 @@ export function useShops(limit = 100) { limit, }, ], - { signal } + { signal, requireAny: true } ); // Query old shops (kind 30818 with 'shop' type) for backward compatibility const oldShops = await querySkateContent( diff --git a/src/hooks/useSkateRelays.ts b/src/hooks/useSkateRelays.ts index 2eab58e..61320ae 100644 --- a/src/hooks/useSkateRelays.ts +++ b/src/hooks/useSkateRelays.ts @@ -88,17 +88,20 @@ export function useSkateRelays() { */ const querySkateContent = async ( filters: NostrFilter[], - options: { signal: AbortSignal } + options: { signal: AbortSignal; requireAny?: boolean } ): Promise => { const relays = getReadRelays(); + const { requireAny = false } = options; // Query all relays in parallel. Individual relay failures/timeouts are // expected (some relays are slow or offline) and handled silently — the // other relays still return results. + let succeeded = 0; const queryPromises = relays.map(async (relayUrl) => { try { const relay = nostr.relay(relayUrl); - const events = await relay.query(filters, options); + const events = await relay.query(filters, { signal: options.signal }); + succeeded += 1; return events; } catch { return []; @@ -106,6 +109,15 @@ export function useSkateRelays() { }); const results = await Promise.all(queryPromises); + + // If NO relay answered, that is an OUTAGE, not an empty result. Returning [] + // here would masquerade as "no spots" and overwrite good cached data with an + // empty list. Callers that can show cached content pass `requireAny` so they + // get a real error and fall back instead of blanking the screen. + if (requireAny && relays.length > 0 && succeeded === 0) { + throw new Error('No relay could be reached'); + } + const allEvents = results.flat(); // Deduplicate addressable events by pubkey+kind+d-tag @@ -141,11 +153,11 @@ export function useSkateRelays() { */ const queryCanonical = async ( filters: NostrFilter[], - options: { signal: AbortSignal } + options: { signal: AbortSignal; requireAny?: boolean } ): Promise => { try { const relay = nostr.relay(SKATE_RELAY); - const events = await relay.query(filters, options); + const events = await relay.query(filters, { signal: options.signal }); // Dedupe addressable events by pubkey+kind+d-tag (keep newest). const eventMap = new Map(); @@ -160,7 +172,9 @@ export function useSkateRelays() { } } return Array.from(eventMap.values()); - } catch { + } catch (err) { + // See querySkateContent: an outage must not look like "empty". + if (options.requireAny) throw err; return []; } }; diff --git a/src/hooks/useSkateSpots.ts b/src/hooks/useSkateSpots.ts index e52cf9a..c3e5ed5 100644 --- a/src/hooks/useSkateSpots.ts +++ b/src/hooks/useSkateSpots.ts @@ -27,42 +27,55 @@ export function useSkateSpots(limit = 100, options: { includeHidden?: boolean } select: (spots: SkateSpot[]) => includeHidden ? spots : filterAdminHidden(spots, SKATE_SPOT_KIND, hidden), queryFn: async () => { - // If offline, return cached spots - if (isOffline()) { + const fromCache = async () => { const cachedSpots = await getAllCachedSpots(); return cachedSpots.sort((a, b) => b.createdAt - a.createdAt).slice(0, limit); + }; + + // If offline, return cached spots + if (isOffline()) { + return fromCache(); } const signal = AbortSignal.timeout(QUERY_TIMEOUT); - const events = await querySkateContent( - [ - { - kinds: [SKATE_SPOT_KIND], - '#t': ['skatespot'], - limit, - }, - ], - { signal } - ); + try { + const events = await querySkateContent( + [ + { + kinds: [SKATE_SPOT_KIND], + '#t': ['skatespot'], + limit, + }, + ], + { signal, requireAny: true } + ); - const spots: SkateSpot[] = []; - const spotsWithEvents: Array<{ event: typeof events[0]; spot: SkateSpot }> = []; + const spots: SkateSpot[] = []; + const spotsWithEvents: Array<{ event: typeof events[0]; spot: SkateSpot }> = []; - for (const event of events) { - const spot = parseSkateSpot(event); - if (spot) { - spots.push(spot); - spotsWithEvents.push({ event, spot }); + for (const event of events) { + const spot = parseSkateSpot(event); + if (spot) { + spots.push(spot); + spotsWithEvents.push({ event, spot }); + } } - } - // Cache spots in the background for offline use - if (spotsWithEvents.length > 0) { - cacheSpots(spotsWithEvents).catch(() => {}); - } + // Cache spots in the background for offline use + if (spotsWithEvents.length > 0) { + cacheSpots(spotsWithEvents).catch(() => {}); + } - return spots.sort((a, b) => b.createdAt - a.createdAt); + return spots.sort((a, b) => b.createdAt - a.createdAt); + } catch (err) { + // Relays unreachable — NOT "no spots". Fall back to the last-known cache + // so the map still works at the skatepark; only surface the error when we + // have nothing saved at all (then the UI shows an honest offline card). + const cached = await fromCache(); + if (cached.length > 0) return cached; + throw err; + } }, staleTime: SPOTS_STALE_TIME, gcTime: SPOTS_GC_TIME, @@ -89,9 +102,10 @@ export function useSkateSpot(identifier: string, pubkey?: string) { return user?.pubkey === spot.pubkey ? spot : null; }, queryFn: async () => { + const { getCachedSpot, cacheSpot } = await import('@/lib/offlineCache'); + // If offline, try to get from cache first if (isOffline() && pubkey) { - const { getCachedSpot } = await import('@/lib/offlineCache'); const cached = await getCachedSpot(pubkey, identifier); if (cached) return cached; } @@ -113,19 +127,27 @@ export function useSkateSpot(identifier: string, pubkey?: string) { filter.authors = [pubkey]; } - const events = await querySkateContent([filter], { signal }); + try { + const events = await querySkateContent([filter], { signal, requireAny: true }); - if (events.length === 0) return null; + if (events.length === 0) return null; - const spot = parseSkateSpot(events[0]); + const spot = parseSkateSpot(events[0]); - // Cache the spot for offline use - if (spot) { - const { cacheSpot } = await import('@/lib/offlineCache'); - cacheSpot(events[0], spot).catch(() => {}); + // Cache the spot for offline use + if (spot) { + cacheSpot(events[0], spot).catch(() => {}); + } + + return spot; + } catch (err) { + // Relays unreachable: serve the cached copy if we have one, else error. + if (pubkey) { + const cached = await getCachedSpot(pubkey, identifier); + if (cached) return cached; + } + throw err; } - - return spot; }, enabled: !!identifier, staleTime: SPOTS_STALE_TIME, diff --git a/src/hooks/useTours.ts b/src/hooks/useTours.ts index e463bbc..52c7a0c 100644 --- a/src/hooks/useTours.ts +++ b/src/hooks/useTours.ts @@ -36,7 +36,7 @@ export function useTours(limit = 100, options: { includeHidden?: boolean } = {}) limit, }, ], - { signal } + { signal, requireAny: true } ); const tours: SkateTour[] = []; diff --git a/src/lib/queryPersister.ts b/src/lib/queryPersister.ts index 64934cb..eda6b59 100644 --- a/src/lib/queryPersister.ts +++ b/src/lib/queryPersister.ts @@ -99,6 +99,10 @@ const PERSIST_DENY_LIST = new Set([ export function shouldDehydrateQuery(query: Query): boolean { if (query.state.status !== 'success') return false; if (query.state.data === undefined || query.state.data === null) return false; + // Never persist an empty list. A transient relay outage can legitimately + // return 0 results, and caching that would wipe the last-known map on the next + // cold start — the exact opposite of what persistence is for. + if (Array.isArray(query.state.data) && query.state.data.length === 0) return false; const prefix = Array.isArray(query.queryKey) ? query.queryKey[0] : query.queryKey; if (typeof prefix !== 'string') return false; diff --git a/src/lib/skateNip05.ts b/src/lib/skateNip05.ts index bfab1f2..3a0d37d 100644 --- a/src/lib/skateNip05.ts +++ b/src/lib/skateNip05.ts @@ -20,7 +20,7 @@ const RESERVED = new Set([ 'admin', 'administrator', 'abuse', 'api', 'bot', 'help', 'mail', 'mod', 'moderator', 'official', 'postmaster', 'root', 'security', 'staff', 'support', 'system', 'test', 'undefined', 'null', 'webmaster', 'www', - 'skate-spots', 'skatespots', + 'skate-spots', 'skatespots', 'skater', ]); // Keep in sync with NAME_RE in netlify/functions/nip05.mjs. diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx index 480f350..ea0f533 100644 --- a/src/pages/MapPage.tsx +++ b/src/pages/MapPage.tsx @@ -24,6 +24,8 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { LoginArea } from '@/components/auth/LoginArea'; import { useSkateSpots } from '@/hooks/useSkateSpots'; +import { useRelayStatus } from '@/hooks/useRelayStatus'; +import { useOnlineStatus } from '@/hooks/useOfflineCache'; import { useShops } from '@/hooks/useShops'; import { useMarketplaceItems } from '@/hooks/useMarketplace'; import { useTours } from '@/hooks/useTours'; @@ -43,7 +45,7 @@ import { useSatsToUsd } from '@/hooks/useExchangeRate'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { genUserName } from '@/lib/genUserName'; import { useTranslation } from '@/contexts/language'; -import { SPOT_CATEGORIES, SPOT_TYPE_LABELS, SHELTER_INFO } from '@/lib/types'; +import { SPOT_CATEGORIES, SPOT_TYPE_LABELS, SHELTER_INFO, SKATE_RELAY } from '@/lib/types'; import type { SkateSpot } from '@/lib/types'; import ngeohash from 'ngeohash'; import { @@ -77,6 +79,8 @@ import { MessageCircle, ChevronDown, Smartphone, + WifiOff, + RefreshCw, } from 'lucide-react'; import { SpotChat } from '@/components/SpotChat'; import { useToast } from '@/hooks/useToast'; @@ -533,9 +537,16 @@ export default function MapPage() { const { user } = useCurrentUser(); const { data: followingList } = useFollowing(user?.pubkey); const followCount = followingList?.length ?? 0; - const { data: spots, isLoading: spotsLoading } = useSkateSpots(2000); + const { data: spots, isLoading: spotsLoading, refetch: refetchSpots } = useSkateSpots(2000); const { data: shops, isLoading: shopsLoading } = useShops(500); + // Wi-Fi present ≠ relay reachable (captive portal / relay outage). The utility + // prefers saved spots over a blank map, so we detect that honestly and say so. + // Reuses the existing relay probe (lib/relayProbe) and online/offline events. + const online = useOnlineStatus(); + const relay = useRelayStatus(SKATE_RELAY); + const relayUnreachable = !online || relay.status === 'offline'; + // Tell the cold-start splash that real content has arrived, so it can reveal // the map already populated (rather than dismissing on bare connection). We // signal readiness when the spots query has genuinely resolved — either it @@ -1646,6 +1657,29 @@ export default function MapPage() { + {/* Offline / relay-down banner. The map keeps showing the + last-known spots from the local cache — this just says so + honestly, and offers a retry. Shown only once we know the relay + is unreachable, so a slow cold start never flashes it. */} + {!isLoading && relayUnreachable && allLocations.length > 0 && ( +
+
+ + + Offline · showing saved spots + + +
+
+ )} + {/* Empty state — the active filters hid everything. Without this the map just looks blank/unloaded. Only shown when the spot/shop layers are on (other layers have their own markers). */} @@ -1661,10 +1695,39 @@ export default function MapPage() { )} + {/* Offline with nothing cached yet — don't pretend the map is + empty ("be the first to map your park"): say what's actually + wrong and offer a retry. */} + {!isLoading && relayUnreachable && (showSpots || showShops) && allLocations.length === 0 && ( +
+
+ +

+ Can't reach the relays +

+

+ {online + ? "You're online, but the community relay isn't answering right now." + : "You're offline."}{' '} + Spots you've already seen are saved on this device and will show here. +

+ +
+
+ )} + {/* Nothing mapped here AT ALL — the honest empty map. This is the density problem, not a filter problem, so the fix on offer is "add the first one" rather than "clear your filters". */} - {!isLoading && (showSpots || showShops) && allLocations.length === 0 && ( + {!isLoading && !relayUnreachable && (showSpots || showShops) && allLocations.length === 0 && (

🛹