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:
parent
c01521ea3f
commit
c79373ed05
9 changed files with 154 additions and 49 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export function useMarketplaceItems(limit = 100, options: { includeHidden?: bool
|
|||
limit,
|
||||
},
|
||||
],
|
||||
{ signal }
|
||||
{ signal, requireAny: true }
|
||||
);
|
||||
|
||||
const items: MarketplaceItem[] = [];
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export function useTours(limit = 100, options: { includeHidden?: boolean } = {})
|
|||
limit,
|
||||
},
|
||||
],
|
||||
{ signal }
|
||||
{ signal, requireAny: true }
|
||||
);
|
||||
|
||||
const tours: SkateTour[] = [];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue