import { useEffect, useRef } from 'react'; import { useNostr } from '@nostrify/react'; import { useNostrLogin } from '@/lib/nostrLogin'; import { useQueryClient } from '@tanstack/react-query'; import { NSchema as n } from '@nostrify/nostrify'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAppContext } from '@/hooks/useAppContext'; import { useToast } from '@/hooks/useToast'; import { useTranslation } from '@/contexts/language'; 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'; // Default relays for brand-new users with no NIP-65 event const NEW_USER_RELAYS = [ { url: 'wss://auth.nostr1.com', read: true, write: true }, { url: 'wss://relay.ditto.pub', read: true, write: true }, { url: 'wss://relay.skate-spots.org', read: true, write: true }, { url: 'wss://purplepag.es', read: true, write: false }, ]; // Always-on profile aggregators — try these unconditionally const BOOTSTRAP_RELAYS = [ 'wss://purplepag.es', 'wss://relay.nostr.band', 'wss://nos.lol', 'wss://relay.damus.io', ]; async function isCitrineAvailable(): Promise { return new Promise((resolve) => { try { const ws = new WebSocket(CITRINE_URL); const timer = setTimeout(() => { ws.close(); resolve(false); }, 1500); ws.onopen = () => { clearTimeout(timer); ws.close(); resolve(true); }; ws.onerror = () => { clearTimeout(timer); resolve(false); }; } catch { resolve(false); } }); } export function NostrSync() { const { t } = useTranslation(); const { nostr } = useNostr(); const { user } = useCurrentUser(); const { logins } = useNostrLogin(); 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()); const retryTimers = useRef>>(new Map()); useEffect(() => { if (!user) return; const pubkey = user.pubkey; // If already succeeded for this pubkey this session, skip if (syncState.current.get(pubkey) === true) return; // Clear any existing retry timer for this pubkey const existing = retryTimers.current.get(pubkey); if (existing) clearTimeout(existing); // PWA needs longer to re-establish WebSocket connections after Amber redirect const isPWA = window.matchMedia('(display-mode: standalone)').matches; const initialDelay = isPWA ? 2000 : 600; const timer = setTimeout(() => doSync(pubkey), initialDelay); retryTimers.current.set(pubkey, timer); return () => { clearTimeout(timer); }; // Re-run whenever user changes — this covers Amber login completing via polling // 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] }); queryClient.removeQueries({ queryKey: ['author', pubkey] }); let profileSynced = false; let relaysSynced = false; // ── helper: fetch kind 0 from a relay source ──────────────────────────── const fetchProfile = async (source: typeof nostr | ReturnType) => { try { const events = await source.query( [{ kinds: [0], authors: [pubkey], limit: 1 }], { signal: AbortSignal.timeout(6000) } ); if (events.length > 0) { const event = events[0]; try { const metadata = n.json().pipe(n.metadata()).parse(event.content); // Populate useAuthor cache queryClient.setQueryData(['author', pubkey], { metadata, event }); // Populate useLoggedInAccounts cache const loginsKey = logins.map((l) => l.id).join(';'); queryClient.setQueryData( ['nostr', 'logins', loginsKey], (old: Array<{ id: string; pubkey: string; metadata?: typeof metadata }> | undefined) => { const base = old ?? logins.map(l => ({ id: l.id, pubkey: l.pubkey })); return base.map(a => a.pubkey === pubkey ? { ...a, metadata, event } : a); } ); return true; } catch { queryClient.setQueryData(['author', pubkey], { event }); return true; } } } catch (e) { console.warn('[NostrSync] fetchProfile failed:', e); } return false; }; // ── helper: fetch kind 10002 from a relay source ──────────────────────── const fetchRelays = async (source: typeof nostr | ReturnType) => { try { const events = await source.query( [{ kinds: [10002], authors: [pubkey], limit: 1 }], { signal: AbortSignal.timeout(6000) } ); if (events.length > 0) { const event = events[0]; if (event.created_at > config.relayMetadata.updatedAt) { let fetchedRelays = event.tags .filter(([name]) => name === 'r') .map(([, url, marker]) => ({ url, read: !marker || marker === 'read', write: !marker || marker === 'write', })) .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: { ...(c.relayMetadata ?? { relays: [], updatedAt: 0 }), relays: fetchedRelays, updatedAt: event.created_at, }, })); return true; } } else { return true; // Already have latest } } else { // No relay list — set defaults for new user if (config.relayMetadata.updatedAt === 0) { updateConfig(c => ({ ...c, relayMetadata: { ...(c.relayMetadata ?? { relays: [], updatedAt: 0 }), relays: NEW_USER_RELAYS, updatedAt: 0, }, })); } return true; } } catch (e) { console.warn('[NostrSync] fetchRelays failed:', e); } return false; }; // ── helper: fetch kind 10050 (NIP-17 DM relays) ──────────────────────── const fetchDmRelays = async (source: typeof nostr | ReturnType) => { try { const events = await source.query( [{ kinds: [10050], authors: [pubkey], limit: 1 }], { signal: AbortSignal.timeout(6000) }, ); if (events.length === 0) return false; const urls = events[0].tags .filter(([name]) => name === 'relay') .map(([, url]) => url) .filter((u): u is string => !!u); if (urls.length === 0) return false; updateConfig((c) => ({ ...c, relayMetadata: { ...(c.relayMetadata ?? { relays: [], updatedAt: 0 }), dmRelays: urls, }, })); return true; } catch (e) { console.warn('[NostrSync] fetchDmRelays failed:', e); return false; } }; // ── 1. Citrine (local relay — fastest, no internet needed) ─────────────── const citrineAvailable = await isCitrineAvailable(); if (citrineAvailable) { const g = nostr.group([CITRINE_URL]); const [r, p] = await Promise.all([fetchRelays(g), fetchProfile(g)]); relaysSynced = r; profileSynced = p; } // ── 2. Bootstrap relays (always-on aggregators) ────────────────────────── if (!profileSynced || !relaysSynced) { const g = nostr.group(BOOTSTRAP_RELAYS); const [r, p] = await Promise.all([ relaysSynced ? Promise.resolve(true) : fetchRelays(g), profileSynced ? Promise.resolve(true) : fetchProfile(g), ]); if (!relaysSynced) relaysSynced = r; if (!profileSynced) profileSynced = p; } // ── 3. Main relay pool ─────────────────────────────────────────────────── if (!profileSynced || !relaysSynced) { const [r, p] = await Promise.all([ relaysSynced ? Promise.resolve(true) : fetchRelays(nostr), profileSynced ? Promise.resolve(true) : fetchProfile(nostr), ]); if (!relaysSynced) relaysSynced = r; if (!profileSynced) profileSynced = p; } // DM relays (kind 10050) are optional — fetch without gating sync on them. void fetchDmRelays(nostr); const success = profileSynced && relaysSynced; if (success) { syncState.current.set(pubkey, true); } else { // Retry with backoff — max 5 attempts, up to ~30s total if (attempt < 5) { const delay = Math.min(3000 * attempt, 15000); console.warn(`[NostrSync] Incomplete — retrying in ${delay}ms (attempt ${attempt + 1})`); const t = setTimeout(() => doSync(pubkey, attempt + 1), delay); retryTimers.current.set(pubkey, t); } else { syncState.current.set(pubkey, false); // Don't show error toast if profile simply doesn't exist yet — // it's a new account, not a connection error if (!profileSynced) { // New account — no profile published yet, nothing to sync. } else { toast({ title: t('toastsUi.couldNotLoadProfile'), description: t('toastsUi.couldNotLoadProfileDesc'), variant: 'destructive', }); } } } } return null; }