diff --git a/src/components/NostrSync.tsx b/src/components/NostrSync.tsx index c13b461..1776e07 100644 --- a/src/components/NostrSync.tsx +++ b/src/components/NostrSync.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef } from 'react'; import { useNostr } from '@nostrify/react'; import { useNostrLogin } from '@nostrify/react/login'; import { useQueryClient } from '@tanstack/react-query'; @@ -7,11 +7,10 @@ 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 +// Citrine local relay (Android app by greenart7c3) const CITRINE_URL = 'ws://localhost:4869'; -// Default relays for new users +// 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 }, @@ -20,18 +19,14 @@ const NEW_USER_RELAYS = [ { 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. +// Always-on profile aggregators — try these unconditionally const BOOTSTRAP_RELAYS = [ 'wss://purplepag.es', 'wss://relay.nostr.band', 'wss://nos.lol', + 'wss://relay.damus.io', ]; -/** - * 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 { @@ -45,19 +40,6 @@ async function isCitrineAvailable(): Promise { }); } -/** - * 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(); @@ -65,66 +47,91 @@ export function NostrSync() { const { config, updateConfig } = useAppContext(); const { toast } = useToast(); const queryClient = useQueryClient(); - const syncedUsers = useRef>(new Set()); - const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle'); + + // Track per-pubkey: null = never tried, false = failed, true = succeeded + const syncState = useRef>(new Map()); + const retryTimers = useRef>>(new Map()); useEffect(() => { if (!user) return; - // Only sync once per user per session - if (syncedUsers.current.has(user.pubkey)) return; + const pubkey = user.pubkey; - 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; + // 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]); - const syncUserData = async () => { - setSyncStatus('syncing'); + async function doSync(pubkey: string, attempt = 1) { + console.log(`[NostrSync] Starting sync for ${pubkey.slice(0, 8)}... attempt ${attempt}`); - // 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] }); + // Always invalidate stale cache first so useAuthor refetches + queryClient.invalidateQueries({ queryKey: ['author', pubkey] }); + queryClient.removeQueries({ queryKey: ['author', pubkey] }); - // 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 profileSynced = false; + let relaysSynced = false; - let relaysSynced = false; - let profileSynced = false; - - // Sync relay list (NIP-65) - const syncRelays = async (relayGroup?: ReturnType): Promise => { - const source = relayGroup || nostr; + // ── helper: fetch kind 0 from a relay source ──────────────────────────── + const fetchProfile = async (source: typeof nostr | ReturnType) => { + try { const events = await source.query( - [{ kinds: [10002], authors: [user.pubkey], limit: 1 }], - { signal: AbortSignal.timeout(8000) } + [{ 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; + }; - // Only update if the event is newer than our stored data + // ── 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') @@ -133,15 +140,11 @@ export function NostrSync() { 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, - }, + console.log(`[NostrSync] Got ${fetchedRelays.length} relays from NIP-65`); + updateConfig(c => ({ + ...c, + relayMetadata: { relays: fetchedRelays, updatedAt: event.created_at }, })); return true; } @@ -149,150 +152,77 @@ export function NostrSync() { 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, - }, - })); + // 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; } - 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); - } + } catch (e) { + console.warn('[NostrSync] fetchRelays failed:', e); } + return false; + }; - // 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); - } - } + // ── 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; + } - // 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); + // ── 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; + } - if (mainResult) { - if (!relaysSynced) relaysSynced = mainResult.relays; - if (!profileSynced) profileSynced = mainResult.profile; - } - } + // ── 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; + } - // Mark sync complete for this user - syncedUsers.current.add(user.pubkey); + const success = profileSynced && relaysSynced; + console.log(`[NostrSync] Done — profile:${profileSynced} relays:${relaysSynced}`); - if (relaysSynced || profileSynced) { - setSyncStatus('success'); + 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 { - setSyncStatus('error'); - console.error('[NostrSync] Sync failed - could not reach relays'); + syncState.current.set(pubkey, false); + console.error('[NostrSync] All attempts failed for', pubkey.slice(0, 8)); toast({ - title: 'Connection Issue', - description: 'Could not sync your profile. Check your internet connection.', + title: 'Could not load profile', + description: 'Check your internet connection and try refreshing.', 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; } diff --git a/src/hooks/useAuthor.ts b/src/hooks/useAuthor.ts index a252dc5..a65ad49 100644 --- a/src/hooks/useAuthor.ts +++ b/src/hooks/useAuthor.ts @@ -2,9 +2,11 @@ import { type NostrEvent, type NostrMetadata, NSchema as n } from '@nostrify/nos import { useNostr } from '@nostrify/react'; import { useQuery } from '@tanstack/react-query'; -// Fallback relays for profile aggregation +// Fallback relays for profile aggregation — global indexers const FALLBACK_RELAYS = [ 'wss://purplepag.es', + 'wss://relay.nostr.band', + 'wss://nos.lol', 'wss://relay.damus.io', ]; @@ -57,8 +59,8 @@ export function useAuthor(pubkey: string | undefined) { return {}; } }, - staleTime: 5 * 60 * 1000, // Keep cached data fresh for 5 minutes - retry: 2, - retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000), // Exponential backoff + staleTime: 60 * 1000, // 1 minute — allows NostrSync to invalidate and refetch quickly + retry: 3, + retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 8000), }); }