diff --git a/AGENTS.md b/AGENTS.md index cc41440..964c192 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2125,11 +2125,14 @@ 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 +- **Stored relay lists are reconciled (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. + (`NostrSync`); and the community relay (`SKATE_RELAY`) is added to the NIP-65 list + when missing (unless `useSkateRelay` is off). `NostrSync` also **republishes** the + corrected kind-10002 once for non-Amber signers (silent) so other clients see the + skate scene; Amber users keep it local (no unprompted popup) and republish from + Settings → Relays. - ⚠️ `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 43a0b08..aa1f488 100644 --- a/src/components/AppProvider.tsx +++ b/src/components/AppProvider.tsx @@ -3,7 +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'; +import { pruneRetiredRelays, ensureCommunityRelay } from '@/lib/relays'; interface AppProviderProps { children: ReactNode; @@ -85,13 +85,15 @@ 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. + // Reconcile the stored list: drop retired/dead relays and make sure the + // community relay is present (unless the user turned it off with the toggle). + // Code defaults change over time, but a saved list is otherwise never rewritten. if (!updates.relayMetadata) { - const pruned = pruneRetiredRelays(config.relayMetadata); - if (pruned) { - updates.relayMetadata = pruned; + let md = config.relayMetadata; + md = pruneRetiredRelays(md) ?? md; + if (config.useSkateRelay !== false) md = ensureCommunityRelay(md) ?? md; + if (md !== config.relayMetadata) { + updates.relayMetadata = md; needsUpdate = true; } } diff --git a/src/components/NostrSync.tsx b/src/components/NostrSync.tsx index da28ec2..25f2d3e 100644 --- a/src/components/NostrSync.tsx +++ b/src/components/NostrSync.tsx @@ -7,7 +7,10 @@ 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'; +import { isRetiredRelay, withCommunityRelay } from '@/lib/relays'; +import { isInteractiveSigner } from '@/lib/signerCapabilities'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { SKATE_RELAY } from '@/lib/types'; // Citrine local relay (Android app by greenart7c3) const CITRINE_URL = 'ws://localhost:4869'; @@ -49,6 +52,8 @@ export function NostrSync() { const { config, updateConfig } = useAppContext(); const { toast } = useToast(); const queryClient = useQueryClient(); + const { mutate: publishEvent } = useNostrPublish(); + const communityHandled = useRef(false); // Track per-pubkey: null = never tried, false = failed, true = succeeded const syncState = useRef>(new Map()); @@ -80,6 +85,46 @@ export function NostrSync() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [user?.pubkey]); + // Ensure the community relay is in the user's NIP-65 list. Users with a silent + // signer also get the list republished once so OTHER Nostr clients see the skate + // scene; Amber users keep it local (they can republish from Settings → Relays). + useEffect(() => { + if (!user || communityHandled.current) return; + communityHandled.current = true; + if (config.useSkateRelay === false) return; + + const has = config.relayMetadata.relays.some( + (r) => r.url.toLowerCase() === SKATE_RELAY.toLowerCase(), + ); + if (has) return; + + const list = withCommunityRelay(config.relayMetadata.relays); + updateConfig((c) => ({ + ...c, + relayMetadata: { ...(c.relayMetadata ?? { relays: [], updatedAt: 0 }), relays: list }, + })); + + // Amber prompts per op — never publish unprompted. And only republish for a + // user who has actually published a NIP-65 list before. + if (isInteractiveSigner(user, logins)) return; + if (config.relayMetadata.updatedAt === 0) return; + + const tags = list + .map((r) => + r.read && r.write + ? ['r', r.url] + : r.read + ? ['r', r.url, 'read'] + : r.write + ? ['r', r.url, 'write'] + : null, + ) + .filter((t): t is string[] => t !== null); + publishEvent({ kind: 10002, content: '', tags }); + // Run once per logged-in user. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user?.pubkey]); + async function doSync(pubkey: string, attempt = 1) { // Always invalidate stale cache first so useAuthor refetches queryClient.invalidateQueries({ queryKey: ['author', pubkey] }); @@ -132,7 +177,7 @@ export function NostrSync() { if (events.length > 0) { const event = events[0]; if (event.created_at > config.relayMetadata.updatedAt) { - const fetchedRelays = event.tags + let fetchedRelays = event.tags .filter(([name]) => name === 'r') .map(([, url, marker]) => ({ url, @@ -141,6 +186,10 @@ export function NostrSync() { })) .filter((r) => !isRetiredRelay(r.url)); if (fetchedRelays.length > 0) { + // Never let a fetched list drop the community relay. + if (config.useSkateRelay !== false) { + fetchedRelays = withCommunityRelay(fetchedRelays); + } updateConfig(c => ({ ...c, relayMetadata: { diff --git a/src/lib/relays.ts b/src/lib/relays.ts index d7acabc..ead55ee 100644 --- a/src/lib/relays.ts +++ b/src/lib/relays.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { useAppContext } from '@/hooks/useAppContext'; import type { RelayMetadata } from '@/contexts/AppContext'; +import { SKATE_RELAY } from '@/lib/types'; /** Host (no scheme) for display, falling back to the raw value. */ export function relayHost(url: string): string { @@ -64,6 +65,20 @@ export function pruneRetiredRelays(metadata: RelayMetadata): RelayMetadata | nul }; } +/** Add the community relay to a NIP-65 relay list when it's missing. */ +export function withCommunityRelay( + relays: T[], +): T[] { + if (relays.some((r) => r.url.toLowerCase() === SKATE_RELAY.toLowerCase())) return relays; + return [{ url: SKATE_RELAY, read: true, write: true } as T, ...relays]; +} + +/** {@link withCommunityRelay} for a RelayMetadata; null when nothing changed. */ +export function ensureCommunityRelay(metadata: RelayMetadata): RelayMetadata | null { + const relays = withCommunityRelay(metadata.relays); + return relays === metadata.relays ? null : { ...metadata, relays }; +} + export interface ResolvedRelays { /** NIP-65 relays with blocked ones removed (read/write flags kept). */ configured: { url: string; read: boolean; write: boolean }[];