import { useEffect, useRef, useState } 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 (runs on Android device via the Citrine app) // https://github.com/greenart7c3/Citrine const CITRINE_URL = 'ws://localhost:4869'; // Default relays for new users 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 }, ]; // Bootstrap relays — always checked for profile even if user has their own relay list. // purplepag.es indexes kind 0 + kind 10002 for most pubkeys worldwide. const BOOTSTRAP_RELAYS = [ 'wss://purplepag.es', 'wss://relay.nostr.band', 'wss://nos.lol', ]; /** * Try to connect to Citrine local relay. * Returns true if Citrine is reachable within 1.5 seconds. */ 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); } }); } /** * NostrSync - Syncs user's Nostr data on login * * This component runs globally to sync various Nostr data when the user logs in. * It fetches data and populates TanStack Query cache so other hooks don't need to refetch. * * Currently syncs: * - NIP-65 relay list (kind 10002) * - Profile metadata (kind 0) - cached for useAuthor hook * * New users (without a published relay list) get default relays instead of * inheriting from previous browser sessions. */ export function NostrSync() { const { nostr } = useNostr(); const { user } = useCurrentUser(); const { logins } = useNostrLogin(); const { config, updateConfig } = useAppContext(); const { toast } = useToast(); const queryClient = useQueryClient(); const syncedUsers = useRef>(new Set()); const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle'); useEffect(() => { if (!user) return; // Only sync once per user per session if (syncedUsers.current.has(user.pubkey)) return; const syncWithRetry = async ( queryFn: () => Promise, maxRetries: number = 5, initialDelayMs: number = 1000 ): Promise => { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { return await queryFn(); } catch (error) { console.warn(`[NostrSync] Attempt ${attempt}/${maxRetries} failed:`, error); if (attempt < maxRetries) { // Exponential backoff: 1s, 2s, 4s, 8s, 16s const delay = initialDelayMs * Math.pow(2, attempt - 1); await new Promise(resolve => setTimeout(resolve, delay)); } } } return null; }; const syncUserData = async () => { setSyncStatus('syncing'); // Invalidate stale cache before fetching fresh data queryClient.invalidateQueries({ queryKey: ['author', user.pubkey] }); const loginsKey = logins.map((l) => l.id).join(';'); queryClient.invalidateQueries({ queryKey: ['nostr', 'logins', loginsKey] }); // Check if Citrine local relay is running — if so, use it first. // Citrine stores all the user's events locally, so it's the fastest // and most reliable source for profile + relay list on Android. const citrineAvailable = await isCitrineAvailable(); if (citrineAvailable) { console.log('[NostrSync] Citrine detected — using local relay first'); } let relaysSynced = false; let profileSynced = false; // Sync relay list (NIP-65) const syncRelays = async (relayGroup?: ReturnType): Promise => { const source = relayGroup || nostr; const events = await source.query( [{ kinds: [10002], authors: [user.pubkey], limit: 1 }], { signal: AbortSignal.timeout(8000) } ); if (events.length > 0) { const event = events[0]; // Only update if the event is newer than our stored data 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] Synced relay list from NIP-65:', fetchedRelays.length, 'relays'); updateConfig((current) => ({ ...current, relayMetadata: { relays: fetchedRelays, updatedAt: event.created_at, }, })); return true; } } else { return true; // Already have latest } } else { // No NIP-65 relay list found - this is a new user! console.log('[NostrSync] New user - setting default relays'); updateConfig((current) => ({ ...current, relayMetadata: { relays: NEW_USER_RELAYS, updatedAt: 0, }, })); return true; } return false; }; // Sync profile (kind 0) and populate useAuthor cache const syncProfile = async (relayGroup?: ReturnType): Promise => { const querySource = relayGroup || nostr; const events = await querySource.query( [{ kinds: [0], authors: [user.pubkey], limit: 1 }], { signal: AbortSignal.timeout(10000) } ); if (events.length > 0) { const event = events[0]; // FIX A: Parse metadata and populate useAuthor's TanStack Query cache try { const metadata = n.json().pipe(n.metadata()).parse(event.content); // Set the cache data that useAuthor expects queryClient.setQueryData(['author', user.pubkey], { metadata, event }); // FIX A+: Also update useLoggedInAccounts cache so profile shows everywhere const loginsKey = logins.map((l) => l.id).join(';'); queryClient.setQueryData(['nostr', 'logins', loginsKey], (oldData: Array<{ id: string; pubkey: string; metadata: typeof metadata; event?: typeof event }> | undefined) => { if (!oldData) { // Create initial data from logins return logins.map((login) => { if (login.pubkey === user.pubkey) { return { id: login.id, pubkey: login.pubkey, metadata, event }; } return { id: login.id, pubkey: login.pubkey, metadata: {} }; }); } // Update existing data return oldData.map((account) => { if (account.pubkey === user.pubkey) { return { ...account, metadata, event }; } return account; }); }); return true; } catch (parseError) { // Event exists but content couldn't be parsed - still cache the event queryClient.setQueryData(['author', user.pubkey], { event }); console.warn('[NostrSync] Profile event found but metadata parse failed'); return true; } } return false; }; // 1. Try Citrine first if available — instant local data if (citrineAvailable) { try { const citrineGroup = nostr.group([CITRINE_URL]); const [relayResult, profileResult] = await Promise.all([ syncRelays(citrineGroup), syncProfile(citrineGroup), ]); relaysSynced = relayResult; profileSynced = profileResult; console.log('[NostrSync] Citrine sync:', { relaysSynced, profileSynced }); } catch (e) { console.warn('[NostrSync] Citrine sync failed:', e); } } // 2. Try bootstrap relays (purplepag.es etc.) — always fast for profile/relays if (!profileSynced || !relaysSynced) { try { const bootstrapGroup = nostr.group(BOOTSTRAP_RELAYS); const [relayResult, profileResult] = await Promise.all([ relaysSynced ? Promise.resolve(true) : syncRelays(bootstrapGroup), profileSynced ? Promise.resolve(true) : syncProfile(bootstrapGroup), ]); if (!relaysSynced) relaysSynced = relayResult; if (!profileSynced) profileSynced = profileResult; console.log('[NostrSync] Bootstrap sync:', { relaysSynced, profileSynced }); } catch (e) { console.warn('[NostrSync] Bootstrap sync failed:', e); } } // 3. Try main configured relays with retry if (!profileSynced || !relaysSynced) { const mainResult = await syncWithRetry(async () => { const [relayResult, profileResult] = await Promise.all([ relaysSynced ? Promise.resolve(true) : syncRelays(), profileSynced ? Promise.resolve(true) : syncProfile(), ]); return { relays: relayResult, profile: profileResult }; }, 3, 1500); if (mainResult) { if (!relaysSynced) relaysSynced = mainResult.relays; if (!profileSynced) profileSynced = mainResult.profile; } } // Mark sync complete for this user syncedUsers.current.add(user.pubkey); if (relaysSynced || profileSynced) { setSyncStatus('success'); } else { setSyncStatus('error'); console.error('[NostrSync] Sync failed - could not reach relays'); toast({ title: 'Connection Issue', description: 'Could not sync your profile. Check your internet connection.', variant: 'destructive', }); } }; // Give relay WebSocket connections time to establish, especially important // for PWA/Amber where the app context is fresh after returning from another app. // Also gives Citrine time to be detected. const isPWA = window.matchMedia('(display-mode: standalone)').matches; const delay = isPWA ? 1500 : 800; const timeoutId = setTimeout(() => { syncUserData(); }, delay); return () => clearTimeout(timeoutId); }, [user, config.relayMetadata.updatedAt, nostr, updateConfig, toast, queryClient, logins]); return null; }