diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx
index 026a34c9..10889ffc 100644
--- a/src/components/NoteList/index.tsx
+++ b/src/components/NoteList/index.tsx
@@ -944,7 +944,7 @@ const NoteList = forwardRef(
pendingPubkeys: feedProfileBatch.pending,
version: feedProfileBatch.version
}),
- [feedProfileBatch]
+ [feedProfileBatch.profiles, feedProfileBatch.pending, feedProfileBatch.version]
)
// Memoize subRequests serialization to avoid expensive JSON.stringify on every render
@@ -1078,7 +1078,9 @@ const NoteList = forwardRef(
}
}
if (!changed) return prev
- return { ...prev, pending, version: prev.version + 1 }
+ // Do not bump `version` here — only the debounced batch + profile merges should notify
+ // `useFetchProfile` (via profiles map / pending membership), not every pending-key sync.
+ return { ...prev, pending }
})
}, [timelineEventsForFilter, newEvents])
@@ -1564,7 +1566,7 @@ const NoteList = forwardRef(
}
}
if (!pendingChanged) return prev
- return { ...prev, pending, version: prev.version + 1 }
+ return { ...prev, pending }
})
void (async () => {
diff --git a/src/components/NoteStats/index.tsx b/src/components/NoteStats/index.tsx
index f54662ff..464b09a4 100644
--- a/src/components/NoteStats/index.tsx
+++ b/src/components/NoteStats/index.tsx
@@ -8,9 +8,8 @@ import noteStatsService from '@/services/note-stats.service'
import { ExtendedKind } from '@/constants'
import { useReplyUnderDiscussionRoot } from '@/hooks/useReplyUnderDiscussionRoot'
import { shouldHideInteractions } from '@/lib/event-filtering'
-import logger from '@/lib/logger'
import { Event } from 'nostr-tools'
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import BookmarkButton from '../BookmarkButton'
import { LikeButtonWithStats } from './LikeButton'
import { LikesWithStats } from './Likes'
@@ -40,8 +39,8 @@ export default function NoteStats({
const { isSmallScreen } = useScreenSize()
const { pubkey } = useNostr()
const noteStats = useNoteStatsById(event.id)
- const { relays: hintRelays, key: hintRelaysKey } = useNoteStatsRelayHints()
- const { relayUrls: rssUrlThreadRelays, key: rssUrlThreadRelaysKey } = useRssUrlThreadQueryRelays()
+ const { relays: hintRelays, currentRelaysKey } = useNoteStatsRelayHints()
+ const { relayUrls: rssUrlThreadRelays, relayMergeTier } = useRssUrlThreadQueryRelays()
const [loading, setLoading] = useState(false)
// Hide boost button for discussion events and replies to discussions
@@ -55,7 +54,10 @@ export default function NoteStats({
const isRssArticleRoot = event.kind === ExtendedKind.RSS_THREAD_ROOT
/** Match {@link RssUrlThreadStatsBar}: inbox/favorites/fast-read merge — plain hints miss many #i indexers. */
const statsRelays = isRssArticleRoot ? rssUrlThreadRelays : hintRelays
- const statsRelaysKey = isRssArticleRoot ? rssUrlThreadRelaysKey : hintRelaysKey
+ /** At most two background refetches per card: before vs after inbox/favorite hints hydrate. */
+ const statsRelayFetchTier = isRssArticleRoot ? relayMergeTier : hintRelays.length > 0 ? 1 : 0
+ const statsRelaysRef = useRef(statsRelays)
+ statsRelaysRef.current = statsRelays
const isZapPoll = event.kind === ExtendedKind.ZAP_POLL
/** Emoji reaction pills (aggregated likes). Shown for RSS/Web URL threads so the side panel matches feed rows. */
@@ -63,18 +65,24 @@ export default function NoteStats({
useEffect(() => {
if (!fetchIfNotExisting) return
- logger.debug('[NoteStats] UI: scheduling fetchNoteStats', {
- eventId: `${event.id.slice(0, 12)}…`,
- kind: event.kind,
- hintRelayCount: statsRelays.length
- })
setLoading(true)
noteStatsService
- .fetchNoteStats(event, pubkey, statsRelays, { foreground: foregroundStats })
+ .fetchNoteStats(event, pubkey, statsRelaysRef.current, { foreground: foregroundStats })
.finally(() => setLoading(false))
// Intentionally omit `event` object: parent feeds often pass new references each render;
// id/sig/kind/created_at identify the note for refetch boundaries.
- }, [event.id, event.kind, event.created_at, event.sig, fetchIfNotExisting, foregroundStats, pubkey, statsRelaysKey])
+ // `statsRelayFetchTier` (not full sorted relay key) avoids a REQ storm when favorites/current relays hydrate.
+ }, [
+ event.id,
+ event.kind,
+ event.created_at,
+ event.sig,
+ fetchIfNotExisting,
+ foregroundStats,
+ pubkey,
+ statsRelayFetchTier,
+ currentRelaysKey
+ ])
if (isSmallScreen) {
return (
diff --git a/src/components/RelayIcon/index.tsx b/src/components/RelayIcon/index.tsx
index 7047e064..a0a7c204 100644
--- a/src/components/RelayIcon/index.tsx
+++ b/src/components/RelayIcon/index.tsx
@@ -1,5 +1,6 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { useFetchRelayInfo } from '@/hooks'
+import { getRelayIconOverrideSrc, relayUrlFingerprintColors } from '@/lib/relay-icon-source'
import { cn } from '@/lib/utils'
import logger from '@/lib/logger'
import { Server } from 'lucide-react'
@@ -45,6 +46,12 @@ export default function RelayIcon({
const iconUrl = useMemo(() => {
if (!url) return undefined
+ const override = getRelayIconOverrideSrc(url)
+ if (override) {
+ logger.debug('[RelayIcon] using override icon', { url, override })
+ return override
+ }
+
// Prefer the NIP-11 icon field
const rawIcon = relayInfo?.icon && typeof relayInfo.icon === 'string' ? relayInfo.icon : undefined
const nip11Icon = rawIcon ? resolveRelayImageUrl(rawIcon, url) : undefined
@@ -56,11 +63,16 @@ export default function RelayIcon({
return undefined
}, [url, relayInfo])
+ const fallbackColors = useMemo(() => relayUrlFingerprintColors(url), [url])
+
return (
{iconUrl && }
-
-
+
+
)
diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx
index 59870a10..5eaed53c 100644
--- a/src/components/ReplyNoteList/index.tsx
+++ b/src/components/ReplyNoteList/index.tsx
@@ -722,7 +722,7 @@ function ReplyNoteList({
}
}
if (!changed) return prev
- return { ...prev, pending, version: prev.version + 1 }
+ return { ...prev, pending }
})
void (async () => {
@@ -769,7 +769,7 @@ function ReplyNoteList({
})()
}, THREAD_PROFILE_BATCH_DEBOUNCE_MS)
return () => window.clearTimeout(handle)
- }, [event, mergedFeed, parentNoteFeed?.version])
+ }, [event, mergedFeed, parentNoteFeed?.profiles, parentNoteFeed?.pendingPubkeys])
const [timelineKey] = useState(undefined)
const [until, setUntil] = useState(undefined)
diff --git a/src/components/RssUrlThreadStatsBar/index.tsx b/src/components/RssUrlThreadStatsBar/index.tsx
index f223e134..5fa14782 100644
--- a/src/components/RssUrlThreadStatsBar/index.tsx
+++ b/src/components/RssUrlThreadStatsBar/index.tsx
@@ -6,7 +6,7 @@ import { useRssUrlThreadQueryRelays } from '@/hooks/useRssUrlThreadQueryRelays'
import { useNostr } from '@/providers/NostrProvider'
import { Bookmark, Highlighter, MessageCircle, ThumbsUp } from 'lucide-react'
import type { Event } from 'nostr-tools'
-import { useEffect, useMemo, useState } from 'react'
+import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
/** Compact reply / reaction / bookmark / highlight counts for RSS + Web URL threads. */
export default function RssUrlThreadStatsBar({
@@ -18,7 +18,9 @@ export default function RssUrlThreadStatsBar({
}) {
const { t } = useTranslation()
const { pubkey } = useNostr()
- const { relayUrls: statsRelays, key: statsRelaysKey } = useRssUrlThreadQueryRelays()
+ const { relayUrls: statsRelays, relayMergeTier, currentRelaysKey } = useRssUrlThreadQueryRelays()
+ const statsRelaysRef = useRef(statsRelays)
+ statsRelaysRef.current = statsRelays
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const noteStats = useNoteStatsById(event.id)
const [loading, setLoading] = useState(false)
@@ -26,9 +28,9 @@ export default function RssUrlThreadStatsBar({
useEffect(() => {
setLoading(true)
noteStatsService
- .fetchNoteStats(event, pubkey, statsRelays, { foreground: true })
+ .fetchNoteStats(event, pubkey, statsRelaysRef.current, { foreground: true })
.finally(() => setLoading(false))
- }, [event.id, event.kind, event.created_at, event.sig, pubkey, statsRelaysKey])
+ }, [event.id, event.kind, event.created_at, event.sig, pubkey, relayMergeTier, currentRelaysKey])
const fmt = (n: number) => (n >= 100 ? '99+' : String(n))
diff --git a/src/constants.ts b/src/constants.ts
index fea18c64..1b6ff5e3 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -409,6 +409,7 @@ export const FAST_READ_RELAY_URLS = [
'wss://nostr21.com',
'wss://thecitadel.nostr1.com',
'wss://aggr.nostr.land',
+ 'wss://primus.nostr1.com',
]
// Optimized relay list for write operations (no aggregator since it's read-only)
@@ -436,8 +437,6 @@ export const SEARCHABLE_RELAY_URLS = [
'wss://orly-relay.imwald.eu',
'wss://aggr.nostr.land',
'wss://thecitadel.nostr1.com',
- 'wss://relay.primal.net',
- 'wss://relay.damus.io',
'wss://nos.lol',
'wss://nostr.mom',
'wss://relay.noswhere.com',
@@ -449,11 +448,8 @@ export const SEARCHABLE_RELAY_URLS = [
]
export const PROFILE_RELAY_URLS = [
- 'wss://nos.lol',
- 'wss://relay.damus.io',
'wss://profiles.nostr1.com',
- 'wss://purplepag.es',
- 'wss://thecitadel.nostr1.com'
+ 'wss://purplepag.es'
]
export const FOLLOWS_HISTORY_RELAY_URLS = [
@@ -461,7 +457,7 @@ export const FOLLOWS_HISTORY_RELAY_URLS = [
]
// Combined relay URLs for profile fetching: search/index relays, fallback inboxes, and profile-specific relays.
-export const PROFILE_FETCH_RELAY_URLS = [...SEARCHABLE_RELAY_URLS, ...FAST_READ_RELAY_URLS, ...PROFILE_RELAY_URLS]
+export const PROFILE_FETCH_RELAY_URLS = [...FAST_READ_RELAY_URLS, ...PROFILE_RELAY_URLS]
export const ExtendedKind = {
PICTURE: 20,
@@ -576,16 +572,12 @@ export function isNip71StyleVideoKind(kind: number): boolean {
* When these kinds are ingested via {@link EventService.addEventToCache}, the client prefetches the event
* author's kind 3 + 10002 (contacts + NIP-65) so profile / relay UIs and publish routing stay warm.
* Omits reactions/zaps where `pubkey` is not the primary profile identity for the row.
+ *
+ * Empty by default: each hit used to schedule batched relay + IndexedDB work (see
+ * {@link ClientService.prefetchAuthorCoreReplaceables}) and could overwhelm the browser on busy feeds.
+ * Author lists still load from profile views, publish flow, and session prewarm.
*/
-export const AUTHOR_CORE_PREFETCH_ON_INGEST_KINDS: ReadonlySet = new Set([
- kinds.ShortTextNote,
- kinds.LongFormArticle,
- kinds.Repost,
- ExtendedKind.GENERIC_REPOST,
- ExtendedKind.PICTURE,
- ExtendedKind.VOICE,
- ...NIP71_VIDEO_KINDS
-])
+export const AUTHOR_CORE_PREFETCH_ON_INGEST_KINDS: ReadonlySet = new Set()
/** Short-form portrait-style bucket (kind 22 or 34236). */
export function isNip71ShortVideoKind(kind: number): boolean {
diff --git a/src/hooks/useFetchProfile.tsx b/src/hooks/useFetchProfile.tsx
index 310626ab..6265db12 100644
--- a/src/hooks/useFetchProfile.tsx
+++ b/src/hooks/useFetchProfile.tsx
@@ -9,7 +9,7 @@ import { eventService, replaceableEventService } from '@/services/client.service
import indexedDb from '@/services/indexed-db.service'
import { TProfile } from '@/types'
import { kinds } from 'nostr-tools'
-import { useEffect, useState, useRef, useCallback } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import logger from '@/lib/logger'
function tryHydrateProfileFromSessionOnly(pubkey: string, skipCache: boolean): TProfile | null {
@@ -58,14 +58,6 @@ const globalFetchingPubkeys = new Set()
const globalFetchCooldowns = new Map() // pubkey -> timestamp when cooldown expires
export function useFetchProfile(id?: string, skipCache = false) {
- // CRITICAL: Reduce logging to prevent performance issues during infinite loops
- // Only log if we're actually going to process (not just checking)
- // logger.info('[useFetchProfile] Hook called', {
- // id: id || 'undefined',
- // skipCache,
- // stack: new Error().stack?.split('\n').slice(1, 4).join('\n')
- // })
-
const nostr = useNostrOptional()
const currentAccountProfile = nostr?.profile ?? null
const noteFeed = useNoteFeedProfileContext()
@@ -83,23 +75,41 @@ export function useFetchProfile(id?: string, skipCache = false) {
const effectRunCountRef = useRef