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'; // Default relays for new users const NEW_USER_RELAYS = [ { url: 'wss://relay.ditto.pub', read: true, write: true }, { url: 'wss://goskatespots.nostr1.com', read: true, write: true }, { url: 'wss://wotr.relatr.xyz', read: true, write: true }, { url: 'wss://purplepag.es', read: true, write: false }, // Profile aggregator ]; // Fallback relays for fetching profile data (aggregators that index many profiles) const FALLBACK_RELAYS = [ 'wss://purplepag.es', 'wss://relay.nostr.band', 'wss://relay.damus.io', 'wss://nos.lol', ]; /** * 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'); console.log('[NostrSync] Starting sync for user:', user.pubkey.slice(0, 8) + '...'); // FIX B: Invalidate any stale cached data for this user BEFORE fetching // This ensures we don't use old cached empty results queryClient.invalidateQueries({ queryKey: ['author', user.pubkey] }); // Also invalidate the logins cache so useLoggedInAccounts refetches const loginsKey = logins.map((l) => l.id).join(';'); queryClient.invalidateQueries({ queryKey: ['nostr', 'logins', loginsKey] }); let relaysSynced = false; let profileSynced = false; // Sync relay list (NIP-65) const syncRelays = async (): Promise => { const events = await nostr.query( [{ kinds: [10002], authors: [user.pubkey], limit: 1 }], { signal: AbortSignal.timeout(10000) } ); 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; console.log('[NostrSync] Querying profile for pubkey:', user.pubkey.slice(0, 8) + '...'); const events = await querySource.query( [{ kinds: [0], authors: [user.pubkey], limit: 1 }], { signal: AbortSignal.timeout(10000) } ); console.log('[NostrSync] Profile query returned', events.length, 'events'); 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; }); }); console.log('[NostrSync] Profile synced and cached:', metadata.name || 'unnamed'); 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; }; // Try main relays first with retry const mainResult = await syncWithRetry(async () => { const [relayResult, profileResult] = await Promise.all([ syncRelays(), syncProfile() ]); return { relays: relayResult, profile: profileResult }; }, 3, 1500); if (mainResult) { relaysSynced = mainResult.relays; profileSynced = mainResult.profile; } // If profile not found on main relays, try fallback relays if (!profileSynced) { console.log('[NostrSync] Profile not found on main relays, trying fallbacks...'); const fallbackResult = await syncWithRetry(async () => { const fallbackGroup = nostr.group(FALLBACK_RELAYS); return await syncProfile(fallbackGroup); }, 3, 2000); if (fallbackResult) { profileSynced = true; } } // Mark sync complete for this user syncedUsers.current.add(user.pubkey); if (relaysSynced || profileSynced) { setSyncStatus('success'); console.log('[NostrSync] Sync completed - relays:', relaysSynced, 'profile:', profileSynced); } 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', }); } }; // FIX C: Longer initial delay to allow relay connections to establish // This helps with external signers like Amber where PWA context may need time // to re-establish WebSocket connections after returning from the signer app const timeoutId = setTimeout(() => { syncUserData(); }, 800); return () => clearTimeout(timeoutId); }, [user, config.relayMetadata.updatedAt, nostr, updateConfig, toast, queryClient, logins]); return null; }