import { useEffect, useRef } from 'react'; import { useNostr } from '@nostrify/react'; import { useNostrLogin } from '@nostrify/react/login'; 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'; // 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://wotr.relatr.xyz', read: true, write: true }, { url: 'wss://relay.ditto.pub', read: true, write: true }, { url: 'wss://goskatespots.nostr1.com', 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 { nostr } = useNostr(); const { user } = useCurrentUser(); const { logins } = useNostrLogin(); const { config, updateConfig } = useAppContext(); const { toast } = useToast(); const queryClient = useQueryClient(); // 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]); async function doSync(pubkey: string, attempt = 1) { console.log(`[NostrSync] Starting sync for ${pubkey.slice(0, 8)}... attempt ${attempt}`); // 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); console.log(`[NostrSync] Got profile: ${metadata.name ?? 'unnamed'}`); // 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) { const fetchedRelays = event.tags .filter(([name]) => name === 'r') .map(([, url, marker]) => ({ url, read: !marker || marker === 'read', write: !marker || marker === 'write', })); if (fetchedRelays.length > 0) { console.log(`[NostrSync] Got ${fetchedRelays.length} relays from NIP-65`); updateConfig(c => ({ ...c, relayMetadata: { 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: { relays: NEW_USER_RELAYS, updatedAt: 0 }, })); } return true; } } catch (e) { console.warn('[NostrSync] fetchRelays failed:', e); } return false; }; // ── 1. Citrine (local relay — fastest, no internet needed) ─────────────── const citrineAvailable = await isCitrineAvailable(); if (citrineAvailable) { console.log('[NostrSync] Citrine available — querying local relay'); 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) { console.log('[NostrSync] Trying bootstrap relays...'); 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) { console.log('[NostrSync] Trying main relay pool...'); 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; } const success = profileSynced && relaysSynced; console.log(`[NostrSync] Done — profile:${profileSynced} relays:${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); console.error('[NostrSync] All attempts failed for', pubkey.slice(0, 8)); // Don't show error toast if profile simply doesn't exist yet — // it's a new account, not a connection error if (!profileSynced) { console.log('[NostrSync] No profile found — may be a new account'); } else { toast({ title: 'Could not load profile', description: 'Check your internet connection and try refreshing.', variant: 'destructive', }); } } } } return null; }