feat(offline): the map survives a dead relay (and says so) + skater@skate-spots.org

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.
This commit is contained in:
SkateSpots 2026-09-28 12:48:54 +07:00
parent c01521ea3f
commit c79373ed05
9 changed files with 154 additions and 49 deletions

View file

@ -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).

View file

@ -28,7 +28,7 @@ export function useMarketplaceItems(limit = 100, options: { includeHidden?: bool
limit,
},
],
{ signal }
{ signal, requireAny: true }
);
const items: MarketplaceItem[] = [];

View file

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

View file

@ -88,17 +88,20 @@ export function useSkateRelays() {
*/
const querySkateContent = async (
filters: NostrFilter[],
options: { signal: AbortSignal }
options: { signal: AbortSignal; requireAny?: boolean }
): Promise<NostrEvent[]> => {
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<NostrEvent[]> => {
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<string, NostrEvent>();
@ -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 [];
}
};

View file

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

View file

@ -36,7 +36,7 @@ export function useTours(limit = 100, options: { includeHidden?: boolean } = {})
limit,
},
],
{ signal }
{ signal, requireAny: true }
);
const tours: SkateTour[] = [];

View file

@ -99,6 +99,10 @@ const PERSIST_DENY_LIST = new Set<string>([
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;

View file

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

View file

@ -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 && (
<div className="absolute top-3 left-1/2 z-[1002] -translate-x-1/2">
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-amber-400/30 bg-black/75 px-3 py-1.5 shadow-lg backdrop-blur-md">
<WifiOff className="h-3.5 w-3.5 text-amber-400" />
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-amber-200/90">
Offline · showing saved spots
</span>
<button
type="button"
onClick={() => refetchSpots()}
className="ml-0.5 rounded-full p-1 text-amber-200/80 transition-colors hover:bg-white/10 hover:text-white"
aria-label="Try again"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
{/* 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() {
</div>
)}
{/* 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 && (
<div className="pointer-events-none absolute inset-0 z-[1001] flex items-center justify-center px-4">
<div className="pointer-events-auto max-w-sm rounded-2xl border border-amber-400/25 bg-black/85 p-6 text-center shadow-2xl backdrop-blur-md">
<WifiOff className="mx-auto h-7 w-7 text-amber-400" />
<p className="mt-2 font-black uppercase tracking-wide text-white">
Can't reach the relays
</p>
<p className="mt-1 text-xs leading-relaxed text-gray-400">
{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.
</p>
<Button
size="sm"
variant="outline"
className="mt-4 gap-2 border-white/20"
onClick={() => refetchSpots()}
>
<RefreshCw className="h-4 w-4" />
Try again
</Button>
</div>
</div>
)}
{/* 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 && (
<div className="pointer-events-none absolute inset-0 z-[1001] flex items-center justify-center px-4">
<div className="pointer-events-auto max-w-sm rounded-2xl border-2 border-[#ccff00]/25 bg-black/85 p-6 text-center shadow-2xl backdrop-blur-md">
<p className="text-2xl">🛹</p>