diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 026a34c9..10889ffc 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -944,7 +944,7 @@ const NoteList = forwardRef( pendingPubkeys: feedProfileBatch.pending, version: feedProfileBatch.version }), - [feedProfileBatch] + [feedProfileBatch.profiles, feedProfileBatch.pending, feedProfileBatch.version] ) // Memoize subRequests serialization to avoid expensive JSON.stringify on every render @@ -1078,7 +1078,9 @@ const NoteList = forwardRef( } } if (!changed) return prev - return { ...prev, pending, version: prev.version + 1 } + // Do not bump `version` here — only the debounced batch + profile merges should notify + // `useFetchProfile` (via profiles map / pending membership), not every pending-key sync. + return { ...prev, pending } }) }, [timelineEventsForFilter, newEvents]) @@ -1564,7 +1566,7 @@ const NoteList = forwardRef( } } if (!pendingChanged) return prev - return { ...prev, pending, version: prev.version + 1 } + return { ...prev, pending } }) void (async () => { diff --git a/src/components/NoteStats/index.tsx b/src/components/NoteStats/index.tsx index f54662ff..464b09a4 100644 --- a/src/components/NoteStats/index.tsx +++ b/src/components/NoteStats/index.tsx @@ -8,9 +8,8 @@ import noteStatsService from '@/services/note-stats.service' import { ExtendedKind } from '@/constants' import { useReplyUnderDiscussionRoot } from '@/hooks/useReplyUnderDiscussionRoot' import { shouldHideInteractions } from '@/lib/event-filtering' -import logger from '@/lib/logger' import { Event } from 'nostr-tools' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import BookmarkButton from '../BookmarkButton' import { LikeButtonWithStats } from './LikeButton' import { LikesWithStats } from './Likes' @@ -40,8 +39,8 @@ export default function NoteStats({ const { isSmallScreen } = useScreenSize() const { pubkey } = useNostr() const noteStats = useNoteStatsById(event.id) - const { relays: hintRelays, key: hintRelaysKey } = useNoteStatsRelayHints() - const { relayUrls: rssUrlThreadRelays, key: rssUrlThreadRelaysKey } = useRssUrlThreadQueryRelays() + const { relays: hintRelays, currentRelaysKey } = useNoteStatsRelayHints() + const { relayUrls: rssUrlThreadRelays, relayMergeTier } = useRssUrlThreadQueryRelays() const [loading, setLoading] = useState(false) // Hide boost button for discussion events and replies to discussions @@ -55,7 +54,10 @@ export default function NoteStats({ const isRssArticleRoot = event.kind === ExtendedKind.RSS_THREAD_ROOT /** Match {@link RssUrlThreadStatsBar}: inbox/favorites/fast-read merge — plain hints miss many #i indexers. */ const statsRelays = isRssArticleRoot ? rssUrlThreadRelays : hintRelays - const statsRelaysKey = isRssArticleRoot ? rssUrlThreadRelaysKey : hintRelaysKey + /** At most two background refetches per card: before vs after inbox/favorite hints hydrate. */ + const statsRelayFetchTier = isRssArticleRoot ? relayMergeTier : hintRelays.length > 0 ? 1 : 0 + const statsRelaysRef = useRef(statsRelays) + statsRelaysRef.current = statsRelays const isZapPoll = event.kind === ExtendedKind.ZAP_POLL /** Emoji reaction pills (aggregated likes). Shown for RSS/Web URL threads so the side panel matches feed rows. */ @@ -63,18 +65,24 @@ export default function NoteStats({ useEffect(() => { if (!fetchIfNotExisting) return - logger.debug('[NoteStats] UI: scheduling fetchNoteStats', { - eventId: `${event.id.slice(0, 12)}…`, - kind: event.kind, - hintRelayCount: statsRelays.length - }) setLoading(true) noteStatsService - .fetchNoteStats(event, pubkey, statsRelays, { foreground: foregroundStats }) + .fetchNoteStats(event, pubkey, statsRelaysRef.current, { foreground: foregroundStats }) .finally(() => setLoading(false)) // Intentionally omit `event` object: parent feeds often pass new references each render; // id/sig/kind/created_at identify the note for refetch boundaries. - }, [event.id, event.kind, event.created_at, event.sig, fetchIfNotExisting, foregroundStats, pubkey, statsRelaysKey]) + // `statsRelayFetchTier` (not full sorted relay key) avoids a REQ storm when favorites/current relays hydrate. + }, [ + event.id, + event.kind, + event.created_at, + event.sig, + fetchIfNotExisting, + foregroundStats, + pubkey, + statsRelayFetchTier, + currentRelaysKey + ]) if (isSmallScreen) { return ( diff --git a/src/components/RelayIcon/index.tsx b/src/components/RelayIcon/index.tsx index 7047e064..a0a7c204 100644 --- a/src/components/RelayIcon/index.tsx +++ b/src/components/RelayIcon/index.tsx @@ -1,5 +1,6 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { useFetchRelayInfo } from '@/hooks' +import { getRelayIconOverrideSrc, relayUrlFingerprintColors } from '@/lib/relay-icon-source' import { cn } from '@/lib/utils' import logger from '@/lib/logger' import { Server } from 'lucide-react' @@ -45,6 +46,12 @@ export default function RelayIcon({ const iconUrl = useMemo(() => { if (!url) return undefined + const override = getRelayIconOverrideSrc(url) + if (override) { + logger.debug('[RelayIcon] using override icon', { url, override }) + return override + } + // Prefer the NIP-11 icon field const rawIcon = relayInfo?.icon && typeof relayInfo.icon === 'string' ? relayInfo.icon : undefined const nip11Icon = rawIcon ? resolveRelayImageUrl(rawIcon, url) : undefined @@ -56,11 +63,16 @@ export default function RelayIcon({ return undefined }, [url, relayInfo]) + const fallbackColors = useMemo(() => relayUrlFingerprintColors(url), [url]) + return ( {iconUrl && } - - + + ) diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 59870a10..5eaed53c 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -722,7 +722,7 @@ function ReplyNoteList({ } } if (!changed) return prev - return { ...prev, pending, version: prev.version + 1 } + return { ...prev, pending } }) void (async () => { @@ -769,7 +769,7 @@ function ReplyNoteList({ })() }, THREAD_PROFILE_BATCH_DEBOUNCE_MS) return () => window.clearTimeout(handle) - }, [event, mergedFeed, parentNoteFeed?.version]) + }, [event, mergedFeed, parentNoteFeed?.profiles, parentNoteFeed?.pendingPubkeys]) const [timelineKey] = useState(undefined) const [until, setUntil] = useState(undefined) diff --git a/src/components/RssUrlThreadStatsBar/index.tsx b/src/components/RssUrlThreadStatsBar/index.tsx index f223e134..5fa14782 100644 --- a/src/components/RssUrlThreadStatsBar/index.tsx +++ b/src/components/RssUrlThreadStatsBar/index.tsx @@ -6,7 +6,7 @@ import { useRssUrlThreadQueryRelays } from '@/hooks/useRssUrlThreadQueryRelays' import { useNostr } from '@/providers/NostrProvider' import { Bookmark, Highlighter, MessageCircle, ThumbsUp } from 'lucide-react' import type { Event } from 'nostr-tools' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' /** Compact reply / reaction / bookmark / highlight counts for RSS + Web URL threads. */ export default function RssUrlThreadStatsBar({ @@ -18,7 +18,9 @@ export default function RssUrlThreadStatsBar({ }) { const { t } = useTranslation() const { pubkey } = useNostr() - const { relayUrls: statsRelays, key: statsRelaysKey } = useRssUrlThreadQueryRelays() + const { relayUrls: statsRelays, relayMergeTier, currentRelaysKey } = useRssUrlThreadQueryRelays() + const statsRelaysRef = useRef(statsRelays) + statsRelaysRef.current = statsRelays const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() const noteStats = useNoteStatsById(event.id) const [loading, setLoading] = useState(false) @@ -26,9 +28,9 @@ export default function RssUrlThreadStatsBar({ useEffect(() => { setLoading(true) noteStatsService - .fetchNoteStats(event, pubkey, statsRelays, { foreground: true }) + .fetchNoteStats(event, pubkey, statsRelaysRef.current, { foreground: true }) .finally(() => setLoading(false)) - }, [event.id, event.kind, event.created_at, event.sig, pubkey, statsRelaysKey]) + }, [event.id, event.kind, event.created_at, event.sig, pubkey, relayMergeTier, currentRelaysKey]) const fmt = (n: number) => (n >= 100 ? '99+' : String(n)) diff --git a/src/constants.ts b/src/constants.ts index fea18c64..1b6ff5e3 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -409,6 +409,7 @@ export const FAST_READ_RELAY_URLS = [ 'wss://nostr21.com', 'wss://thecitadel.nostr1.com', 'wss://aggr.nostr.land', + 'wss://primus.nostr1.com', ] // Optimized relay list for write operations (no aggregator since it's read-only) @@ -436,8 +437,6 @@ export const SEARCHABLE_RELAY_URLS = [ 'wss://orly-relay.imwald.eu', 'wss://aggr.nostr.land', 'wss://thecitadel.nostr1.com', - 'wss://relay.primal.net', - 'wss://relay.damus.io', 'wss://nos.lol', 'wss://nostr.mom', 'wss://relay.noswhere.com', @@ -449,11 +448,8 @@ export const SEARCHABLE_RELAY_URLS = [ ] export const PROFILE_RELAY_URLS = [ - 'wss://nos.lol', - 'wss://relay.damus.io', 'wss://profiles.nostr1.com', - 'wss://purplepag.es', - 'wss://thecitadel.nostr1.com' + 'wss://purplepag.es' ] export const FOLLOWS_HISTORY_RELAY_URLS = [ @@ -461,7 +457,7 @@ export const FOLLOWS_HISTORY_RELAY_URLS = [ ] // Combined relay URLs for profile fetching: search/index relays, fallback inboxes, and profile-specific relays. -export const PROFILE_FETCH_RELAY_URLS = [...SEARCHABLE_RELAY_URLS, ...FAST_READ_RELAY_URLS, ...PROFILE_RELAY_URLS] +export const PROFILE_FETCH_RELAY_URLS = [...FAST_READ_RELAY_URLS, ...PROFILE_RELAY_URLS] export const ExtendedKind = { PICTURE: 20, @@ -576,16 +572,12 @@ export function isNip71StyleVideoKind(kind: number): boolean { * When these kinds are ingested via {@link EventService.addEventToCache}, the client prefetches the event * author's kind 3 + 10002 (contacts + NIP-65) so profile / relay UIs and publish routing stay warm. * Omits reactions/zaps where `pubkey` is not the primary profile identity for the row. + * + * Empty by default: each hit used to schedule batched relay + IndexedDB work (see + * {@link ClientService.prefetchAuthorCoreReplaceables}) and could overwhelm the browser on busy feeds. + * Author lists still load from profile views, publish flow, and session prewarm. */ -export const AUTHOR_CORE_PREFETCH_ON_INGEST_KINDS: ReadonlySet = new Set([ - kinds.ShortTextNote, - kinds.LongFormArticle, - kinds.Repost, - ExtendedKind.GENERIC_REPOST, - ExtendedKind.PICTURE, - ExtendedKind.VOICE, - ...NIP71_VIDEO_KINDS -]) +export const AUTHOR_CORE_PREFETCH_ON_INGEST_KINDS: ReadonlySet = new Set() /** Short-form portrait-style bucket (kind 22 or 34236). */ export function isNip71ShortVideoKind(kind: number): boolean { diff --git a/src/hooks/useFetchProfile.tsx b/src/hooks/useFetchProfile.tsx index 310626ab..6265db12 100644 --- a/src/hooks/useFetchProfile.tsx +++ b/src/hooks/useFetchProfile.tsx @@ -9,7 +9,7 @@ import { eventService, replaceableEventService } from '@/services/client.service import indexedDb from '@/services/indexed-db.service' import { TProfile } from '@/types' import { kinds } from 'nostr-tools' -import { useEffect, useState, useRef, useCallback } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import logger from '@/lib/logger' function tryHydrateProfileFromSessionOnly(pubkey: string, skipCache: boolean): TProfile | null { @@ -58,14 +58,6 @@ const globalFetchingPubkeys = new Set() const globalFetchCooldowns = new Map() // pubkey -> timestamp when cooldown expires export function useFetchProfile(id?: string, skipCache = false) { - // CRITICAL: Reduce logging to prevent performance issues during infinite loops - // Only log if we're actually going to process (not just checking) - // logger.info('[useFetchProfile] Hook called', { - // id: id || 'undefined', - // skipCache, - // stack: new Error().stack?.split('\n').slice(1, 4).join('\n') - // }) - const nostr = useNostrOptional() const currentAccountProfile = nostr?.profile ?? null const noteFeed = useNoteFeedProfileContext() @@ -83,23 +75,41 @@ export function useFetchProfile(id?: string, skipCache = false) { const effectRunCountRef = useRef>(new Map()) // Track how many times effect has run for each pubkey (safety guard against infinite loops) const initializedPubkeysRef = useRef>(new Set()) // Track pubkeys we've successfully initialized (have profile or failed) + const pkLowerResolved = useMemo(() => { + if (!id) return null as string | null + const pk = userIdToPubkey(id) + if (pk.length !== 64 || !/^[0-9a-f]{64}$/i.test(pk)) return null + return pk.toLowerCase() + }, [id]) + + const isPendingInFeed = Boolean( + pkLowerResolved && noteFeed?.pendingPubkeys.has(pkLowerResolved) + ) + + /** + * Changes when this row's batched profile row appears/updates — **not** on every feed-wide + * `version` tick (that remounted thousands of avatars and spammed relay fetches). + */ + const feedProfileSyncKey = useMemo(() => { + if (!pkLowerResolved || !noteFeed) return '' + const row = noteFeed.profiles.get(pkLowerResolved) + if (!row) return isPendingInFeed ? 'p:wait' : 'p:none' + return [ + row.batchPlaceholder ? 'ph' : 'ok', + row.username ?? '', + row.avatar ?? '', + row.npub ?? '' + ].join('\x1e') + }, [pkLowerResolved, noteFeed?.profiles, isPendingInFeed]) + // Function to check for profile updates with GLOBAL deduplication // fetchProfileEvent already checks: 1) IndexedDB, 2) network (with author's relays) // Memoize to prevent recreation on every render const checkProfile = useCallback(async (pubkey: string, cancelled: { current: boolean }): Promise => { - // CRITICAL: Reduce logging during rapid scrolling to prevent performance issues - // Only log at debug level during normal operations - logger.debug('[useFetchProfile] checkProfile called', { - pubkey: pubkey.substring(0, 8), - cancelled: cancelled.current, - skipCache - }) - if (cancelled.current) { - logger.debug('[useFetchProfile] Already cancelled, returning null') return null } - + // CRITICAL: Check cooldown period first to prevent cascade of duplicate fetches after timeout. // Still hydrate from session/IndexedDB — otherwise new rows remount after a timeout and stay on // identicons until cooldown ends with no effect re-run (deps unchanged). @@ -117,10 +127,6 @@ export function useFetchProfile(id?: string, skipCache = false) { effectRunCountRef.current.delete(pubkey) return cachedDuringCooldown } - logger.debug('[useFetchProfile] In cooldown period after timeout, skipping fetch', { - pubkey: pubkey.substring(0, 8), - remainingMs: cooldownExpiry - Date.now() - }) return null } // Clean up expired cooldowns @@ -133,9 +139,6 @@ export function useFetchProfile(id?: string, skipCache = false) { // Add timeout protection to prevent infinite waits const existingPromise = globalFetchPromises.get(pubkey) if (existingPromise) { - logger.debug('[useFetchProfile] Reusing existing fetch promise', { - pubkey: pubkey.substring(0, 8) - }) try { // Await the shared promise only — it already races fetchProfileEvent with // PROFILE_FETCH_PROMISE_TIMEOUT_MS. Per-waiter Promise.race timers caused N identical @@ -160,10 +163,7 @@ export function useFetchProfile(id?: string, skipCache = false) { } } catch (err) { // If the existing promise failed, we'll try again below - logger.debug('[useFetchProfile] Existing promise failed, will retry', { - pubkey: pubkey.substring(0, 8), - error: err instanceof Error ? err.message : String(err) - }) + void err // Clear the failed promise so we can start fresh globalFetchPromises.delete(pubkey) globalFetchingPubkeys.delete(pubkey) @@ -196,10 +196,7 @@ export function useFetchProfile(id?: string, skipCache = false) { return null } } catch (err) { - logger.debug('[useFetchProfile] Retry promise failed', { - pubkey: pubkey.substring(0, 8), - error: err instanceof Error ? err.message : String(err) - }) + void err // Clear the failed promise globalFetchPromises.delete(pubkey) globalFetchingPubkeys.delete(pubkey) @@ -213,14 +210,9 @@ export function useFetchProfile(id?: string, skipCache = false) { let idbEarlyP: Promise | null = null try { globalFetchingPubkeys.add(pubkey) - const startTime = Date.now() const quick = tryHydrateProfileFromSessionOnly(pubkey, skipCache) if (quick) { - logger.debug('[useFetchProfile] Profile from session/IndexedDB (fast path)', { - pubkey: pubkey.substring(0, 8), - hasAvatar: !!quick.avatar - }) return quick } @@ -243,68 +235,30 @@ export function useFetchProfile(id?: string, skipCache = false) { replaceableEventService.fetchProfileEvent(pubkey, skipCache), timeoutPromise ]) - const fetchTime = Date.now() - startTime - - if (profileEvent || fetchTime > 1000) { - logger.debug('[useFetchProfile] fetchProfileEvent completed', { - pubkey: pubkey.substring(0, 8), - hasEvent: !!profileEvent, - eventId: profileEvent?.id?.substring(0, 8), - fetchTime: `${fetchTime}ms` - }) - } - if (profileEvent) { // getProfileFromEvent always returns a profile object (with fallback username) const newProfile = getProfileFromEvent(profileEvent) - // Only log at debug level to reduce noise during rapid scrolling - logger.debug('[useFetchProfile] Profile found', { - pubkey: pubkey.substring(0, 8), - username: newProfile.username, - hasAvatar: !!newProfile.avatar, - fetchTime: `${fetchTime}ms`, - unmounted: cancelled.current - }) // CRITICAL: Always return the profile from this shared promise, even when the // originating hook cleaned up (list virtualization, Strict Mode, feed switch). // Returning null here made every waiter treat the result like a timeout, applied // cooldowns, and left avatars empty (especially busy feeds e.g. all-favorites). return newProfile } - // Only log warnings for missing profiles if skipCache is true (user explicitly requested) - if (skipCache) { - logger.debug('[useFetchProfile] No profile event found', { - pubkey: pubkey.substring(0, 8), - fetchTime: `${fetchTime}ms` - }) - } const afterMiss = (idbEarlyP != null ? await idbEarlyP : null) ?? tryHydrateProfileFromSessionOnly(pubkey, skipCache) if (afterMiss) { - logger.debug('[useFetchProfile] Profile from session/IndexedDB after network miss', { - pubkey: pubkey.substring(0, 8), - hasAvatar: !!afterMiss.avatar - }) return afterMiss } return null } catch (err) { const isTimeout = err instanceof Error && err.message.includes('timeout') if (isTimeout) { - logger.debug('[useFetchProfile] Profile fetch timed out', { - pubkey: pubkey.substring(0, 8), - error: err.message - }) // Set cooldown period after timeout to prevent cascade of duplicate fetches globalFetchCooldowns.set(pubkey, Date.now() + 10000) // 10 second cooldown const fallback = tryHydrateProfileFromSessionOnly(pubkey, skipCache) ?? (idbEarlyP != null ? await idbEarlyP : null) if (fallback) { - logger.debug('[useFetchProfile] Profile from session/IndexedDB after fetch timeout', { - pubkey: pubkey.substring(0, 8), - hasAvatar: !!fallback.avatar - }) return fallback } // Return null on timeout instead of throwing - allows UI to show fallback @@ -366,9 +320,7 @@ export function useFetchProfile(id?: string, skipCache = false) { }, [skipCache]) useEffect(() => { - // Early exit when id is missing (e.g. truncated or undefined) - use debug to avoid console spam if (!id) { - logger.debug('[useFetchProfile] No id provided') setProfile(null) setPubkey(null) setIsFetching(false) @@ -377,14 +329,12 @@ export function useFetchProfile(id?: string, skipCache = false) { return } - // Extract pubkey early to check if id has changed const extractedPubkey = userIdToPubkey(id) // Note feeds: profiles are batch-fetched in NoteList — skip per-row relay storms while pending. - // Batch may only synthesize a pubkey row when kind 0 is missing; those must not skip fetchProfileEvent - // or avatars stay on identicons forever. if (extractedPubkey && noteFeed && !skipCache) { - const fromBatch = noteFeed.profiles.get(extractedPubkey) + const pkL = extractedPubkey.toLowerCase() + const fromBatch = noteFeed.profiles.get(pkL) ?? noteFeed.profiles.get(extractedPubkey) if (fromBatch && !fromBatch.batchPlaceholder) { setProfile(fromBatch) setPubkey(extractedPubkey) @@ -402,9 +352,8 @@ export function useFetchProfile(id?: string, skipCache = false) { setIsFetching(false) setError(null) } - if (noteFeed.pendingPubkeys.has(extractedPubkey)) { - const pkLower = extractedPubkey.toLowerCase() - const sessionEv = eventService.getSessionMetadataForPubkey(pkLower) + if (noteFeed.pendingPubkeys.has(pkL)) { + const sessionEv = eventService.getSessionMetadataForPubkey(pkL) if (sessionEv) { const quick = getProfileFromEvent(sessionEv) setProfile(quick) @@ -420,7 +369,7 @@ export function useFetchProfile(id?: string, skipCache = false) { setIsFetching(false) setError(null) const pendingCancelled = { current: false } - void tryHydrateProfileFromLocalCaches(pkLower, false).then((quick) => { + void tryHydrateProfileFromLocalCaches(pkL, false).then((quick) => { if (pendingCancelled.current || !quick) return setProfile(quick) setIsFetching(false) @@ -459,11 +408,8 @@ export function useFetchProfile(id?: string, skipCache = false) { return } } - + // Skip only when this pubkey already has an in-flight fetch (global dedupe + local flag). - // Do **not** return merely because processingPubkeyRef matches: after a failed or timed-out - // fetch, deps can change (e.g. noteFeed.version) while ref is still set — returning here - // left the hook stuck forever (blank profile / 404 until hard reload). if (extractedPubkey) { if (processingPubkeyRef.current === extractedPubkey) { const sharedPromise = globalFetchPromises.get(extractedPubkey) @@ -477,92 +423,62 @@ export function useFetchProfile(id?: string, skipCache = false) { processingPubkeyRef.current = extractedPubkey } - // CRITICAL: Early exit if we already have a profile for this pubkey - // This prevents re-fetching when we already have the profile if (extractedPubkey && profile && profile.pubkey === extractedPubkey && !profile.batchPlaceholder) { - // Ensure processingPubkeyRef is set to prevent re-fetch if (processingPubkeyRef.current !== extractedPubkey) { processingPubkeyRef.current = extractedPubkey } - // Mark as initialized initializedPubkeysRef.current.add(extractedPubkey) - // Ensure fetching is false (but don't call setState if already false to avoid re-renders) if (isFetching) { setIsFetching(false) } - // Clear run count since we have the profile effectRunCountRef.current.delete(extractedPubkey) return } - - // CRITICAL: Early exit if we've already initialized this pubkey (even if profile is null) - // This prevents re-fetching when we've already tried and failed - // BUT: Allow retry if skipCache is true (user explicitly wants to refresh) + if (extractedPubkey && initializedPubkeysRef.current.has(extractedPubkey) && !profile) { if (skipCache) { - // User wants to refresh - clear initialized flag to allow fresh fetch initializedPubkeysRef.current.delete(extractedPubkey) - // Also clear run count to allow fresh attempt effectRunCountRef.current.delete(extractedPubkey) } else { - // Already tried and failed - don't retry unless explicitly requested - // Ensure fetching is false if (isFetching) { setIsFetching(false) } return } } - - // CRITICAL: Guard against infinite loops — limit effect runs per pubkey. Feed batch often leaves - // {@link batchPlaceholder} rows that need several retries across noteFeed.version bumps; use a higher cap. + if (extractedPubkey) { const runCount = effectRunCountRef.current.get(extractedPubkey) || 0 const pkLower = extractedPubkey.toLowerCase() const feedBatchPlaceholder = noteFeed?.profiles.get(pkLower)?.batchPlaceholder === true || noteFeed?.profiles.get(extractedPubkey)?.batchPlaceholder === true - const maxRunsBeforeCircuitBreak = feedBatchPlaceholder ? 12 : 3 + const maxRunsBeforeCircuitBreak = feedBatchPlaceholder ? 6 : 3 if (runCount >= maxRunsBeforeCircuitBreak) { logger.warn('[useFetchProfile] Too many effect runs for this pubkey, preventing infinite loop', { extractedPubkey, runCount }) - // Clear the run count after a delay to allow retries later setTimeout(() => { effectRunCountRef.current.delete(extractedPubkey) - }, 30000) // Clear after 30 seconds + }, 30000) processingPubkeyRef.current = null if (isFetching) setIsFetching(false) return } - // Only increment if we're actually going to process effectRunCountRef.current.set(extractedPubkey, runCount + 1) } - - // If id has changed (extractedPubkey is different from processingPubkeyRef), clear the refs - // This allows a new fetch to start for a different pubkey + if (extractedPubkey && processingPubkeyRef.current && processingPubkeyRef.current !== extractedPubkey) { const oldPubkey = processingPubkeyRef.current - // Clear run count and initialized status for old pubkey before clearing ref effectRunCountRef.current.delete(oldPubkey) initializedPubkeysRef.current.delete(oldPubkey) processingPubkeyRef.current = null } - + const cancelled = { current: false } - // CRITICAL: Reduce logging during rapid scrolling - only log at debug level - logger.debug('[useFetchProfile] Extracting pubkey', { - idLength: id.length, - idStartsWithNpub: id.startsWith('npub1'), - idStartsWithNprofile: id.startsWith('nprofile1') - }) - + if (!extractedPubkey) { - logger.debug('[useFetchProfile] Invalid id - could not extract pubkey', { - idLength: id.length, - prefix: id.slice(0, 16) - }) setProfile(null) setPubkey(null) setIsFetching(false) @@ -570,14 +486,8 @@ export function useFetchProfile(id?: string, skipCache = false) { processingPubkeyRef.current = null return } - - // Validate pubkey format + if (extractedPubkey.length !== 64 || !/^[0-9a-f]{64}$/.test(extractedPubkey)) { - logger.debug('[useFetchProfile] Invalid pubkey format (non-hex id passed through userIdToPubkey)', { - idLength: id.length, - extractedLen: extractedPubkey.length, - prefix: id.slice(0, 12) - }) setProfile(null) setPubkey(null) setIsFetching(false) @@ -585,104 +495,60 @@ export function useFetchProfile(id?: string, skipCache = false) { processingPubkeyRef.current = null return } - - // These checks are now done earlier in the effect (before incrementing run count) - // Keeping this as a safety check, but it should rarely be hit now that we set processingPubkeyRef earlier + if (processingPubkeyRef.current !== extractedPubkey) { - // This should never happen now, but keep as safety check logger.warn('[useFetchProfile] processingPubkeyRef mismatch (safety check)', { extractedPubkey, processingPubkey: processingPubkeyRef.current }) processingPubkeyRef.current = extractedPubkey } - + if (profile && profile.pubkey === extractedPubkey && !profile.batchPlaceholder) { - logger.debug('[useFetchProfile] Already have profile for this pubkey (safety check)', { - extractedPubkey - }) setIsFetching(false) effectRunCountRef.current.delete(extractedPubkey) return } - - // processingPubkeyRef is already set earlier (right after extraction) - // No need to set it again here - - // CRITICAL: Only update pubkey state if it's actually different - // Avoid state updates that could trigger re-renders and loops + if (pubkey !== extractedPubkey) { setPubkey(extractedPubkey) } - // CRITICAL: Reduce logging during rapid scrolling - logger.debug('[useFetchProfile] Starting profile fetch', { - pubkey: extractedPubkey?.substring(0, 8) || 'null' - }) const run = async () => { try { setIsFetching(true) setError(null) - - // Initial fetch - fetchReplaceableEvent checks: 1) in-memory, 2) IndexedDB, 3) network - // checkProfile now returns the profile directly (or null) and handles global deduplication + const profile = await checkProfile(extractedPubkey, cancelled) - - // Only log if profile was found or if cancelled (important events) - if (profile || cancelled.current) { - logger.debug('[useFetchProfile] checkProfile completed', { - pubkey: extractedPubkey?.substring(0, 8), - found: !!profile, - cancelled: cancelled.current - }) - } - + if (cancelled.current) { - logger.debug('[useFetchProfile] Cancelled after checkProfile, cleaning up') setIsFetching(false) return } - + if (profile) { - // Profile found (from cache or network), we're done - // checkProfile already set the profile state, so we're done return } - - logger.debug('[useFetchProfile] No profile found, considering retry') - // 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 - - // CRITICAL FIX: Disable retry intervals during rapid scrolling to prevent browser crashes - // Only retry if skipCache is true (user explicitly wants to refresh) - // For normal feed scrolling, missing profiles are acceptable and will be fetched on-demand - // This prevents accumulation of hundreds of intervals during rapid scrolling + setError(null) + if (skipCache) { - // If no profile was found, periodically re-check (profiles might load asynchronously) - // REDUCED: Check every 10 seconds for up to 30 seconds (3 checks) to prevent too many intervals - // This reduces memory usage when many profiles are being fetched (e.g., large search results) let checkCount = 0 - const maxChecks = 3 // Reduced from 4 to further reduce load + const maxChecks = 3 const startTime = Date.now() - const maxTotalTime = 20000 // 20 seconds total timeout (3 checks * ~5s + buffer) - + const maxTotalTime = 20000 + checkIntervalRef.current = setInterval(async () => { - // CRITICAL: Check for timeout to prevent infinite retries const elapsed = Date.now() - startTime if (elapsed > maxTotalTime) { - logger.warn('[useFetchProfile] Retry interval timeout reached, stopping retries', { - pubkey: extractedPubkey?.substring(0, 8), - elapsed: `${elapsed}ms` - }) if (checkIntervalRef.current) { clearInterval(checkIntervalRef.current) checkIntervalRef.current = null } return } - + if (cancelled.current || checkCount >= maxChecks) { if (checkIntervalRef.current) { clearInterval(checkIntervalRef.current) @@ -690,31 +556,23 @@ export function useFetchProfile(id?: string, skipCache = false) { } return } - + checkCount++ const profile = await checkProfile(extractedPubkey, cancelled) if (profile || cancelled.current) { - // Profile found or cancelled, stop checking if (checkIntervalRef.current) { clearInterval(checkIntervalRef.current) checkIntervalRef.current = null } } - }, 10000) // Increased from 5 seconds to 10 seconds to reduce load - } else { - // For normal feed scrolling, don't set up retry intervals - // Profiles will be fetched on-demand when user navigates to profile page - // This prevents accumulation of intervals during rapid scrolling - logger.debug('[useFetchProfile] Skipping retry intervals for normal feed scrolling', { - pubkey: extractedPubkey - }) + }, 10000) } } catch (err) { - logger.error('[useFetchProfile] run() error', { - pubkey: extractedPubkey, - error: err instanceof Error ? err.message : String(err), - stack: err instanceof Error ? err.stack : undefined - }) + logger.error('[useFetchProfile] run() error', { + pubkey: extractedPubkey, + error: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined + }) if (!cancelled.current) { setError(err as Error) setIsFetching(false) @@ -722,9 +580,6 @@ export function useFetchProfile(id?: string, skipCache = false) { } } - logger.debug('[useFetchProfile] About to call run()', { - pubkey: extractedPubkey?.substring(0, 8) - }) run().catch((err) => { logger.error('[useFetchProfile] Unhandled error in run()', { pubkey: extractedPubkey, @@ -734,26 +589,18 @@ export function useFetchProfile(id?: string, skipCache = false) { }) return () => { cancelled.current = true - // Only clear processingPubkeyRef if it matches the current pubkey - // This prevents clearing it if a new fetch has already started if (processingPubkeyRef.current === extractedPubkey) { processingPubkeyRef.current = null } - // CRITICAL: Always clear interval on cleanup to prevent memory leaks - // This is especially important during rapid scrolling when many components mount/unmount if (checkIntervalRef.current) { clearInterval(checkIntervalRef.current) checkIntervalRef.current = null } - // Clear run count and initialized status on cleanup to allow fresh fetches if component remounts if (extractedPubkey) { effectRunCountRef.current.delete(extractedPubkey) - // Don't clear initializedPubkeysRef here - keep it to prevent re-fetching on remount - // Only clear it if explicitly requested via skipCache } } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id, skipCache, noteFeed?.version]) // checkProfile is memoized; noteFeed.version hydrates batch profiles + }, [id, skipCache, feedProfileSyncKey, checkProfile]) useEffect(() => { const acc = currentAccountProfile diff --git a/src/hooks/useNoteStatsRelayHints.ts b/src/hooks/useNoteStatsRelayHints.ts index 352a913f..0d270ed2 100644 --- a/src/hooks/useNoteStatsRelayHints.ts +++ b/src/hooks/useNoteStatsRelayHints.ts @@ -6,13 +6,19 @@ import { useMemo } from 'react' * Relays to hint for note-stats REQ construction: user favorites plus any “current” relays * (e.g. single-relay feed), deduped. */ -export function useNoteStatsRelayHints(): { relays: string[]; key: string } { +export function useNoteStatsRelayHints(): { + relays: string[] + key: string + /** Sorted join of “current” relays only (small); use in effects when favorites tier is stable. */ + currentRelaysKey: string +} { const { favoriteRelays } = useFavoriteRelays() const currentRelayUrls = useCurrentRelayUrlsOptional() return useMemo(() => { const relays = [...new Set([...(favoriteRelays ?? []), ...currentRelayUrls])] const key = relays.slice().sort().join('|') - return { relays, key } + const currentRelaysKey = currentRelayUrls.slice().sort().join('|') + return { relays, key, currentRelaysKey } }, [favoriteRelays, currentRelayUrls]) } diff --git a/src/hooks/useRssUrlThreadQueryRelays.ts b/src/hooks/useRssUrlThreadQueryRelays.ts index 193f78a2..30d5c1c2 100644 --- a/src/hooks/useRssUrlThreadQueryRelays.ts +++ b/src/hooks/useRssUrlThreadQueryRelays.ts @@ -9,10 +9,15 @@ import { useNoteStatsRelayHints } from './useNoteStatsRelayHints' * Relay set for RSS+Web article URL thread REQs: inbox/favorites/fast-read merge (same as URL discovery) * plus {@link useNoteStatsRelayHints} (current relay context). */ -export function useRssUrlThreadQueryRelays(): { relayUrls: string[]; key: string } { +export function useRssUrlThreadQueryRelays(): { + relayUrls: string[] + key: string + relayMergeTier: number + currentRelaysKey: string +} { const { pubkey } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() - const { relays: hintRelays, key: hintKey } = useNoteStatsRelayHints() + const { relays: hintRelays, key: hintKey, currentRelaysKey } = useNoteStatsRelayHints() const [baseUrls, setBaseUrls] = useState([]) const [baseKey, setBaseKey] = useState('') @@ -35,6 +40,8 @@ export function useRssUrlThreadQueryRelays(): { relayUrls: string[]; key: string return useMemo(() => { const merged = [...new Set([...baseUrls, ...hintRelays])] const relayUrls = merged.length > 0 ? merged : [...FAST_READ_RELAY_URLS] - return { relayUrls, key: `${baseKey}::${hintKey}::${relayUrls.length}` } - }, [baseUrls, baseKey, hintRelays, hintKey]) + /** Coarse tier for effects: avoids refetching on every relay-url permutation once hints exist. */ + const relayMergeTier = (baseUrls.length > 0 ? 2 : 0) | (hintRelays.length > 0 ? 1 : 0) + return { relayUrls, key: `${baseKey}::${hintKey}::${relayUrls.length}`, relayMergeTier, currentRelaysKey } + }, [baseUrls, baseKey, hintRelays, hintKey, currentRelaysKey]) } diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 68de2b52..83610c1e 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -2,11 +2,12 @@ * Centralized logging utility. * * Level matrix: - * dev + debug flag → debug / info / warn / error (full formatted output) - * dev (no flag) → info / warn / error (formatted, no stack) - * production → warn / error only (bare console — no timestamp string built) + * dev (default) → debug / info / warn / error (full formatted output; `logger.debug` on) + * dev + opt-out → info / warn / error (set `imwald-debug` or `jumble-debug` to `false`) + * production → warn / error only (bare console — no timestamp string built) * - * Enable debug in dev: localStorage.setItem('jumble-debug', 'true') then reload. + * Opt out in dev: `localStorage.setItem('imwald-debug', 'false')` then reload. + * Force on (e.g. prod build): `VITE_DEBUG=true` or localStorage … `'true'`. */ type LogLevel = 'debug' | 'info' | 'warn' | 'error' @@ -19,11 +20,15 @@ class Logger { private minLevel: LogLevel constructor() { - this.enableDebug = - this.isDev && - (localStorage.getItem('imwald-debug') === 'true' || - localStorage.getItem('jumble-debug') === 'true' || - import.meta.env.VITE_DEBUG === 'true') + const explicitOff = + localStorage.getItem('imwald-debug') === 'false' || + localStorage.getItem('jumble-debug') === 'false' + const explicitOn = + localStorage.getItem('imwald-debug') === 'true' || + localStorage.getItem('jumble-debug') === 'true' || + import.meta.env.VITE_DEBUG === 'true' + // `npm run dev`: debug on by default so relay/query/cache traces are visible without localStorage. + this.enableDebug = this.isDev && (explicitOn || !explicitOff) // In production only warn/error reach the console — info is noise for end-users. this.minLevel = this.enableDebug ? 'debug' : this.isDev ? 'info' : 'warn' diff --git a/src/lib/relay-icon-source.ts b/src/lib/relay-icon-source.ts new file mode 100644 index 00000000..f57eb309 --- /dev/null +++ b/src/lib/relay-icon-source.ts @@ -0,0 +1,73 @@ +import { normalizeUrl } from '@/lib/url' +import { isWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' + +/** + * Site favicon for nostr.sovbit (same as the browser tab icon on `/invoices` and the rest of the host). + * @see https://nostr.sovbit.host/invoices + */ +export const NOSTR_SOVBIT_ICON_SRC = 'https://nostr.sovbit.host/favicon.ico' + +/** + * Free relay slice — distinct branding from paid nostr.sovbit. + * @see https://freelay.sovbit.host/ + */ +export const FREELAY_SOVBIT_ICON_SRC = 'https://freelay.sovbit.host/favicon.ico' + +/** + * Nostr Archives front-site favicon for trending shards and related relay hosts. + * @see https://nostrarchives.com/ + */ +export const NOSTRARCHIVES_SITE_ICON_SRC = 'https://nostrarchives.com/favicon.ico' + +function parseRelayHostname(url: string): string | undefined { + const raw = (normalizeUrl(url) || url).trim() + const forParse = raw.replace(/^ws:\/\//i, 'http://').replace(/^wss:\/\//i, 'https://') + try { + return new URL(forParse).hostname.toLowerCase() + } catch { + return undefined + } +} + +/** + * Static icon URL for relays where NIP-11 is missing or we want a consistent mark (tab favicon). + * Checked before NIP-11 `icon` in {@link RelayIcon}. + */ +export function getRelayIconOverrideSrc(url: string | undefined): string | undefined { + if (!url) return undefined + const host = parseRelayHostname(url) + if (!host) return undefined + if (host === 'nostr.sovbit.host') { + return NOSTR_SOVBIT_ICON_SRC + } + if (host === 'freelay.sovbit.host') { + return FREELAY_SOVBIT_ICON_SRC + } + if ( + isWispTrendingNotesRelayUrl(url) || + host === 'feeds.nostrarchives.com' || + host === 'nostrarchives.com' + ) { + return NOSTRARCHIVES_SITE_ICON_SRC + } + return undefined +} + +/** FNV-1a-ish fingerprint → HSL for a per-relay fallback swatch (no network). */ +export function relayUrlFingerprintColors(url: string | undefined): { + background: string + color: string +} { + const raw = url ?? '' + const s = ((normalizeUrl(raw) || raw || '?').trim().toLowerCase()) + let h = 2166136261 >>> 0 + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i) + h = Math.imul(h, 16777619) >>> 0 + } + const hue = h % 360 + return { + background: `hsl(${hue} 50% 36%)`, + color: `hsl(${hue} 35% 96%)` + } +} diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index a2abacdb..1b1b6afe 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -104,6 +104,18 @@ export class EventService { private sessionEventCache = new LRUCache({ max: getDefaultSessionLruMaxSync() }) /** Latest kind-0 per pubkey from {@link sessionEventCache} for batch profile short-circuit. */ private sessionMetadataByPubkey = new Map() + /** Ingest coalescing: max `created_at` already queued for durable replaceable cache (coordinate → ts). */ + private ingestReplaceablePersistMaxCreatedAt = new Map() + + private trimIngestReplaceablePersistMap(): void { + const MAX = 12_000 + while (this.ingestReplaceablePersistMaxCreatedAt.size > MAX) { + const k = this.ingestReplaceablePersistMaxCreatedAt.keys().next().value + if (k === undefined) break + this.ingestReplaceablePersistMaxCreatedAt.delete(k) + } + } + /** Callbacks waiting for an event id to appear in {@link sessionEventCache} (e.g. embed loads before timeline caches the note). */ private sessionEventWaiters = new Map void>>() /** Waiters keyed like {@link replaceableWaiterKey} — naddr embeds have no hex id until a REQ returns. */ @@ -542,7 +554,13 @@ export class EventService { (cleanEvent.kind === kinds.RelayList || cleanEvent.kind === kinds.Contacts) && indexedDb.hasReplaceableEventStoreForKind(cleanEvent.kind) ) { - void client.replaceableEventService.updateReplaceableEventCache(cleanEvent as NEvent).catch(() => {}) + const coord = normalizeReplaceableCoordinateString(getReplaceableCoordinateFromEvent(cleanEvent as NEvent)) + const prev = this.ingestReplaceablePersistMaxCreatedAt.get(coord) ?? -1 + if (cleanEvent.created_at > prev) { + this.ingestReplaceablePersistMaxCreatedAt.set(coord, cleanEvent.created_at) + this.trimIngestReplaceablePersistMap() + void client.replaceableEventService.updateReplaceableEventCache(cleanEvent as NEvent).catch(() => {}) + } } if (AUTHOR_CORE_PREFETCH_ON_INGEST_KINDS.has(cleanEvent.kind)) { const pk = cleanEvent.pubkey @@ -554,25 +572,30 @@ export class EventService { this.notifyReplaceableCoordinateWaiters(cleanEvent as NEvent) queuePersistSeenEvent(cleanEvent as NEvent) if (isReplaceableEvent(cleanEvent.kind) && isDocumentRelayKind(cleanEvent.kind)) { - // Long-form (30023), wiki, and publication replaceables — same store as profile “Articles” tab. - void indexedDb.putReplaceableEvent(cleanEvent as NEvent).catch((error: unknown) => { - const err = error instanceof Error ? error : new Error(String(error)) - const q = err.name === 'QuotaExceededError' || /quota|storage/i.test(err.message) - if (q) { - logger.debug('[EventService] Skipped document replaceable IndexedDB persist (storage quota)', { + const docCoord = normalizeReplaceableCoordinateString(getReplaceableCoordinateFromEvent(cleanEvent as NEvent)) + const docPrev = this.ingestReplaceablePersistMaxCreatedAt.get(docCoord) ?? -1 + if (cleanEvent.created_at > docPrev) { + this.ingestReplaceablePersistMaxCreatedAt.set(docCoord, cleanEvent.created_at) + this.trimIngestReplaceablePersistMap() + void indexedDb.putReplaceableEvent(cleanEvent as NEvent).catch((error: unknown) => { + const err = error instanceof Error ? error : new Error(String(error)) + const q = err.name === 'QuotaExceededError' || /quota|storage/i.test(err.message) + if (q) { + logger.debug('[EventService] Skipped document replaceable IndexedDB persist (storage quota)', { + kind: cleanEvent.kind, + eventId: id + }) + return + } + logger.warn('[EventService] Failed to persist document replaceable to IndexedDB', { kind: cleanEvent.kind, - eventId: id + eventId: id, + errorMessage: err.message, + errorName: err.name, + error: err }) - return - } - logger.warn('[EventService] Failed to persist document replaceable to IndexedDB', { - kind: cleanEvent.kind, - eventId: id, - errorMessage: err.message, - errorName: err.name, - error: err }) - }) + } } if (isCalendarEventKind(cleanEvent.kind)) { void indexedDb.putCalendarEventRow(cleanEvent as NEvent).catch((error: unknown) => { diff --git a/src/services/client.service.ts b/src/services/client.service.ts index d45757ce..82d8699e 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -155,6 +155,7 @@ import { import { sha256 } from '@noble/hashes/sha2' import dayjs from 'dayjs' import FlexSearch from 'flexsearch' +import { LRUCache } from 'lru-cache' import { EventTemplate, Filter, @@ -300,7 +301,14 @@ class ClientService extends EventTarget { private sessionPrewarmBaseCompleted = false /** Per-pubkey cooldown for {@link prefetchAuthorCoreReplaceables} from feed ingest (avoid REQ storms). */ private authorCorePrefetchCooldownUntilMs = new Map() - private static readonly AUTHOR_CORE_PREFETCH_COOLDOWN_MS = 90_000 + private static readonly AUTHOR_CORE_PREFETCH_COOLDOWN_MS = 6 * 60 * 1000 + /** + * Max kind-0 `created_at` already queued for IndexedDB from {@link QueryService} ingest (same profile + * re-emitted across relays/batches should not each open tombstone + get + put). + */ + private ingestProfileIdbMaxCreatedAt = new Map() + /** Dedupe {@link addUsernameToIndex} for the same kind-0 id (multi-relay / re-REQ spam). */ + private metadataIngestIndexDedupe = new LRUCache({ max: 12_000 }) constructor() { super() @@ -346,8 +354,18 @@ class ClientService extends EventTarget { this.eventService.addEventToCache(e) // Kind 0 from timelines/REQs was only kept in the session LRU, not in PROFILE_EVENTS or FlexSearch, // so @-mention / profile search missed people you already saw on feeds (e.g. notifications). - if (e.kind === kinds.Metadata && !shouldDropEventOnIngest(e)) { + if (e.kind !== kinds.Metadata || shouldDropEventOnIngest(e)) continue + const pk = e.pubkey.toLowerCase() + const best = this.eventService.getSessionMetadataForPubkey(pk) + if (!best || best.id !== e.id) continue + if (!this.metadataIngestIndexDedupe.has(e.id)) { + this.metadataIngestIndexDedupe.set(e.id, true) void this.addUsernameToIndex(e) + } + const prev = this.ingestProfileIdbMaxCreatedAt.get(pk) ?? -1 + if (e.created_at > prev) { + this.ingestProfileIdbMaxCreatedAt.set(pk, e.created_at) + this.trimIngestProfileIdbMap() void indexedDb.putReplaceableEvent(e).catch(() => {}) } } @@ -3681,6 +3699,15 @@ class ClientService extends EventTarget { return out } + private trimIngestProfileIdbMap(): void { + const MAX = 10_000 + while (this.ingestProfileIdbMaxCreatedAt.size > MAX) { + const k = this.ingestProfileIdbMaxCreatedAt.keys().next().value + if (k === undefined) break + this.ingestProfileIdbMaxCreatedAt.delete(k) + } + } + private async addUsernameToIndex(profileEvent: NEvent) { try { const profileObj = JSON.parse(profileEvent.content) diff --git a/src/services/event-archive.service.ts b/src/services/event-archive.service.ts index 6df2de56..e1347d14 100644 --- a/src/services/event-archive.service.ts +++ b/src/services/event-archive.service.ts @@ -20,6 +20,12 @@ const CORE_FEED_KINDS = new Set([ let footprint: { count: number; bytes: number } | null = null const pending = new Map() let flushTimer: ReturnType | null = null +let archiveFlushInProgress = false + +/** Hard cap so a relay flood cannot grow an unbounded RAM queue before the first flush. */ +const MAX_PENDING_ARCHIVE = 4000 +/** IndexedDB + trim work per flush so one timer tick cannot process tens of thousands of rows. */ +const MAX_ARCHIVE_ROWS_PER_FLUSH = 150 export function invalidateArchiveFootprintCache(): void { footprint = null @@ -77,26 +83,46 @@ async function trimArchiveIfNeeded(): Promise { } async function flushArchiveQueue(): Promise { - if (pending.size === 0) return - const batch = [...pending.values()] - pending.clear() - for (const ev of batch) { - if (shouldSkipArchiving(ev)) continue - const id = /^[0-9a-f]{64}$/i.test(ev.id) ? ev.id.toLowerCase() : ev.id - const tier = archiveTierForEvent(ev) - const bytes = approxEventBytes(ev) - try { - await indexedDb.putArchivedEventRow(ev, tier, bytes) - } catch (e) { - logger.warn('[EventArchive] put failed', { id: id.slice(0, 8), e }) + if (archiveFlushInProgress) return + archiveFlushInProgress = true + try { + while (pending.size > 0) { + const batch: Event[] = [] + for (const id of pending.keys()) { + if (batch.length >= MAX_ARCHIVE_ROWS_PER_FLUSH) break + const ev = pending.get(id) + if (ev) { + pending.delete(id) + batch.push(ev) + } + } + if (batch.length === 0) break + for (const ev of batch) { + if (shouldSkipArchiving(ev)) continue + const id = /^[0-9a-f]{64}$/i.test(ev.id) ? ev.id.toLowerCase() : ev.id + const tier = archiveTierForEvent(ev) + const bytes = approxEventBytes(ev) + try { + await indexedDb.putArchivedEventRow(ev, tier, bytes) + } catch (e) { + logger.warn('[EventArchive] put failed', { id: id.slice(0, 8), e }) + } + } } + footprint = await indexedDb.getArchiveFootprint() + await trimArchiveIfNeeded() + } catch (e) { + logger.warn('[EventArchive] flush failed', { e }) + } finally { + archiveFlushInProgress = false + } + if (pending.size > 0) { + void flushArchiveQueue().catch((e) => logger.warn('[EventArchive] flush', e)) } - footprint = await indexedDb.getArchiveFootprint() - await trimArchiveIfNeeded() } function scheduleFlush(): void { - if (flushTimer !== null) return + if (flushTimer !== null || archiveFlushInProgress) return flushTimer = setTimeout(() => { flushTimer = null void flushArchiveQueue().catch((e) => logger.warn('[EventArchive] flush', e)) @@ -108,6 +134,11 @@ export function queuePersistSeenEvent(ev: Event): void { if (shouldSkipArchiving(ev)) return const id = /^[0-9a-f]{64}$/i.test(ev.id) ? ev.id.toLowerCase() : ev.id if (!/^[0-9a-f]{64}$/.test(id)) return + while (pending.size >= MAX_PENDING_ARCHIVE) { + const first = pending.keys().next().value + if (first === undefined) break + pending.delete(first) + } pending.set(id, ev) scheduleFlush() } diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 29da5a3d..d5e1bcca 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -258,6 +258,13 @@ class IndexedDbService { private initPromise: Promise | null = null /** Browser timer id (DOM `setTimeout` returns a number). */ private cleanupTimer: number | null = null + /** + * Short-lived negative cache for {@link isTombstoned}: most keys are not tombstoned; ingest can probe + * the same coordinate many times. TTL avoids stale reads if another tab tombstones (eventually). + */ + private tombstoneNotUntilMs = new Map() + private static readonly TOMBSTONE_NOT_CACHE_TTL_MS = 45_000 + private static readonly TOMBSTONE_NOT_CACHE_MAX = 4096 /** First TTL sweep after DB open (profile / relay list rows). */ private static readonly CLEANUP_INITIAL_DELAY_MS = 60 * 1000 @@ -2882,11 +2889,33 @@ class IndexedDbService { ) } + private rememberTombstoneNot(key: string): void { + const until = Date.now() + IndexedDbService.TOMBSTONE_NOT_CACHE_TTL_MS + this.tombstoneNotUntilMs.set(key, until) + while (this.tombstoneNotUntilMs.size > IndexedDbService.TOMBSTONE_NOT_CACHE_MAX) { + const first = this.tombstoneNotUntilMs.keys().next().value + if (first === undefined) break + this.tombstoneNotUntilMs.delete(first) + } + } + + private invalidateTombstoneNotCache(key: string): void { + this.tombstoneNotUntilMs.delete(key) + } + /** * Check if an event is tombstoned (deleted) */ async isTombstoned(key: string): Promise { await this.initPromise + const now = Date.now() + const until = this.tombstoneNotUntilMs.get(key) + if (until !== undefined && now < until) { + return false + } + if (until !== undefined && now >= until) { + this.tombstoneNotUntilMs.delete(key) + } return new Promise((resolve) => { if (!this.db) { return resolve(false) @@ -2901,7 +2930,13 @@ class IndexedDbService { request.onsuccess = () => { const row = request.result as TValue | undefined transaction.commit() - resolve(row !== undefined && row.value !== null) + const tombstoned = row !== undefined && row.value !== null + if (!tombstoned) { + this.rememberTombstoneNot(key) + } else { + this.invalidateTombstoneNotCache(key) + } + resolve(tombstoned) } request.onerror = () => {