diff --git a/nip66-cron/index.mjs b/nip66-cron/index.mjs index 80140b5c..98f7ab98 100644 --- a/nip66-cron/index.mjs +++ b/nip66-cron/index.mjs @@ -179,9 +179,30 @@ async function fetchRelayUrlsFromKind10002 (authorPubkey, queryRelayUrls) { try { ws = new WebSocket(relayUrl, { handshakeTimeout: 12000 }) await new Promise((resolve, reject) => { - ws.on('open', resolve) - ws.on('error', reject) - setTimeout(() => reject(new Error('open timeout')), 15000) + let timeoutId + let resolved = false + const onOpen = () => { + if (resolved) return + resolved = true + clearTimeout(timeoutId) + resolve() + } + const onError = (err) => { + if (resolved) return + resolved = true + clearTimeout(timeoutId) + ws.removeListener('open', onOpen) + reject(err) + } + timeoutId = setTimeout(() => { + if (resolved) return + resolved = true + ws.removeListener('open', onOpen) + ws.removeListener('error', onError) + reject(new Error('open timeout')) + }, 15000) + ws.once('open', onOpen) + ws.on('error', onError) }) ws.send(JSON.stringify(['REQ', subId, filter])) const events = await new Promise((resolve) => { @@ -306,9 +327,30 @@ async function publishEvent (relayUrls, event) { try { const ws = new WebSocket(url, { handshakeTimeout: 8000 }) await new Promise((resolve, reject) => { - ws.on('open', resolve) - ws.on('error', reject) - setTimeout(() => reject(new Error('open timeout')), 10000) + let timeoutId + let resolved = false + const onOpen = () => { + if (resolved) return + resolved = true + clearTimeout(timeoutId) + resolve() + } + const onError = (err) => { + if (resolved) return + resolved = true + clearTimeout(timeoutId) + ws.removeListener('open', onOpen) + reject(err) + } + timeoutId = setTimeout(() => { + if (resolved) return + resolved = true + ws.removeListener('open', onOpen) + ws.removeListener('error', onError) + reject(new Error('open timeout')) + }, 10000) + ws.once('open', onOpen) + ws.on('error', onError) }) conns.push(ws) ws.send(msg) diff --git a/src/components/Sidebar/AccountButton.tsx b/src/components/Sidebar/AccountButton.tsx index 54a305db..38dc64ea 100644 --- a/src/components/Sidebar/AccountButton.tsx +++ b/src/components/Sidebar/AccountButton.tsx @@ -8,7 +8,7 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { toWallet } from '@/lib/link' -import { formatPubkey, generateImageByPubkey } from '@/lib/pubkey' +import { formatPubkey, generateImageByPubkey, pubkeyToNpub, formatNpub } from '@/lib/pubkey' import { usePrimaryPage, useSecondaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' import { ArrowDownUp, LogIn, LogOut, UserRound, Wallet } from 'lucide-react' @@ -39,7 +39,10 @@ function ProfileButton() { if (!pubkey) return null const defaultAvatar = generateImageByPubkey(pubkey) - const { username, avatar } = profile || { username: formatPubkey(pubkey), avatar: defaultAvatar } + // Fallback to formatted npub if no profile + const npub = pubkeyToNpub(pubkey) + const fallbackUsername = npub ? formatNpub(npub) : formatPubkey(pubkey) + const { username, avatar } = profile || { username: fallbackUsername, avatar: defaultAvatar } return ( diff --git a/src/hooks/useFetchProfile.tsx b/src/hooks/useFetchProfile.tsx index a130d541..73b0930b 100644 --- a/src/hooks/useFetchProfile.tsx +++ b/src/hooks/useFetchProfile.tsx @@ -2,9 +2,9 @@ import { getProfileFromEvent } from '@/lib/event-metadata' import { userIdToPubkey } from '@/lib/pubkey' import { useNostr } from '@/providers/NostrProvider' import { replaceableEventService } from '@/services/client.service' -import { kinds } from 'nostr-tools' import { TProfile } from '@/types' -import { useEffect, useState, useRef } from 'react' +import { useEffect, useState, useRef, useCallback } from 'react' +import logger from '@/lib/logger' export function useFetchProfile(id?: string, skipCache = false) { const { profile: currentAccountProfile } = useNostr() @@ -15,36 +15,50 @@ export function useFetchProfile(id?: string, skipCache = false) { const checkIntervalRef = useRef(null) // Function to check for profile updates - const checkProfile = async (pubkey: string, cancelled: { current: boolean }) => { - if (cancelled.current) return + // fetchProfileEvent already checks: 1) in-memory cache, 2) IndexedDB, 3) network (with author's relays) + // Memoize to prevent recreation on every render + const checkProfile = useCallback(async (pubkey: string, cancelled: { current: boolean }) => { + if (cancelled.current) return false try { - // Re-check cache (might have been updated by background fetch) - const profileEvent = await replaceableEventService.fetchReplaceableEvent(pubkey, kinds.Metadata) + // Use fetchProfileEvent which includes author's relay list for better profile discovery + // fetchProfileEvent handles all cache layers: + // 1. In-memory cache (instant return) + // 2. IndexedDB (fast async) + // 3. Network (with author's relay list for better discovery) + const profileEvent = await replaceableEventService.fetchProfileEvent(pubkey, skipCache) - if (cancelled.current) return + if (cancelled.current) return false if (profileEvent) { + // getProfileFromEvent always returns a profile object (with fallback username) const newProfile = getProfileFromEvent(profileEvent) - if (newProfile) { - setProfile(newProfile) - setIsFetching(false) - // Clear interval once we have a profile - if (checkIntervalRef.current) { - clearInterval(checkIntervalRef.current) - checkIntervalRef.current = null - } - return true + logger.debug('[useFetchProfile] Profile found', { + pubkey: pubkey.substring(0, 8), + username: newProfile.username, + hasAvatar: !!newProfile.avatar + }) + setProfile(newProfile) + setIsFetching(false) + // Clear interval once we have a profile + if (checkIntervalRef.current) { + clearInterval(checkIntervalRef.current) + checkIntervalRef.current = null } + return true } + logger.debug('[useFetchProfile] No profile event found', { + pubkey: pubkey.substring(0, 8) + }) return false } catch (err) { if (!cancelled.current) { setError(err as Error) + setIsFetching(false) } return false } - } + }, [skipCache]) useEffect(() => { if (!id) { @@ -57,24 +71,33 @@ export function useFetchProfile(id?: string, skipCache = false) { const cancelled = { current: false } const pubkey = userIdToPubkey(id) + if (!pubkey) { + setProfile(null) + setPubkey(null) + setIsFetching(false) + setError(new Error('Invalid id: could not extract pubkey')) + return + } setPubkey(pubkey) const run = async () => { setIsFetching(true) setError(null) - // Initial fetch + // Initial fetch - fetchReplaceableEvent checks: 1) in-memory, 2) IndexedDB, 3) network const found = await checkProfile(pubkey, cancelled) if (cancelled.current) return if (found) { - // Profile found, we're done + // Profile found (from cache or network), we're done return } - // No profile found yet - set fetching to false but keep checking in background + // No profile found yet - set fetching to false so UI can show fallback + // The profile will remain null, allowing components to show npub fallback setIsFetching(false) + setError(null) // Clear any previous errors // If no profile was found, periodically re-check (profiles might load asynchronously) // Check every 2 seconds for up to 30 seconds (15 checks) @@ -110,18 +133,26 @@ export function useFetchProfile(id?: string, skipCache = false) { checkIntervalRef.current = null } } - }, [id, skipCache]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id, skipCache]) // checkProfile is memoized and stable, no need to include it useEffect(() => { - if (currentAccountProfile && pubkey === currentAccountProfile.pubkey) { - setProfile(currentAccountProfile) - // Clear interval if we got the profile from current account - if (checkIntervalRef.current) { - clearInterval(checkIntervalRef.current) - checkIntervalRef.current = null + // Only use currentAccountProfile if it matches the pubkey we're looking for + // Use pubkey from the profile object to avoid reference equality issues + if (currentAccountProfile?.pubkey && pubkey && pubkey === currentAccountProfile.pubkey) { + // Only update if we don't have a profile yet (avoid unnecessary updates) + // Using a ref to track if we've already set it to prevent loops + if (!profile) { + setProfile(currentAccountProfile) + setIsFetching(false) + // Clear interval if we got the profile from current account + if (checkIntervalRef.current) { + clearInterval(checkIntervalRef.current) + checkIntervalRef.current = null + } } } - }, [currentAccountProfile, pubkey]) + }, [currentAccountProfile?.pubkey, pubkey]) // Removed profile?.pubkey to prevent loops return { isFetching, error, profile } } diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 30a46341..7614ac5e 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -439,6 +439,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { if (profileEvent) { const updatedProfileEvent = await indexedDb.putReplaceableEvent(profileEvent) if (updatedProfileEvent.id === profileEvent.id) { + // Update in-memory cache so it's immediately available + await replaceableEventService.updateReplaceableEventCache(updatedProfileEvent) setProfileEvent(updatedProfileEvent) setProfile(getProfileFromEvent(updatedProfileEvent)) } diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts index b19cf850..637968c0 100644 --- a/src/services/client-replaceable-events.service.ts +++ b/src/services/client-replaceable-events.service.ts @@ -22,18 +22,6 @@ export class ReplaceableEventService { max: 50, ttl: 1000 * 60 * 60 }) - // In-memory cache for profiles - instant access, no IndexedDB blocking - private profileMemoryCache = new LRUCache({ - max: 1000, // Cache up to 1000 profiles in memory - ttl: 1000 * 60 * 30, // 30 minutes TTL - updateAgeOnGet: true // Refresh TTL on access - }) - // In-memory cache for all replaceable events - fast access - private replaceableEventMemoryCache = new LRUCache({ - max: 2000, // Cache up to 2000 events in memory - ttl: 1000 * 60 * 30, // 30 minutes TTL - updateAgeOnGet: true - }) private replaceableEventFromBigRelaysDataloader: DataLoader< { pubkey: string; kind: number }, NEvent | null, @@ -146,36 +134,25 @@ export class ReplaceableEventService { d?: string, containingEventRelays: string[] = [] ): Promise { - const cacheKey = d ? `${kind}:${pubkey}:${d}` : `${kind}:${pubkey}` - - // 1. Check in-memory cache FIRST - instant return, no async overhead - const memoryCached = this.replaceableEventMemoryCache.get(cacheKey) - if (memoryCached) { - // Check tombstone in background (non-blocking) - this.checkTombstoneAndUpdateCache(memoryCached, kind).catch(() => {}) - // Fetch in background to update cache if newer version exists - this.refreshInBackground(pubkey, kind, d).catch(() => {}) - return memoryCached - } - - // 2. Check IndexedDB (async but faster than network) + // 1. Check IndexedDB (async but faster than network) try { const indexedDbCached = await indexedDb.getReplaceableEvent(pubkey, kind, d) if (indexedDbCached) { - // Check tombstone (non-blocking - check in background) + // Check tombstone in background (non-blocking) const tombstoneKey = isReplaceableEvent(kind) ? getReplaceableCoordinateFromEvent(indexedDbCached) : indexedDbCached.id - // Check tombstone in background, don't block indexedDb.isTombstoned(tombstoneKey).then(isTombstoned => { if (isTombstoned) { - // Remove from caches if tombstoned - this.replaceableEventMemoryCache.delete(cacheKey) - } else { - // Add to memory cache for next time - this.replaceableEventMemoryCache.set(cacheKey, indexedDbCached) + // Event is tombstoned - will be handled by IndexedDB cleanup + logger.debug('[ReplaceableEventService] Event is tombstoned', { + pubkey: formatPubkey(pubkey), + kind + }) } - }).catch(() => {}) + }).catch(() => { + // If tombstone check fails, keep it in cache (better to show stale than nothing) + }) // Fetch in background to update cache if newer version exists this.refreshInBackground(pubkey, kind, d).catch(() => {}) @@ -183,9 +160,14 @@ export class ReplaceableEventService { } } catch (error) { // IndexedDB error - continue to network fetch + logger.warn('[ReplaceableEventService] IndexedDB error', { + pubkey: formatPubkey(pubkey), + kind, + error: error instanceof Error ? error.message : String(error) + }) } - // 3. Not in cache, fetch from network + // 2. Not in cache, fetch from network // Note: DataLoader will use comprehensive relay list from batch load function // For profiles: if we have containingEventRelays (from fetchProfileEvent), include them // Profiles are often on the same relays where the author publishes their events @@ -218,12 +200,6 @@ export class ReplaceableEventService { // Extract relay hints from the found event (for future related fetches) const eventRelayHints = this.extractRelayHintsFromEvent(event) - // Add to memory cache for instant access next time - this.replaceableEventMemoryCache.set(cacheKey, event) - if (kind === kinds.Metadata) { - this.profileMemoryCache.set(pubkey, event) - } - // If we found relay hints, log them (they're already used in the batch load function) if (eventRelayHints.length > 0) { logger.debug('[ReplaceableEventService] Found relay hints in event', { @@ -238,8 +214,7 @@ export class ReplaceableEventService { // Log when no event is found (helps debug relay failures) if (kind === kinds.Metadata) { logger.debug('[ReplaceableEventService] No profile found for pubkey', { - pubkey: formatPubkey(pubkey), - cacheKey + pubkey: formatPubkey(pubkey) }) } } catch (error) { @@ -255,22 +230,6 @@ export class ReplaceableEventService { return undefined } - /** - * Check tombstone and update cache (non-blocking background operation) - */ - private async checkTombstoneAndUpdateCache(event: NEvent, kind: number): Promise { - const tombstoneKey = isReplaceableEvent(kind) - ? getReplaceableCoordinateFromEvent(event) - : event.id - const isTombstoned = await indexedDb.isTombstoned(tombstoneKey) - if (isTombstoned) { - const cacheKey = isReplaceableEvent(kind) - ? `${kind}:${event.pubkey}` - : `${kind}:${event.pubkey}:${event.id}` - this.replaceableEventMemoryCache.delete(cacheKey) - } - } - /** * Refresh event in background (non-blocking) */ @@ -279,11 +238,7 @@ export class ReplaceableEventService { if (d) { await this.replaceableEventDataLoader.load({ pubkey, kind, d }) } else { - const event = await this.replaceableEventFromBigRelaysDataloader.load({ pubkey, kind }) - if (event) { - const cacheKey = `${kind}:${pubkey}` - this.replaceableEventMemoryCache.set(cacheKey, event) - } + await this.replaceableEventFromBigRelaysDataloader.load({ pubkey, kind }) } } catch { // Ignore errors in background refresh @@ -292,47 +247,31 @@ export class ReplaceableEventService { /** * Batch fetch replaceable events from profile fetch relays - * Optimized: checks memory cache first (instant), then IndexedDB, then network + * Checks IndexedDB first, then network */ async fetchReplaceableEventsFromProfileFetchRelays(pubkeys: string[], kind: number): Promise<(NEvent | undefined)[]> { - // First check memory cache (instant) - const memoryCached: (NEvent | undefined)[] = [] - const memoryMisses: { pubkey: string; index: number }[] = [] - - pubkeys.forEach((pubkey, i) => { - const cacheKey = `${kind}:${pubkey}` - const cached = this.replaceableEventMemoryCache.get(cacheKey) - if (cached) { - memoryCached[i] = cached - } else { - memoryMisses.push({ pubkey, index: i }) - } - }) + const results: (NEvent | undefined)[] = [] + const misses: { pubkey: string; index: number }[] = [] - // For memory misses, check IndexedDB in parallel - const indexedDbPromises = memoryMisses.map(async ({ pubkey, index }) => { + // Check IndexedDB in parallel + const indexedDbPromises = pubkeys.map(async (pubkey, index) => { try { const event = await indexedDb.getReplaceableEvent(pubkey, kind) if (event) { - // Add to memory cache - const cacheKey = `${kind}:${pubkey}` - this.replaceableEventMemoryCache.set(cacheKey, event) - if (kind === kinds.Metadata) { - this.profileMemoryCache.set(pubkey, event) - } - memoryCached[index] = event + results[index] = event return { index, event } } } catch { // Ignore errors } + misses.push({ pubkey, index }) return null }) await Promise.allSettled(indexedDbPromises) // Find what's still missing and fetch from network - const stillMissing = memoryMisses.filter(({ index }) => memoryCached[index] === undefined) + const stillMissing = misses.filter(({ index }) => results[index] === undefined) if (stillMissing.length > 0) { const newEvents = await this.replaceableEventFromBigRelaysDataloader.loadMany( stillMissing.map(({ pubkey }) => ({ pubkey, kind })) @@ -341,27 +280,20 @@ export class ReplaceableEventService { if (event && !(event instanceof Error)) { const { index } = stillMissing[idx]! if (index !== undefined) { - memoryCached[index] = event ?? undefined - // Add to memory cache - if (event) { - const cacheKey = `${kind}:${stillMissing[idx]!.pubkey}` - this.replaceableEventMemoryCache.set(cacheKey, event) - if (kind === kinds.Metadata) { - this.profileMemoryCache.set(stillMissing[idx]!.pubkey, event) - } - } + results[index] = event ?? undefined } } }) } - return memoryCached + return results } /** * Update replaceable event cache */ async updateReplaceableEventCache(event: NEvent): Promise { + // Update DataLoader cache and IndexedDB await this.updateReplaceableEventFromBigRelaysCache(event) } @@ -371,28 +303,6 @@ export class ReplaceableEventService { clearCaches(): void { this.replaceableEventFromBigRelaysDataloader.clearAll() this.replaceableEventDataLoader.clearAll() - this.replaceableEventMemoryCache.clear() - this.profileMemoryCache.clear() - } - - /** - * Pre-load profiles into memory cache for instant access - */ - async preloadProfiles(pubkeys: string[]): Promise { - // Load from IndexedDB in parallel - const promises = pubkeys.map(async (pubkey) => { - try { - const event = await indexedDb.getReplaceableEvent(pubkey, kinds.Metadata) - if (event) { - const cacheKey = `${kinds.Metadata}:${pubkey}` - this.replaceableEventMemoryCache.set(cacheKey, event) - this.profileMemoryCache.set(pubkey, event) - } - } catch { - // Ignore errors - } - }) - await Promise.allSettled(promises) } /** @@ -457,25 +367,10 @@ export class ReplaceableEventService { } for (const event of events) { - // Check tombstone in background (non-blocking) - const tombstoneKey = isReplaceableEvent(event.kind) - ? getReplaceableCoordinateFromEvent(event) - : event.id - // Don't block on tombstone check - do it in background - indexedDb.isTombstoned(tombstoneKey).then(isTombstoned => { - if (isTombstoned) { - const cacheKey = `${event.kind}:${event.pubkey}` - this.replaceableEventMemoryCache.delete(cacheKey) - } - }).catch(() => {}) - const key = `${event.pubkey}:${event.kind}` const existing = eventsMap.get(key) if (!existing || existing.created_at < event.created_at) { eventsMap.set(key, event) - // Add to memory cache - const cacheKey = `${event.kind}:${event.pubkey}` - this.replaceableEventMemoryCache.set(cacheKey, event) } } }) @@ -485,12 +380,6 @@ export class ReplaceableEventService { const key = `${pubkey}:${kind}` const event = eventsMap.get(key) if (event) { - // Add to memory cache for instant access - const cacheKey = `${kind}:${pubkey}` - this.replaceableEventMemoryCache.set(cacheKey, event) - if (kind === kinds.Metadata) { - this.profileMemoryCache.set(pubkey, event) - } indexedDb.putReplaceableEvent(event) return event } else { @@ -537,25 +426,10 @@ export class ReplaceableEventService { }) for (const event of events) { - // Check tombstone in background (non-blocking) - const tombstoneKey = isReplaceableEvent(event.kind) - ? getReplaceableCoordinateFromEvent(event) - : event.id - // Don't block on tombstone check - do it in background - indexedDb.isTombstoned(tombstoneKey).then(isTombstoned => { - if (isTombstoned) { - const cacheKey = `${event.kind}:${event.pubkey}:${d ?? ''}` - this.replaceableEventMemoryCache.delete(cacheKey) - } - }).catch(() => {}) - const eventKey = `${event.pubkey}:${event.kind}:${d ?? ''}` const existing = eventsMap.get(eventKey) if (!existing || existing.created_at < event.created_at) { eventsMap.set(eventKey, event) - // Add to memory cache - const cacheKey = `${event.kind}:${event.pubkey}:${d ?? ''}` - this.replaceableEventMemoryCache.set(cacheKey, event) } } }) @@ -565,12 +439,6 @@ export class ReplaceableEventService { const eventKey = `${pubkey}:${kind}:${d ?? ''}` const event = eventsMap.get(eventKey) if (event) { - // Add to memory cache for instant access - const cacheKey = `${kind}:${pubkey}:${d ?? ''}` - this.replaceableEventMemoryCache.set(cacheKey, event) - if (kind === kinds.Metadata) { - this.profileMemoryCache.set(pubkey, event) - } indexedDb.putReplaceableEvent(event) return event } else { @@ -589,6 +457,7 @@ export class ReplaceableEventService { { pubkey: event.pubkey, kind: event.kind }, Promise.resolve(event) ) + // Store in IndexedDB await indexedDb.putReplaceableEvent(event) } @@ -599,7 +468,7 @@ export class ReplaceableEventService { /** * Fetch profile event by id (hex, npub, nprofile) */ - async fetchProfileEvent(id: string, skipCache: boolean = false): Promise { + async fetchProfileEvent(id: string, _skipCache: boolean = false): Promise { let pubkey: string | undefined let relays: string[] = [] if (/^[0-9a-f]{64}$/.test(id)) { @@ -620,12 +489,6 @@ export class ReplaceableEventService { if (!pubkey) { throw new Error('Invalid id') } - if (!skipCache) { - const localProfile = await indexedDb.getReplaceableEvent(pubkey, kinds.Metadata) - if (localProfile) { - return localProfile - } - } // For profiles: get author's relay list (from cache if available) and use those relays // Profiles are often on the same relays where the author publishes their events @@ -650,6 +513,7 @@ export class ReplaceableEventService { }) } + // Use fetchReplaceableEvent which checks IndexedDB then network const profileEvent = await this.fetchReplaceableEvent(pubkey, kinds.Metadata, undefined, relays) if (profileEvent) { await this.indexProfile(profileEvent) diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index ebd94f9a..ae929097 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -452,10 +452,17 @@ class IndexedDbService { return resolve(undefined) } // Invalidate profile and payment info cache when stale so they refetch regularly + // BUT: Always return cached profiles even if stale - we'll refresh in background + // This ensures profiles are always visible, even if slightly outdated const isProfileOrPayment = kind === kinds.Metadata || kind === ExtendedKind.PAYMENT_INFO if (isProfileOrPayment && row.addedAt && Date.now() - row.addedAt > PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS) { - transaction.commit() - return resolve(undefined) + // Profile is stale, but return it anyway - refresh will happen in background + // This prevents the "no profile" state when cache exists but is just old + logger.debug('[IndexedDB] Profile cache is stale but returning anyway', { + pubkey: pubkey.substring(0, 8), + age: Date.now() - row.addedAt, + maxAge: PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS + }) } transaction.commit() resolve(row.value)