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. 285
      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(
pendingPubkeys: feedProfileBatch.pending, pendingPubkeys: feedProfileBatch.pending,
version: feedProfileBatch.version version: feedProfileBatch.version
}), }),
[feedProfileBatch] [feedProfileBatch.profiles, feedProfileBatch.pending, feedProfileBatch.version]
) )
// Memoize subRequests serialization to avoid expensive JSON.stringify on every render // Memoize subRequests serialization to avoid expensive JSON.stringify on every render
@ -1078,7 +1078,9 @@ const NoteList = forwardRef(
} }
} }
if (!changed) return prev 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]) }, [timelineEventsForFilter, newEvents])
@ -1564,7 +1566,7 @@ const NoteList = forwardRef(
} }
} }
if (!pendingChanged) return prev if (!pendingChanged) return prev
return { ...prev, pending, version: prev.version + 1 } return { ...prev, pending }
}) })
void (async () => { void (async () => {

32
src/components/NoteStats/index.tsx

@ -8,9 +8,8 @@ import noteStatsService from '@/services/note-stats.service'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { useReplyUnderDiscussionRoot } from '@/hooks/useReplyUnderDiscussionRoot' import { useReplyUnderDiscussionRoot } from '@/hooks/useReplyUnderDiscussionRoot'
import { shouldHideInteractions } from '@/lib/event-filtering' import { shouldHideInteractions } from '@/lib/event-filtering'
import logger from '@/lib/logger'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import BookmarkButton from '../BookmarkButton' import BookmarkButton from '../BookmarkButton'
import { LikeButtonWithStats } from './LikeButton' import { LikeButtonWithStats } from './LikeButton'
import { LikesWithStats } from './Likes' import { LikesWithStats } from './Likes'
@ -40,8 +39,8 @@ export default function NoteStats({
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { pubkey } = useNostr() const { pubkey } = useNostr()
const noteStats = useNoteStatsById(event.id) const noteStats = useNoteStatsById(event.id)
const { relays: hintRelays, key: hintRelaysKey } = useNoteStatsRelayHints() const { relays: hintRelays, currentRelaysKey } = useNoteStatsRelayHints()
const { relayUrls: rssUrlThreadRelays, key: rssUrlThreadRelaysKey } = useRssUrlThreadQueryRelays() const { relayUrls: rssUrlThreadRelays, relayMergeTier } = useRssUrlThreadQueryRelays()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
// Hide boost button for discussion events and replies to discussions // 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 const isRssArticleRoot = event.kind === ExtendedKind.RSS_THREAD_ROOT
/** Match {@link RssUrlThreadStatsBar}: inbox/favorites/fast-read merge — plain hints miss many #i indexers. */ /** Match {@link RssUrlThreadStatsBar}: inbox/favorites/fast-read merge — plain hints miss many #i indexers. */
const statsRelays = isRssArticleRoot ? rssUrlThreadRelays : hintRelays 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 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. */ /** 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(() => { useEffect(() => {
if (!fetchIfNotExisting) return if (!fetchIfNotExisting) return
logger.debug('[NoteStats] UI: scheduling fetchNoteStats', {
eventId: `${event.id.slice(0, 12)}`,
kind: event.kind,
hintRelayCount: statsRelays.length
})
setLoading(true) setLoading(true)
noteStatsService noteStatsService
.fetchNoteStats(event, pubkey, statsRelays, { foreground: foregroundStats }) .fetchNoteStats(event, pubkey, statsRelaysRef.current, { foreground: foregroundStats })
.finally(() => setLoading(false)) .finally(() => setLoading(false))
// Intentionally omit `event` object: parent feeds often pass new references each render; // Intentionally omit `event` object: parent feeds often pass new references each render;
// id/sig/kind/created_at identify the note for refetch boundaries. // 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) { if (isSmallScreen) {
return ( return (

16
src/components/RelayIcon/index.tsx

@ -1,5 +1,6 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { useFetchRelayInfo } from '@/hooks' import { useFetchRelayInfo } from '@/hooks'
import { getRelayIconOverrideSrc, relayUrlFingerprintColors } from '@/lib/relay-icon-source'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { Server } from 'lucide-react' import { Server } from 'lucide-react'
@ -45,6 +46,12 @@ export default function RelayIcon({
const iconUrl = useMemo(() => { const iconUrl = useMemo(() => {
if (!url) return undefined 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 // Prefer the NIP-11 icon field
const rawIcon = relayInfo?.icon && typeof relayInfo.icon === 'string' ? relayInfo.icon : undefined const rawIcon = relayInfo?.icon && typeof relayInfo.icon === 'string' ? relayInfo.icon : undefined
const nip11Icon = rawIcon ? resolveRelayImageUrl(rawIcon, url) : undefined const nip11Icon = rawIcon ? resolveRelayImageUrl(rawIcon, url) : undefined
@ -56,11 +63,16 @@ export default function RelayIcon({
return undefined return undefined
}, [url, relayInfo]) }, [url, relayInfo])
const fallbackColors = useMemo(() => relayUrlFingerprintColors(url), [url])
return ( return (
<Avatar className={cn('w-6 h-6', className)}> <Avatar className={cn('w-6 h-6', className)}>
{iconUrl && <AvatarImage src={iconUrl} className="object-cover object-center" />} {iconUrl && <AvatarImage src={iconUrl} className="object-cover object-center" />}
<AvatarFallback> <AvatarFallback
<Server size={iconSize} /> className="bg-transparent"
style={{ backgroundColor: fallbackColors.background, color: fallbackColors.color }}
>
<Server size={iconSize} className="opacity-95" aria-hidden />
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
) )

4
src/components/ReplyNoteList/index.tsx

@ -722,7 +722,7 @@ function ReplyNoteList({
} }
} }
if (!changed) return prev if (!changed) return prev
return { ...prev, pending, version: prev.version + 1 } return { ...prev, pending }
}) })
void (async () => { void (async () => {
@ -769,7 +769,7 @@ function ReplyNoteList({
})() })()
}, THREAD_PROFILE_BATCH_DEBOUNCE_MS) }, THREAD_PROFILE_BATCH_DEBOUNCE_MS)
return () => window.clearTimeout(handle) return () => window.clearTimeout(handle)
}, [event, mergedFeed, parentNoteFeed?.version]) }, [event, mergedFeed, parentNoteFeed?.profiles, parentNoteFeed?.pendingPubkeys])
const [timelineKey] = useState<string | undefined>(undefined) const [timelineKey] = useState<string | undefined>(undefined)
const [until, setUntil] = useState<number | undefined>(undefined) const [until, setUntil] = useState<number | undefined>(undefined)

10
src/components/RssUrlThreadStatsBar/index.tsx

@ -6,7 +6,7 @@ import { useRssUrlThreadQueryRelays } from '@/hooks/useRssUrlThreadQueryRelays'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Bookmark, Highlighter, MessageCircle, ThumbsUp } from 'lucide-react' import { Bookmark, Highlighter, MessageCircle, ThumbsUp } from 'lucide-react'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
/** Compact reply / reaction / bookmark / highlight counts for RSS + Web URL threads. */ /** Compact reply / reaction / bookmark / highlight counts for RSS + Web URL threads. */
export default function RssUrlThreadStatsBar({ export default function RssUrlThreadStatsBar({
@ -18,7 +18,9 @@ export default function RssUrlThreadStatsBar({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey } = useNostr() 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 { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const noteStats = useNoteStatsById(event.id) const noteStats = useNoteStatsById(event.id)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@ -26,9 +28,9 @@ export default function RssUrlThreadStatsBar({
useEffect(() => { useEffect(() => {
setLoading(true) setLoading(true)
noteStatsService noteStatsService
.fetchNoteStats(event, pubkey, statsRelays, { foreground: true }) .fetchNoteStats(event, pubkey, statsRelaysRef.current, { foreground: true })
.finally(() => setLoading(false)) .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)) const fmt = (n: number) => (n >= 100 ? '99+' : String(n))

24
src/constants.ts

@ -409,6 +409,7 @@ export const FAST_READ_RELAY_URLS = [
'wss://nostr21.com', 'wss://nostr21.com',
'wss://thecitadel.nostr1.com', 'wss://thecitadel.nostr1.com',
'wss://aggr.nostr.land', 'wss://aggr.nostr.land',
'wss://primus.nostr1.com',
] ]
// Optimized relay list for write operations (no aggregator since it's read-only) // 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://orly-relay.imwald.eu',
'wss://aggr.nostr.land', 'wss://aggr.nostr.land',
'wss://thecitadel.nostr1.com', 'wss://thecitadel.nostr1.com',
'wss://relay.primal.net',
'wss://relay.damus.io',
'wss://nos.lol', 'wss://nos.lol',
'wss://nostr.mom', 'wss://nostr.mom',
'wss://relay.noswhere.com', 'wss://relay.noswhere.com',
@ -449,11 +448,8 @@ export const SEARCHABLE_RELAY_URLS = [
] ]
export const PROFILE_RELAY_URLS = [ export const PROFILE_RELAY_URLS = [
'wss://nos.lol',
'wss://relay.damus.io',
'wss://profiles.nostr1.com', 'wss://profiles.nostr1.com',
'wss://purplepag.es', 'wss://purplepag.es'
'wss://thecitadel.nostr1.com'
] ]
export const FOLLOWS_HISTORY_RELAY_URLS = [ 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. // 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 = { export const ExtendedKind = {
PICTURE: 20, 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 * 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. * 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. * 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>([ 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
])
/** Short-form portrait-style bucket (kind 22 or 34236). */ /** Short-form portrait-style bucket (kind 22 or 34236). */
export function isNip71ShortVideoKind(kind: number): boolean { export function isNip71ShortVideoKind(kind: number): boolean {

285
src/hooks/useFetchProfile.tsx

@ -9,7 +9,7 @@ import { eventService, replaceableEventService } from '@/services/client.service
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import { TProfile } from '@/types' import { TProfile } from '@/types'
import { kinds } from 'nostr-tools' 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' import logger from '@/lib/logger'
function tryHydrateProfileFromSessionOnly(pubkey: string, skipCache: boolean): TProfile | null { function tryHydrateProfileFromSessionOnly(pubkey: string, skipCache: boolean): TProfile | null {
@ -58,14 +58,6 @@ const globalFetchingPubkeys = new Set<string>()
const globalFetchCooldowns = new Map<string, number>() // pubkey -> timestamp when cooldown expires const globalFetchCooldowns = new Map<string, number>() // pubkey -> timestamp when cooldown expires
export function useFetchProfile(id?: string, skipCache = false) { 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 nostr = useNostrOptional()
const currentAccountProfile = nostr?.profile ?? null const currentAccountProfile = nostr?.profile ?? null
const noteFeed = useNoteFeedProfileContext() const noteFeed = useNoteFeedProfileContext()
@ -83,23 +75,41 @@ 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 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 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 // Function to check for profile updates with GLOBAL deduplication
// fetchProfileEvent already checks: 1) IndexedDB, 2) network (with author's relays) // fetchProfileEvent already checks: 1) IndexedDB, 2) network (with author's relays)
// Memoize to prevent recreation on every render // Memoize to prevent recreation on every render
const checkProfile = useCallback(async (pubkey: string, cancelled: { current: boolean }): Promise<TProfile | null> => { 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) { if (cancelled.current) {
logger.debug('[useFetchProfile] Already cancelled, returning null')
return null return null
} }
// CRITICAL: Check cooldown period first to prevent cascade of duplicate fetches after timeout. // 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 // 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). // 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) effectRunCountRef.current.delete(pubkey)
return cachedDuringCooldown return cachedDuringCooldown
} }
logger.debug('[useFetchProfile] In cooldown period after timeout, skipping fetch', {
pubkey: pubkey.substring(0, 8),
remainingMs: cooldownExpiry - Date.now()
})
return null return null
} }
// Clean up expired cooldowns // Clean up expired cooldowns
@ -133,9 +139,6 @@ export function useFetchProfile(id?: string, skipCache = false) {
// Add timeout protection to prevent infinite waits // Add timeout protection to prevent infinite waits
const existingPromise = globalFetchPromises.get(pubkey) const existingPromise = globalFetchPromises.get(pubkey)
if (existingPromise) { if (existingPromise) {
logger.debug('[useFetchProfile] Reusing existing fetch promise', {
pubkey: pubkey.substring(0, 8)
})
try { try {
// Await the shared promise only — it already races fetchProfileEvent with // Await the shared promise only — it already races fetchProfileEvent with
// PROFILE_FETCH_PROMISE_TIMEOUT_MS. Per-waiter Promise.race timers caused N identical // 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) { } catch (err) {
// If the existing promise failed, we'll try again below // If the existing promise failed, we'll try again below
logger.debug('[useFetchProfile] Existing promise failed, will retry', { void err
pubkey: pubkey.substring(0, 8),
error: err instanceof Error ? err.message : String(err)
})
// Clear the failed promise so we can start fresh // Clear the failed promise so we can start fresh
globalFetchPromises.delete(pubkey) globalFetchPromises.delete(pubkey)
globalFetchingPubkeys.delete(pubkey) globalFetchingPubkeys.delete(pubkey)
@ -196,10 +196,7 @@ export function useFetchProfile(id?: string, skipCache = false) {
return null return null
} }
} catch (err) { } catch (err) {
logger.debug('[useFetchProfile] Retry promise failed', { void err
pubkey: pubkey.substring(0, 8),
error: err instanceof Error ? err.message : String(err)
})
// Clear the failed promise // Clear the failed promise
globalFetchPromises.delete(pubkey) globalFetchPromises.delete(pubkey)
globalFetchingPubkeys.delete(pubkey) globalFetchingPubkeys.delete(pubkey)
@ -213,14 +210,9 @@ export function useFetchProfile(id?: string, skipCache = false) {
let idbEarlyP: Promise<TProfile | null> | null = null let idbEarlyP: Promise<TProfile | null> | null = null
try { try {
globalFetchingPubkeys.add(pubkey) globalFetchingPubkeys.add(pubkey)
const startTime = Date.now()
const quick = tryHydrateProfileFromSessionOnly(pubkey, skipCache) const quick = tryHydrateProfileFromSessionOnly(pubkey, skipCache)
if (quick) { if (quick) {
logger.debug('[useFetchProfile] Profile from session/IndexedDB (fast path)', {
pubkey: pubkey.substring(0, 8),
hasAvatar: !!quick.avatar
})
return quick return quick
} }
@ -243,68 +235,30 @@ export function useFetchProfile(id?: string, skipCache = false) {
replaceableEventService.fetchProfileEvent(pubkey, skipCache), replaceableEventService.fetchProfileEvent(pubkey, skipCache),
timeoutPromise 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) { if (profileEvent) {
// getProfileFromEvent always returns a profile object (with fallback username) // getProfileFromEvent always returns a profile object (with fallback username)
const newProfile = getProfileFromEvent(profileEvent) 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 // CRITICAL: Always return the profile from this shared promise, even when the
// originating hook cleaned up (list virtualization, Strict Mode, feed switch). // originating hook cleaned up (list virtualization, Strict Mode, feed switch).
// Returning null here made every waiter treat the result like a timeout, applied // 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). // cooldowns, and left avatars empty (especially busy feeds e.g. all-favorites).
return newProfile 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 = const afterMiss =
(idbEarlyP != null ? await idbEarlyP : null) ?? tryHydrateProfileFromSessionOnly(pubkey, skipCache) (idbEarlyP != null ? await idbEarlyP : null) ?? tryHydrateProfileFromSessionOnly(pubkey, skipCache)
if (afterMiss) { if (afterMiss) {
logger.debug('[useFetchProfile] Profile from session/IndexedDB after network miss', {
pubkey: pubkey.substring(0, 8),
hasAvatar: !!afterMiss.avatar
})
return afterMiss return afterMiss
} }
return null return null
} catch (err) { } catch (err) {
const isTimeout = err instanceof Error && err.message.includes('timeout') const isTimeout = err instanceof Error && err.message.includes('timeout')
if (isTimeout) { 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 // Set cooldown period after timeout to prevent cascade of duplicate fetches
globalFetchCooldowns.set(pubkey, Date.now() + 10000) // 10 second cooldown globalFetchCooldowns.set(pubkey, Date.now() + 10000) // 10 second cooldown
const fallback = const fallback =
tryHydrateProfileFromSessionOnly(pubkey, skipCache) ?? tryHydrateProfileFromSessionOnly(pubkey, skipCache) ??
(idbEarlyP != null ? await idbEarlyP : null) (idbEarlyP != null ? await idbEarlyP : null)
if (fallback) { if (fallback) {
logger.debug('[useFetchProfile] Profile from session/IndexedDB after fetch timeout', {
pubkey: pubkey.substring(0, 8),
hasAvatar: !!fallback.avatar
})
return fallback return fallback
} }
// Return null on timeout instead of throwing - allows UI to show 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]) }, [skipCache])
useEffect(() => { useEffect(() => {
// Early exit when id is missing (e.g. truncated or undefined) - use debug to avoid console spam
if (!id) { if (!id) {
logger.debug('[useFetchProfile] No id provided')
setProfile(null) setProfile(null)
setPubkey(null) setPubkey(null)
setIsFetching(false) setIsFetching(false)
@ -377,14 +329,12 @@ export function useFetchProfile(id?: string, skipCache = false) {
return return
} }
// Extract pubkey early to check if id has changed
const extractedPubkey = userIdToPubkey(id) const extractedPubkey = userIdToPubkey(id)
// Note feeds: profiles are batch-fetched in NoteList — skip per-row relay storms while pending. // 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) { 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) { if (fromBatch && !fromBatch.batchPlaceholder) {
setProfile(fromBatch) setProfile(fromBatch)
setPubkey(extractedPubkey) setPubkey(extractedPubkey)
@ -402,9 +352,8 @@ export function useFetchProfile(id?: string, skipCache = false) {
setIsFetching(false) setIsFetching(false)
setError(null) setError(null)
} }
if (noteFeed.pendingPubkeys.has(extractedPubkey)) { if (noteFeed.pendingPubkeys.has(pkL)) {
const pkLower = extractedPubkey.toLowerCase() const sessionEv = eventService.getSessionMetadataForPubkey(pkL)
const sessionEv = eventService.getSessionMetadataForPubkey(pkLower)
if (sessionEv) { if (sessionEv) {
const quick = getProfileFromEvent(sessionEv) const quick = getProfileFromEvent(sessionEv)
setProfile(quick) setProfile(quick)
@ -420,7 +369,7 @@ export function useFetchProfile(id?: string, skipCache = false) {
setIsFetching(false) setIsFetching(false)
setError(null) setError(null)
const pendingCancelled = { current: false } const pendingCancelled = { current: false }
void tryHydrateProfileFromLocalCaches(pkLower, false).then((quick) => { void tryHydrateProfileFromLocalCaches(pkL, false).then((quick) => {
if (pendingCancelled.current || !quick) return if (pendingCancelled.current || !quick) return
setProfile(quick) setProfile(quick)
setIsFetching(false) setIsFetching(false)
@ -459,11 +408,8 @@ export function useFetchProfile(id?: string, skipCache = false) {
return return
} }
} }
// Skip only when this pubkey already has an in-flight fetch (global dedupe + local flag). // 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 (extractedPubkey) {
if (processingPubkeyRef.current === extractedPubkey) { if (processingPubkeyRef.current === extractedPubkey) {
const sharedPromise = globalFetchPromises.get(extractedPubkey) const sharedPromise = globalFetchPromises.get(extractedPubkey)
@ -477,92 +423,62 @@ export function useFetchProfile(id?: string, skipCache = false) {
processingPubkeyRef.current = extractedPubkey 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) { if (extractedPubkey && profile && profile.pubkey === extractedPubkey && !profile.batchPlaceholder) {
// Ensure processingPubkeyRef is set to prevent re-fetch
if (processingPubkeyRef.current !== extractedPubkey) { if (processingPubkeyRef.current !== extractedPubkey) {
processingPubkeyRef.current = extractedPubkey processingPubkeyRef.current = extractedPubkey
} }
// Mark as initialized
initializedPubkeysRef.current.add(extractedPubkey) initializedPubkeysRef.current.add(extractedPubkey)
// Ensure fetching is false (but don't call setState if already false to avoid re-renders)
if (isFetching) { if (isFetching) {
setIsFetching(false) setIsFetching(false)
} }
// Clear run count since we have the profile
effectRunCountRef.current.delete(extractedPubkey) effectRunCountRef.current.delete(extractedPubkey)
return 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 (extractedPubkey && initializedPubkeysRef.current.has(extractedPubkey) && !profile) {
if (skipCache) { if (skipCache) {
// User wants to refresh - clear initialized flag to allow fresh fetch
initializedPubkeysRef.current.delete(extractedPubkey) initializedPubkeysRef.current.delete(extractedPubkey)
// Also clear run count to allow fresh attempt
effectRunCountRef.current.delete(extractedPubkey) effectRunCountRef.current.delete(extractedPubkey)
} else { } else {
// Already tried and failed - don't retry unless explicitly requested
// Ensure fetching is false
if (isFetching) { if (isFetching) {
setIsFetching(false) setIsFetching(false)
} }
return 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) { if (extractedPubkey) {
const runCount = effectRunCountRef.current.get(extractedPubkey) || 0 const runCount = effectRunCountRef.current.get(extractedPubkey) || 0
const pkLower = extractedPubkey.toLowerCase() const pkLower = extractedPubkey.toLowerCase()
const feedBatchPlaceholder = const feedBatchPlaceholder =
noteFeed?.profiles.get(pkLower)?.batchPlaceholder === true || noteFeed?.profiles.get(pkLower)?.batchPlaceholder === true ||
noteFeed?.profiles.get(extractedPubkey)?.batchPlaceholder === true noteFeed?.profiles.get(extractedPubkey)?.batchPlaceholder === true
const maxRunsBeforeCircuitBreak = feedBatchPlaceholder ? 12 : 3 const maxRunsBeforeCircuitBreak = feedBatchPlaceholder ? 6 : 3
if (runCount >= maxRunsBeforeCircuitBreak) { if (runCount >= maxRunsBeforeCircuitBreak) {
logger.warn('[useFetchProfile] Too many effect runs for this pubkey, preventing infinite loop', { logger.warn('[useFetchProfile] Too many effect runs for this pubkey, preventing infinite loop', {
extractedPubkey, extractedPubkey,
runCount runCount
}) })
// Clear the run count after a delay to allow retries later
setTimeout(() => { setTimeout(() => {
effectRunCountRef.current.delete(extractedPubkey) effectRunCountRef.current.delete(extractedPubkey)
}, 30000) // Clear after 30 seconds }, 30000)
processingPubkeyRef.current = null processingPubkeyRef.current = null
if (isFetching) setIsFetching(false) if (isFetching) setIsFetching(false)
return return
} }
// Only increment if we're actually going to process
effectRunCountRef.current.set(extractedPubkey, runCount + 1) 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) { if (extractedPubkey && processingPubkeyRef.current && processingPubkeyRef.current !== extractedPubkey) {
const oldPubkey = processingPubkeyRef.current const oldPubkey = processingPubkeyRef.current
// Clear run count and initialized status for old pubkey before clearing ref
effectRunCountRef.current.delete(oldPubkey) effectRunCountRef.current.delete(oldPubkey)
initializedPubkeysRef.current.delete(oldPubkey) initializedPubkeysRef.current.delete(oldPubkey)
processingPubkeyRef.current = null processingPubkeyRef.current = null
} }
const cancelled = { current: false } 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) { if (!extractedPubkey) {
logger.debug('[useFetchProfile] Invalid id - could not extract pubkey', {
idLength: id.length,
prefix: id.slice(0, 16)
})
setProfile(null) setProfile(null)
setPubkey(null) setPubkey(null)
setIsFetching(false) setIsFetching(false)
@ -570,14 +486,8 @@ export function useFetchProfile(id?: string, skipCache = false) {
processingPubkeyRef.current = null processingPubkeyRef.current = null
return return
} }
// Validate pubkey format
if (extractedPubkey.length !== 64 || !/^[0-9a-f]{64}$/.test(extractedPubkey)) { 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) setProfile(null)
setPubkey(null) setPubkey(null)
setIsFetching(false) setIsFetching(false)
@ -585,104 +495,60 @@ export function useFetchProfile(id?: string, skipCache = false) {
processingPubkeyRef.current = null processingPubkeyRef.current = null
return 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) { if (processingPubkeyRef.current !== extractedPubkey) {
// This should never happen now, but keep as safety check
logger.warn('[useFetchProfile] processingPubkeyRef mismatch (safety check)', { logger.warn('[useFetchProfile] processingPubkeyRef mismatch (safety check)', {
extractedPubkey, extractedPubkey,
processingPubkey: processingPubkeyRef.current processingPubkey: processingPubkeyRef.current
}) })
processingPubkeyRef.current = extractedPubkey processingPubkeyRef.current = extractedPubkey
} }
if (profile && profile.pubkey === extractedPubkey && !profile.batchPlaceholder) { if (profile && profile.pubkey === extractedPubkey && !profile.batchPlaceholder) {
logger.debug('[useFetchProfile] Already have profile for this pubkey (safety check)', {
extractedPubkey
})
setIsFetching(false) setIsFetching(false)
effectRunCountRef.current.delete(extractedPubkey) effectRunCountRef.current.delete(extractedPubkey)
return 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) { if (pubkey !== extractedPubkey) {
setPubkey(extractedPubkey) setPubkey(extractedPubkey)
} }
// CRITICAL: Reduce logging during rapid scrolling
logger.debug('[useFetchProfile] Starting profile fetch', {
pubkey: extractedPubkey?.substring(0, 8) || 'null'
})
const run = async () => { const run = async () => {
try { try {
setIsFetching(true) setIsFetching(true)
setError(null) 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) 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) { if (cancelled.current) {
logger.debug('[useFetchProfile] Cancelled after checkProfile, cleaning up')
setIsFetching(false) setIsFetching(false)
return return
} }
if (profile) { if (profile) {
// Profile found (from cache or network), we're done
// checkProfile already set the profile state, so we're done
return 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) 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 (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 let checkCount = 0
const maxChecks = 3 // Reduced from 4 to further reduce load const maxChecks = 3
const startTime = Date.now() const startTime = Date.now()
const maxTotalTime = 20000 // 20 seconds total timeout (3 checks * ~5s + buffer) const maxTotalTime = 20000
checkIntervalRef.current = setInterval(async () => { checkIntervalRef.current = setInterval(async () => {
// CRITICAL: Check for timeout to prevent infinite retries
const elapsed = Date.now() - startTime const elapsed = Date.now() - startTime
if (elapsed > maxTotalTime) { if (elapsed > maxTotalTime) {
logger.warn('[useFetchProfile] Retry interval timeout reached, stopping retries', {
pubkey: extractedPubkey?.substring(0, 8),
elapsed: `${elapsed}ms`
})
if (checkIntervalRef.current) { if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current) clearInterval(checkIntervalRef.current)
checkIntervalRef.current = null checkIntervalRef.current = null
} }
return return
} }
if (cancelled.current || checkCount >= maxChecks) { if (cancelled.current || checkCount >= maxChecks) {
if (checkIntervalRef.current) { if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current) clearInterval(checkIntervalRef.current)
@ -690,31 +556,23 @@ export function useFetchProfile(id?: string, skipCache = false) {
} }
return return
} }
checkCount++ checkCount++
const profile = await checkProfile(extractedPubkey, cancelled) const profile = await checkProfile(extractedPubkey, cancelled)
if (profile || cancelled.current) { if (profile || cancelled.current) {
// Profile found or cancelled, stop checking
if (checkIntervalRef.current) { if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current) clearInterval(checkIntervalRef.current)
checkIntervalRef.current = null checkIntervalRef.current = null
} }
} }
}, 10000) // Increased from 5 seconds to 10 seconds to reduce load }, 10000)
} 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
})
} }
} catch (err) { } catch (err) {
logger.error('[useFetchProfile] run() error', { logger.error('[useFetchProfile] run() error', {
pubkey: extractedPubkey, pubkey: extractedPubkey,
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined stack: err instanceof Error ? err.stack : undefined
}) })
if (!cancelled.current) { if (!cancelled.current) {
setError(err as Error) setError(err as Error)
setIsFetching(false) 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) => { run().catch((err) => {
logger.error('[useFetchProfile] Unhandled error in run()', { logger.error('[useFetchProfile] Unhandled error in run()', {
pubkey: extractedPubkey, pubkey: extractedPubkey,
@ -734,26 +589,18 @@ export function useFetchProfile(id?: string, skipCache = false) {
}) })
return () => { return () => {
cancelled.current = true 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) { if (processingPubkeyRef.current === extractedPubkey) {
processingPubkeyRef.current = null 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) { if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current) clearInterval(checkIntervalRef.current)
checkIntervalRef.current = null checkIntervalRef.current = null
} }
// Clear run count and initialized status on cleanup to allow fresh fetches if component remounts
if (extractedPubkey) { if (extractedPubkey) {
effectRunCountRef.current.delete(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, feedProfileSyncKey, checkProfile])
}, [id, skipCache, noteFeed?.version]) // checkProfile is memoized; noteFeed.version hydrates batch profiles
useEffect(() => { useEffect(() => {
const acc = currentAccountProfile const acc = currentAccountProfile

10
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 * Relays to hint for note-stats REQ construction: user favorites plus any current relays
* (e.g. single-relay feed), deduped. * (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 { favoriteRelays } = useFavoriteRelays()
const currentRelayUrls = useCurrentRelayUrlsOptional() const currentRelayUrls = useCurrentRelayUrlsOptional()
return useMemo(() => { return useMemo(() => {
const relays = [...new Set([...(favoriteRelays ?? []), ...currentRelayUrls])] const relays = [...new Set([...(favoriteRelays ?? []), ...currentRelayUrls])]
const key = relays.slice().sort().join('|') const key = relays.slice().sort().join('|')
return { relays, key } const currentRelaysKey = currentRelayUrls.slice().sort().join('|')
return { relays, key, currentRelaysKey }
}, [favoriteRelays, currentRelayUrls]) }, [favoriteRelays, currentRelayUrls])
} }

15
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) * Relay set for RSS+Web article URL thread REQs: inbox/favorites/fast-read merge (same as URL discovery)
* plus {@link useNoteStatsRelayHints} (current relay context). * 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 { pubkey } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { relays: hintRelays, key: hintKey } = useNoteStatsRelayHints() const { relays: hintRelays, key: hintKey, currentRelaysKey } = useNoteStatsRelayHints()
const [baseUrls, setBaseUrls] = useState<string[]>([]) const [baseUrls, setBaseUrls] = useState<string[]>([])
const [baseKey, setBaseKey] = useState('') const [baseKey, setBaseKey] = useState('')
@ -35,6 +40,8 @@ export function useRssUrlThreadQueryRelays(): { relayUrls: string[]; key: string
return useMemo(() => { return useMemo(() => {
const merged = [...new Set([...baseUrls, ...hintRelays])] const merged = [...new Set([...baseUrls, ...hintRelays])]
const relayUrls = merged.length > 0 ? merged : [...FAST_READ_RELAY_URLS] const relayUrls = merged.length > 0 ? merged : [...FAST_READ_RELAY_URLS]
return { relayUrls, key: `${baseKey}::${hintKey}::${relayUrls.length}` } /** Coarse tier for effects: avoids refetching on every relay-url permutation once hints exist. */
}, [baseUrls, baseKey, hintRelays, hintKey]) 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 @@
* Centralized logging utility. * Centralized logging utility.
* *
* Level matrix: * Level matrix:
* dev + debug flag debug / info / warn / error (full formatted output) * dev (default) debug / info / warn / error (full formatted output; `logger.debug` on)
* dev (no flag) info / warn / error (formatted, no stack) * dev + opt-out info / warn / error (set `imwald-debug` or `jumble-debug` to `false`)
* production warn / error only (bare console no timestamp string built) * 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' type LogLevel = 'debug' | 'info' | 'warn' | 'error'
@ -19,11 +20,15 @@ class Logger {
private minLevel: LogLevel private minLevel: LogLevel
constructor() { constructor() {
this.enableDebug = const explicitOff =
this.isDev && localStorage.getItem('imwald-debug') === 'false' ||
(localStorage.getItem('imwald-debug') === 'true' || localStorage.getItem('jumble-debug') === 'false'
localStorage.getItem('jumble-debug') === 'true' || const explicitOn =
import.meta.env.VITE_DEBUG === 'true') 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. // In production only warn/error reach the console — info is noise for end-users.
this.minLevel = this.enableDebug ? 'debug' : this.isDev ? 'info' : 'warn' this.minLevel = this.enableDebug ? 'debug' : this.isDev ? 'info' : 'warn'

73
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%)`
}
}

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

@ -104,6 +104,18 @@ export class EventService {
private sessionEventCache = new LRUCache<string, NEvent>({ max: getDefaultSessionLruMaxSync() }) private sessionEventCache = new LRUCache<string, NEvent>({ max: getDefaultSessionLruMaxSync() })
/** Latest kind-0 per pubkey from {@link sessionEventCache} for batch profile short-circuit. */ /** Latest kind-0 per pubkey from {@link sessionEventCache} for batch profile short-circuit. */
private sessionMetadataByPubkey = new Map<string, NEvent>() 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). */ /** 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>>() private sessionEventWaiters = new Map<string, Set<() => void>>()
/** Waiters keyed like {@link replaceableWaiterKey} — naddr embeds have no hex id until a REQ returns. */ /** 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) && (cleanEvent.kind === kinds.RelayList || cleanEvent.kind === kinds.Contacts) &&
indexedDb.hasReplaceableEventStoreForKind(cleanEvent.kind) 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)) { if (AUTHOR_CORE_PREFETCH_ON_INGEST_KINDS.has(cleanEvent.kind)) {
const pk = cleanEvent.pubkey const pk = cleanEvent.pubkey
@ -554,25 +572,30 @@ export class EventService {
this.notifyReplaceableCoordinateWaiters(cleanEvent as NEvent) this.notifyReplaceableCoordinateWaiters(cleanEvent as NEvent)
queuePersistSeenEvent(cleanEvent as NEvent) queuePersistSeenEvent(cleanEvent as NEvent)
if (isReplaceableEvent(cleanEvent.kind) && isDocumentRelayKind(cleanEvent.kind)) { if (isReplaceableEvent(cleanEvent.kind) && isDocumentRelayKind(cleanEvent.kind)) {
// Long-form (30023), wiki, and publication replaceables — same store as profile “Articles” tab. const docCoord = normalizeReplaceableCoordinateString(getReplaceableCoordinateFromEvent(cleanEvent as NEvent))
void indexedDb.putReplaceableEvent(cleanEvent as NEvent).catch((error: unknown) => { const docPrev = this.ingestReplaceablePersistMaxCreatedAt.get(docCoord) ?? -1
const err = error instanceof Error ? error : new Error(String(error)) if (cleanEvent.created_at > docPrev) {
const q = err.name === 'QuotaExceededError' || /quota|storage/i.test(err.message) this.ingestReplaceablePersistMaxCreatedAt.set(docCoord, cleanEvent.created_at)
if (q) { this.trimIngestReplaceablePersistMap()
logger.debug('[EventService] Skipped document replaceable IndexedDB persist (storage quota)', { 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, 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)) { if (isCalendarEventKind(cleanEvent.kind)) {
void indexedDb.putCalendarEventRow(cleanEvent as NEvent).catch((error: unknown) => { void indexedDb.putCalendarEventRow(cleanEvent as NEvent).catch((error: unknown) => {

31
src/services/client.service.ts

@ -155,6 +155,7 @@ import {
import { sha256 } from '@noble/hashes/sha2' import { sha256 } from '@noble/hashes/sha2'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import FlexSearch from 'flexsearch' import FlexSearch from 'flexsearch'
import { LRUCache } from 'lru-cache'
import { import {
EventTemplate, EventTemplate,
Filter, Filter,
@ -300,7 +301,14 @@ class ClientService extends EventTarget {
private sessionPrewarmBaseCompleted = false private sessionPrewarmBaseCompleted = false
/** Per-pubkey cooldown for {@link prefetchAuthorCoreReplaceables} from feed ingest (avoid REQ storms). */ /** Per-pubkey cooldown for {@link prefetchAuthorCoreReplaceables} from feed ingest (avoid REQ storms). */
private authorCorePrefetchCooldownUntilMs = new Map<string, number>() 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() { constructor() {
super() super()
@ -346,8 +354,18 @@ class ClientService extends EventTarget {
this.eventService.addEventToCache(e) this.eventService.addEventToCache(e)
// Kind 0 from timelines/REQs was only kept in the session LRU, not in PROFILE_EVENTS or FlexSearch, // 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). // 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) 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(() => {}) void indexedDb.putReplaceableEvent(e).catch(() => {})
} }
} }
@ -3681,6 +3699,15 @@ class ClientService extends EventTarget {
return out 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) { private async addUsernameToIndex(profileEvent: NEvent) {
try { try {
const profileObj = JSON.parse(profileEvent.content) const profileObj = JSON.parse(profileEvent.content)

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

@ -20,6 +20,12 @@ const CORE_FEED_KINDS = new Set<number>([
let footprint: { count: number; bytes: number } | null = null let footprint: { count: number; bytes: number } | null = null
const pending = new Map<string, Event>() const pending = new Map<string, Event>()
let flushTimer: ReturnType<typeof setTimeout> | null = null 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 { export function invalidateArchiveFootprintCache(): void {
footprint = null footprint = null
@ -77,26 +83,46 @@ async function trimArchiveIfNeeded(): Promise<void> {
} }
async function flushArchiveQueue(): Promise<void> { async function flushArchiveQueue(): Promise<void> {
if (pending.size === 0) return if (archiveFlushInProgress) return
const batch = [...pending.values()] archiveFlushInProgress = true
pending.clear() try {
for (const ev of batch) { while (pending.size > 0) {
if (shouldSkipArchiving(ev)) continue const batch: Event[] = []
const id = /^[0-9a-f]{64}$/i.test(ev.id) ? ev.id.toLowerCase() : ev.id for (const id of pending.keys()) {
const tier = archiveTierForEvent(ev) if (batch.length >= MAX_ARCHIVE_ROWS_PER_FLUSH) break
const bytes = approxEventBytes(ev) const ev = pending.get(id)
try { if (ev) {
await indexedDb.putArchivedEventRow(ev, tier, bytes) pending.delete(id)
} catch (e) { batch.push(ev)
logger.warn('[EventArchive] put failed', { id: id.slice(0, 8), e }) }
}
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 { function scheduleFlush(): void {
if (flushTimer !== null) return if (flushTimer !== null || archiveFlushInProgress) return
flushTimer = setTimeout(() => { flushTimer = setTimeout(() => {
flushTimer = null flushTimer = null
void flushArchiveQueue().catch((e) => logger.warn('[EventArchive] flush', e)) void flushArchiveQueue().catch((e) => logger.warn('[EventArchive] flush', e))
@ -108,6 +134,11 @@ export function queuePersistSeenEvent(ev: Event): void {
if (shouldSkipArchiving(ev)) return if (shouldSkipArchiving(ev)) return
const id = /^[0-9a-f]{64}$/i.test(ev.id) ? ev.id.toLowerCase() : ev.id const id = /^[0-9a-f]{64}$/i.test(ev.id) ? ev.id.toLowerCase() : ev.id
if (!/^[0-9a-f]{64}$/.test(id)) return 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) pending.set(id, ev)
scheduleFlush() scheduleFlush()
} }

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

@ -258,6 +258,13 @@ class IndexedDbService {
private initPromise: Promise<void> | null = null private initPromise: Promise<void> | null = null
/** Browser timer id (DOM `setTimeout` returns a number). */ /** Browser timer id (DOM `setTimeout` returns a number). */
private cleanupTimer: number | null = null 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). */ /** First TTL sweep after DB open (profile / relay list rows). */
private static readonly CLEANUP_INITIAL_DELAY_MS = 60 * 1000 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) * Check if an event is tombstoned (deleted)
*/ */
async isTombstoned(key: string): Promise<boolean> { async isTombstoned(key: string): Promise<boolean> {
await this.initPromise 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) => { return new Promise((resolve) => {
if (!this.db) { if (!this.db) {
return resolve(false) return resolve(false)
@ -2901,7 +2930,13 @@ class IndexedDbService {
request.onsuccess = () => { request.onsuccess = () => {
const row = request.result as TValue | undefined const row = request.result as TValue | undefined
transaction.commit() 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 = () => { request.onerror = () => {

Loading…
Cancel
Save