diff --git a/AGENTS.md b/AGENTS.md index 42ffab3..cc41440 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2125,6 +2125,11 @@ The community relay moved off the hosted relay.tools instance relay will lose skate content once the relay.tools subscription is cancelled — cancel only after the Zapstore update has propagated. Historical newsletter text still names the old relay (left as-is on purpose). +- **Stored relay lists are migrated (2026-10-12):** `RETIRED_RELAY_HOSTS` in + `lib/relays.ts` (`goskatespots.nostr1.com`, dead `wotr.relatr.xyz`) are pruned at + runtime (`resolveRelays`), on config load (`AppProvider`) and on NIP-65 sync + (`NostrSync`), so existing users stop showing/using them without manual edits. + Saving the Relays screen republishes the cleaned NIP-65 list. - ⚠️ `relay.skate-spots.org` can show a stale NXDOMAIN in a local resolver cache right after creation (our dev box did). Verify with `curl --resolve relay.skate-spots.org:443:185.18.221.2 …` or from another host. diff --git a/src/components/AppProvider.tsx b/src/components/AppProvider.tsx index dd12e0d..43a0b08 100644 --- a/src/components/AppProvider.tsx +++ b/src/components/AppProvider.tsx @@ -3,6 +3,7 @@ import { z } from 'zod'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { safeJson } from '@/lib/safeJson'; import { AppContext, type AppConfig, type AppContextType, type Theme, type RelayMetadata } from '@/contexts/AppContext'; +import { pruneRetiredRelays } from '@/lib/relays'; interface AppProviderProps { children: ReactNode; @@ -84,6 +85,17 @@ export function AppProvider(props: AppProviderProps) { } } + // One-time cleanup: drop retired/dead relays from an existing user's saved list. + // Code defaults change over time, but a user's stored relay list is otherwise + // never rewritten — so a retired relay would linger forever. + if (!updates.relayMetadata) { + const pruned = pruneRetiredRelays(config.relayMetadata); + if (pruned) { + updates.relayMetadata = pruned; + needsUpdate = true; + } + } + // Force dark theme (app is dark-only) if (config.theme !== 'dark') { updates.theme = 'dark'; diff --git a/src/components/NostrSync.tsx b/src/components/NostrSync.tsx index ef6845c..da28ec2 100644 --- a/src/components/NostrSync.tsx +++ b/src/components/NostrSync.tsx @@ -7,6 +7,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAppContext } from '@/hooks/useAppContext'; import { useToast } from '@/hooks/useToast'; import { useTranslation } from '@/contexts/language'; +import { isRetiredRelay } from '@/lib/relays'; // Citrine local relay (Android app by greenart7c3) const CITRINE_URL = 'ws://localhost:4869'; @@ -137,7 +138,8 @@ export function NostrSync() { url, read: !marker || marker === 'read', write: !marker || marker === 'write', - })); + })) + .filter((r) => !isRetiredRelay(r.url)); if (fetchedRelays.length > 0) { updateConfig(c => ({ ...c, diff --git a/src/lib/relays.ts b/src/lib/relays.ts index 39f0b79..d7acabc 100644 --- a/src/lib/relays.ts +++ b/src/lib/relays.ts @@ -27,6 +27,43 @@ export function normalizeRelayUrl(input: string): string | null { } } +/** Hosts that are retired or dead and are pruned from every relay list. */ +export const RETIRED_RELAY_HOSTS = new Set([ + 'goskatespots.nostr1.com', // legacy hosted relay — retired 2026-10-12 + 'wotr.relatr.xyz', // dead (does not resolve) +]); + +/** True when `url` points at a retired/dead relay host. */ +export function isRetiredRelay(url: string): boolean { + try { + return RETIRED_RELAY_HOSTS.has(new URL(url).host.toLowerCase()); + } catch { + return false; + } +} + +/** + * Remove retired/dead relays from a stored RelayMetadata. Returns a new object + * only when something actually changed (so callers can skip a no-op write), else + * null. + */ +export function pruneRetiredRelays(metadata: RelayMetadata): RelayMetadata | null { + const relays = metadata.relays.filter((r) => !isRetiredRelay(r.url)); + const local = metadata.localRelays?.filter((u) => !isRetiredRelay(u)); + const dm = metadata.dmRelays?.filter((u) => !isRetiredRelay(u)); + const changed = + relays.length !== metadata.relays.length || + (local?.length ?? 0) !== (metadata.localRelays?.length ?? 0) || + (dm?.length ?? 0) !== (metadata.dmRelays?.length ?? 0); + if (!changed) return null; + return { + ...metadata, + relays, + ...(metadata.localRelays ? { localRelays: local } : {}), + ...(metadata.dmRelays ? { dmRelays: dm } : {}), + }; +} + export interface ResolvedRelays { /** NIP-65 relays with blocked ones removed (read/write flags kept). */ configured: { url: string; read: boolean; write: boolean }[]; @@ -46,8 +83,12 @@ export interface ResolvedRelays { */ export function resolveRelays(metadata: RelayMetadata): ResolvedRelays { const blocked = new Set((metadata.blockedRelays ?? []).map((u) => u.toLowerCase())); - const local = (metadata.localRelays ?? []).filter((u) => !blocked.has(u.toLowerCase())); - const configured = metadata.relays.filter((r) => !blocked.has(r.url.toLowerCase())); + const local = (metadata.localRelays ?? []).filter( + (u) => !blocked.has(u.toLowerCase()) && !isRetiredRelay(u), + ); + const configured = metadata.relays.filter( + (r) => !blocked.has(r.url.toLowerCase()) && !isRetiredRelay(r.url), + ); return { configured,