Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
538a9d3dfd
  1. 8
      src/components/NoteList/index.tsx
  2. 32
      src/components/NoteStats/index.tsx
  3. 16
      src/components/RelayIcon/index.tsx
  4. 4
      src/components/ReplyNoteList/index.tsx
  5. 10
      src/components/RssUrlThreadStatsBar/index.tsx
  6. 24
      src/constants.ts
  7. 247
      src/hooks/useFetchProfile.tsx
  8. 10
      src/hooks/useNoteStatsRelayHints.ts
  9. 15
      src/hooks/useRssUrlThreadQueryRelays.ts
  10. 23
      src/lib/logger.ts
  11. 73
      src/lib/relay-icon-source.ts
  12. 57
      src/services/client-events.service.ts
  13. 31
      src/services/client.service.ts
  14. 61
      src/services/event-archive.service.ts
  15. 37
      src/services/indexed-db.service.ts

8
src/components/NoteList/index.tsx

@ -944,7 +944,7 @@ const NoteList = forwardRef( @@ -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( @@ -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( @@ -1564,7 +1566,7 @@ const NoteList = forwardRef(
}
}
if (!pendingChanged) return prev
return { ...prev, pending, version: prev.version + 1 }
return { ...prev, pending }
})
void (async () => {

32
src/components/NoteStats/index.tsx

@ -8,9 +8,8 @@ import noteStatsService from '@/services/note-stats.service' @@ -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({ @@ -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({ @@ -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({ @@ -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 (

16
src/components/RelayIcon/index.tsx

@ -1,5 +1,6 @@ @@ -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({ @@ -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({ @@ -56,11 +63,16 @@ export default function RelayIcon({
return undefined
}, [url, relayInfo])
const fallbackColors = useMemo(() => relayUrlFingerprintColors(url), [url])
return (
<Avatar className={cn('w-6 h-6', className)}>
{iconUrl && <AvatarImage src={iconUrl} className="object-cover object-center" />}
<AvatarFallback>
<Server size={iconSize} />
<AvatarFallback
className="bg-transparent"
style={{ backgroundColor: fallbackColors.background, color: fallbackColors.color }}
>
<Server size={iconSize} className="opacity-95" aria-hidden />
</AvatarFallback>
</Avatar>
)

4
src/components/ReplyNoteList/index.tsx

@ -722,7 +722,7 @@ function ReplyNoteList({ @@ -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({ @@ -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<string | undefined>(undefined)
const [until, setUntil] = useState<number | undefined>(undefined)

10
src/components/RssUrlThreadStatsBar/index.tsx

@ -6,7 +6,7 @@ import { useRssUrlThreadQueryRelays } from '@/hooks/useRssUrlThreadQueryRelays' @@ -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({ @@ -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({ @@ -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))

24
src/constants.ts

@ -409,6 +409,7 @@ export const FAST_READ_RELAY_URLS = [ @@ -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 = [ @@ -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 = [ @@ -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 = [ @@ -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 { @@ -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<number> = new Set<number>([
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<number> = new Set<number>()
/** Short-form portrait-style bucket (kind 22 or 34236). */
export function isNip71ShortVideoKind(kind: number): boolean {

247
src/hooks/useFetchProfile.tsx

@ -9,7 +9,7 @@ import { eventService, replaceableEventService } from '@/services/client.service @@ -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<string>() @@ -58,14 +58,6 @@ const globalFetchingPubkeys = new Set<string>()
const globalFetchCooldowns = new Map<string, number>() // 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,20 +75,38 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -83,20 +75,38 @@ export function useFetchProfile(id?: string, skipCache = false) {
const effectRunCountRef = useRef<Map<string, number>>(new Map()) // Track how many times effect has run for each pubkey (safety guard against infinite loops)
const initializedPubkeysRef = useRef<Set<string>>(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<TProfile | null> => {
// 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
}
@ -117,10 +127,6 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -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) { @@ -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) { @@ -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) { @@ -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) { @@ -213,14 +210,9 @@ export function useFetchProfile(id?: string, skipCache = false) {
let idbEarlyP: Promise<TProfile | null> | 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) { @@ -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) { @@ -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) { @@ -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) { @@ -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) { @@ -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)
@ -461,9 +410,6 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -461,9 +410,6 @@ export function useFetchProfile(id?: string, skipCache = false) {
}
// 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,36 +423,23 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -477,36 +423,23 @@ 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)
}
@ -514,55 +447,38 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -514,55 +447,38 @@ export function useFetchProfile(id?: string, skipCache = false) {
}
}
// 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)
@ -571,13 +487,7 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -571,13 +487,7 @@ export function useFetchProfile(id?: string, skipCache = false) {
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)
@ -586,10 +496,7 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -586,10 +496,7 @@ export function useFetchProfile(id?: string, skipCache = false) {
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
@ -598,84 +505,43 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -598,84 +505,43 @@ export function useFetchProfile(id?: string, skipCache = false) {
}
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
setError(null)
// 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
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
@ -694,27 +560,19 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -694,27 +560,19 @@ export function useFetchProfile(id?: string, skipCache = false) {
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) { @@ -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) { @@ -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

10
src/hooks/useNoteStatsRelayHints.ts

@ -6,13 +6,19 @@ import { useMemo } from 'react' @@ -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])
}

15
src/hooks/useRssUrlThreadQueryRelays.ts

@ -9,10 +9,15 @@ import { useNoteStatsRelayHints } from './useNoteStatsRelayHints' @@ -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<string[]>([])
const [baseKey, setBaseKey] = useState('')
@ -35,6 +40,8 @@ export function useRssUrlThreadQueryRelays(): { relayUrls: string[]; key: string @@ -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])
}

23
src/lib/logger.ts

@ -2,11 +2,12 @@ @@ -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 { @@ -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'

73
src/lib/relay-icon-source.ts

@ -0,0 +1,73 @@ @@ -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%)`
}
}

57
src/services/client-events.service.ts

@ -104,6 +104,18 @@ export class EventService { @@ -104,6 +104,18 @@ export class EventService {
private sessionEventCache = new LRUCache<string, NEvent>({ max: getDefaultSessionLruMaxSync() })
/** Latest kind-0 per pubkey from {@link sessionEventCache} for batch profile short-circuit. */
private sessionMetadataByPubkey = new Map<string, NEvent>()
/** Ingest coalescing: max `created_at` already queued for durable replaceable cache (coordinate → ts). */
private ingestReplaceablePersistMaxCreatedAt = new Map<string, number>()
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<string, Set<() => void>>()
/** Waiters keyed like {@link replaceableWaiterKey} — naddr embeds have no hex id until a REQ returns. */
@ -542,7 +554,13 @@ export class EventService { @@ -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 { @@ -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) => {

31
src/services/client.service.ts

@ -155,6 +155,7 @@ import { @@ -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 { @@ -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<string, number>()
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<string, number>()
/** Dedupe {@link addUsernameToIndex} for the same kind-0 id (multi-relay / re-REQ spam). */
private metadataIngestIndexDedupe = new LRUCache<string, true>({ max: 12_000 })
constructor() {
super()
@ -346,8 +354,18 @@ class ClientService extends EventTarget { @@ -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 { @@ -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)

61
src/services/event-archive.service.ts

@ -20,6 +20,12 @@ const CORE_FEED_KINDS = new Set<number>([ @@ -20,6 +20,12 @@ const CORE_FEED_KINDS = new Set<number>([
let footprint: { count: number; bytes: number } | null = null
const pending = new Map<string, Event>()
let flushTimer: ReturnType<typeof setTimeout> | 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<void> { @@ -77,26 +83,46 @@ async function trimArchiveIfNeeded(): Promise<void> {
}
async function flushArchiveQueue(): Promise<void> {
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 { @@ -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()
}

37
src/services/indexed-db.service.ts

@ -258,6 +258,13 @@ class IndexedDbService { @@ -258,6 +258,13 @@ class IndexedDbService {
private initPromise: Promise<void> | 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<string, number>()
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 { @@ -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<boolean> {
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 { @@ -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 = () => {

Loading…
Cancel
Save