diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx
index f0f2d207..25ad25cf 100644
--- a/src/components/ReplyNoteList/index.tsx
+++ b/src/components/ReplyNoteList/index.tsx
@@ -33,15 +33,23 @@ import { useReply } from '@/providers/ReplyProvider'
import { useUserTrust } from '@/contexts/user-trust-context'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
+import {
+ NoteFeedProfileContext,
+ type NoteFeedProfileContextValue,
+ useNoteFeedProfileContext
+} from '@/providers/NoteFeedProfileContext'
import client, { eventService, queryService } from '@/services/client.service'
import noteStatsService from '@/services/note-stats.service'
import discussionFeedCache from '@/services/discussion-feed-cache.service'
+import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder'
import { replyBelongsToNoteThread } from '@/lib/thread-reply-root-match'
import {
buildRssArticleUrlThreadInteractionFilters,
+ buildRssWebNostrQueryRelayUrls,
isRssArticleUrlThreadInteraction
} from '@/lib/rss-web-feed'
+import type { TProfile } from '@/types'
import { Filter, Event as NEvent, kinds } from 'nostr-tools'
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -62,6 +70,8 @@ type TRootInfo =
const LIMIT = 200
const SHOW_COUNT = 10
+const THREAD_PROFILE_BATCH_DEBOUNCE_MS = 50
+const THREAD_PROFILE_CHUNK = 80
function partitionZapReceipts(items: NEvent[]) {
const zaps: NEvent[] = []
@@ -202,6 +212,45 @@ function isWebThreadTailKind(kind: number): boolean {
return EA_THREAD_TAIL_REFERENCE_KINDS.has(kind) || WEB_THREAD_EXTRA_TAIL_KINDS.has(kind)
}
+/** Kind 1111 / 1244 that includes the thread root id on an e/E tag (common on relays; stricter root-tag walks may miss these). */
+function commentReferencesThreadRootEventHex(evt: NEvent, rootHexLower: string): boolean {
+ if (evt.kind !== ExtendedKind.COMMENT && evt.kind !== ExtendedKind.VOICE_COMMENT) return false
+ const h = rootHexLower.trim().toLowerCase()
+ if (!/^[0-9a-f]{64}$/.test(h)) return false
+ return evt.tags.some(
+ (t) => (t[0] === 'e' || t[0] === 'E') && typeof t[1] === 'string' && t[1].toLowerCase() === h
+ )
+}
+
+function replyIdPresentInRepliesMap(
+ map: Map
}>,
+ replyId: string
+): boolean {
+ for (const { events } of map.values()) {
+ if (events.some((e) => e.id === replyId)) return true
+ }
+ return false
+}
+
+function replyMatchesThreadForList(
+ evt: NEvent,
+ opEvent: NEvent,
+ rootInfo: TRootInfo,
+ isDiscussionRoot: boolean
+): boolean {
+ if (rootInfo.type === 'I') {
+ return isRssArticleUrlThreadInteraction(evt, rootInfo.id)
+ }
+ if (
+ isDiscussionRoot &&
+ rootInfo.type === 'E' &&
+ commentReferencesThreadRootEventHex(evt, rootInfo.id)
+ ) {
+ return true
+ }
+ return replyBelongsToNoteThread(evt, opEvent, rootInfo)
+}
+
function threadBacklinkRelationLabel(item: NEvent, t: TFunction): string {
if (item.kind === kinds.Highlights) return t('highlighted this note')
if (item.kind === kinds.ShortTextNote) return t('quoted this note')
@@ -262,7 +311,7 @@ function ReplyNoteList({
const { hideContentMentioningMutedUsers } = useContentPolicy()
const { pubkey: userPubkey } = useNostr()
const { zapReplyThreshold } = useZap()
- const { blockedRelays } = useFavoriteRelays()
+ const { blockedRelays, favoriteRelays } = useFavoriteRelays()
const { relayUrls: browsingRelayUrls } = useCurrentRelays()
const [rootInfo, setRootInfo] = useState(undefined)
const { repliesMap, addReplies } = useReply()
@@ -382,7 +431,7 @@ function ReplyNoteList({
) {
return
}
- if (rootInfo && !replyBelongsToNoteThread(evt, event, rootInfo)) return
+ if (rootInfo && !replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot)) return
replyIdSet.add(evt.id)
replyEvents.push(evt)
@@ -467,7 +516,8 @@ function ReplyNoteList({
mutePubkeySet,
hideContentMentioningMutedUsers,
sort,
- zapReplyThreshold
+ zapReplyThreshold,
+ isDiscussionRoot
])
const replyIdSet = useMemo(() => new Set(replies.map((r) => r.id)), [replies])
@@ -549,6 +599,124 @@ function ReplyNoteList({
return zapsThenTimeSorted(merged, 'desc')
}, [replies, filteredQuoteEvents, showQuotes, sort, replyIdSet, rootInfo])
+ const parentNoteFeed = useNoteFeedProfileContext()
+ const threadProfileLoadedRef = useRef>(new Set())
+ const threadProfileBatchGenRef = useRef(0)
+ const [threadProfileBatch, setThreadProfileBatch] = useState<{
+ profiles: Map
+ pending: Set
+ version: number
+ }>(() => ({ profiles: new Map(), pending: new Set(), version: 0 }))
+
+ useEffect(() => {
+ threadProfileLoadedRef.current.clear()
+ threadProfileBatchGenRef.current += 1
+ setThreadProfileBatch({ profiles: new Map(), pending: new Set(), version: 0 })
+ }, [event.id])
+
+ const threadNoteFeedProfileValue = useMemo(() => {
+ const profiles = new Map(parentNoteFeed?.profiles ?? [])
+ for (const [k, v] of threadProfileBatch.profiles) profiles.set(k, v)
+ const pending = new Set(parentNoteFeed?.pendingPubkeys ?? [])
+ threadProfileBatch.pending.forEach((p) => pending.add(p))
+ return {
+ profiles,
+ pendingPubkeys: pending,
+ version: (parentNoteFeed?.version ?? 0) * 1_000_000 + threadProfileBatch.version
+ }
+ }, [parentNoteFeed, threadProfileBatch])
+
+ useEffect(() => {
+ const handle = window.setTimeout(() => {
+ const gen = threadProfileBatchGenRef.current
+ const candidates = new Set()
+ const addPk = (p: string | undefined) => {
+ if (p && p.length === 64 && /^[0-9a-f]{64}$/i.test(p)) {
+ candidates.add(p.toLowerCase())
+ }
+ }
+ const addFromEvt = (e: NEvent) => {
+ addPk(e.pubkey)
+ let n = 0
+ for (const tag of e.tags) {
+ if (tag[0] === 'p' && tag[1]) {
+ addPk(tag[1])
+ n++
+ if (n >= 4) break
+ }
+ }
+ }
+ addFromEvt(event)
+ for (const e of mergedFeed) addFromEvt(e)
+
+ const parentProfiles = parentNoteFeed?.profiles
+ const parentPending = parentNoteFeed?.pendingPubkeys
+ const need = [...candidates].filter((pk) => {
+ if (parentProfiles?.has(pk)) return false
+ if (parentPending?.has(pk)) return false
+ if (threadProfileLoadedRef.current.has(pk)) return false
+ return true
+ })
+ if (need.length === 0) return
+
+ need.forEach((pk) => threadProfileLoadedRef.current.add(pk))
+
+ setThreadProfileBatch((prev) => {
+ const pending = new Set(prev.pending)
+ let changed = false
+ for (const pk of need) {
+ if (!pending.has(pk)) {
+ pending.add(pk)
+ changed = true
+ }
+ }
+ if (!changed) return prev
+ return { ...prev, pending, version: prev.version + 1 }
+ })
+
+ void (async () => {
+ const chunks: string[][] = []
+ for (let i = 0; i < need.length; i += THREAD_PROFILE_CHUNK) {
+ chunks.push(need.slice(i, i + THREAD_PROFILE_CHUNK))
+ }
+ const settled = await Promise.allSettled(
+ chunks.map((chunk) => client.fetchProfilesForPubkeys(chunk))
+ )
+ if (gen !== threadProfileBatchGenRef.current) return
+
+ setThreadProfileBatch((prev) => {
+ const next = new Map(prev.profiles)
+ const pend = new Set(prev.pending)
+ settled.forEach((res, idx) => {
+ const chunk = chunks[idx]!
+ if (res.status === 'rejected') {
+ chunk.forEach((pk) => threadProfileLoadedRef.current.delete(pk))
+ chunk.forEach((pk) => pend.delete(pk))
+ return
+ }
+ const profiles = res.value
+ for (const p of profiles) {
+ next.set(p.pubkey, p)
+ pend.delete(p.pubkey)
+ }
+ for (const pk of chunk) {
+ pend.delete(pk)
+ if (!next.has(pk)) {
+ next.set(pk, {
+ pubkey: pk,
+ npub: pubkeyToNpub(pk) ?? '',
+ username: formatPubkey(pk)
+ })
+ }
+ }
+ })
+ return { profiles: next, pending: pend, version: prev.version + 1 }
+ })
+ })()
+ }, THREAD_PROFILE_BATCH_DEBOUNCE_MS)
+ return () => window.clearTimeout(handle)
+ }, [event, mergedFeed, parentNoteFeed?.version])
+
const [timelineKey] = useState(undefined)
const [until, setUntil] = useState(undefined)
const [loading, setLoading] = useState(false)
@@ -692,6 +860,72 @@ function ReplyNoteList({
hideContentMentioningMutedUsers
])
+ /** When note-stats counted discussion replies we did not REQ in the thread, fetch by id (same idea as RSS threads). */
+ const discussionStatsHydratedReplyIdsRef = useRef>(new Set())
+
+ useEffect(() => {
+ discussionStatsHydratedReplyIdsRef.current.clear()
+ }, [event.id])
+
+ useEffect(() => {
+ if (event.kind !== ExtendedKind.DISCUSSION || !rootInfo || rootInfo.type !== 'E') return
+ const fromStats = noteStats?.replies
+ if (!fromStats?.length) return
+ const threadRoot = rootInfo
+
+ const candidates = fromStats.filter(
+ (r) =>
+ !replyIdPresentInRepliesMap(repliesMap, r.id) &&
+ !discussionStatsHydratedReplyIdsRef.current.has(r.id)
+ )
+ if (candidates.length === 0) return
+
+ let cancelled = false
+ ;(async () => {
+ const batch: NEvent[] = []
+ for (const { id } of candidates) {
+ discussionStatsHydratedReplyIdsRef.current.add(id)
+ try {
+ const ev = await eventService.fetchEvent(id)
+ if (cancelled) return
+ if (ev && replyMatchesThreadForList(ev, event, threadRoot, true)) {
+ batch.push(ev)
+ } else {
+ discussionStatsHydratedReplyIdsRef.current.delete(id)
+ }
+ } catch {
+ discussionStatsHydratedReplyIdsRef.current.delete(id)
+ }
+ }
+ if (!cancelled && batch.length > 0) {
+ const ok = batch.filter(
+ (e) =>
+ !shouldHideThreadResponseEvent(
+ e,
+ mutePubkeySet,
+ hideContentMentioningMutedUsers
+ )
+ )
+ if (ok.length > 0) addReplies(ok)
+ }
+ })()
+
+ return () => {
+ cancelled = true
+ }
+ }, [
+ event.kind,
+ event.id,
+ event,
+ rootInfo,
+ noteStats?.replies,
+ noteStats?.updatedAt,
+ repliesMap,
+ addReplies,
+ mutePubkeySet,
+ hideContentMentioningMutedUsers
+ ])
+
const onNewReply = useCallback(
(evt: NEvent) => {
if (
@@ -718,7 +952,7 @@ function ReplyNoteList({
const handleEventPublished = (data: Event) => {
const ce = data as CustomEvent
const evt = ce.detail
- if (!evt || !replyBelongsToNoteThread(evt, event, rootInfo)) return
+ if (!evt || !replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot)) return
onNewReply(evt)
}
@@ -726,7 +960,7 @@ function ReplyNoteList({
return () => {
client.removeEventListener('newEvent', handleEventPublished)
}
- }, [rootInfo, event, onNewReply])
+ }, [rootInfo, event, onNewReply, isDiscussionRoot])
const replyFetchGenRef = useRef(0)
@@ -783,6 +1017,27 @@ function ReplyNoteList({
threadRelayHints
)
+ // URL/article threads (NIP-22 `#i`): synthetic root has no e-tags or seen-relay hints — merge the same
+ // relay stack as RSS+Web discovery / {@link RssUrlThreadStatsBar} so replies match feed stats.
+ if (rootInfo.type === 'I') {
+ const rssLayer = await buildRssWebNostrQueryRelayUrls({
+ accountPubkey: userPubkey ?? null,
+ favoriteRelays: favoriteRelays ?? [],
+ blockedRelays: blockedRelays ?? []
+ })
+ const seenNorm = new Set(
+ finalRelayUrls.map((u) => (normalizeAnyRelayUrl(u) || u).toLowerCase()).filter(Boolean)
+ )
+ for (const u of rssLayer) {
+ const n = normalizeAnyRelayUrl(u) || u?.trim()
+ if (!n) continue
+ const k = n.toLowerCase()
+ if (seenNorm.has(k)) continue
+ seenNorm.add(k)
+ finalRelayUrls.push(n)
+ }
+ }
+
const filters: Filter[] = []
if (rootInfo.type === 'E') {
// Fetch all reply types for event-based replies
@@ -871,10 +1126,7 @@ function ReplyNoteList({
// Filter and add replies (URL threads include kind 9802 highlights of this page)
const regularReplies = allReplies.filter((evt) => {
- const match =
- rootInfo.type === 'I'
- ? isRssArticleUrlThreadInteraction(evt, rootInfo.id)
- : replyBelongsToNoteThread(evt, event, rootInfo)
+ const match = replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot)
if (!match) return false
return !shouldHideThreadResponseEvent(
evt,
@@ -942,6 +1194,53 @@ function ReplyNoteList({
}
}
}
+
+ // Second pass for kind-11 discussions: nested 1111/1 chains are keyed under parent ids in
+ // ReplyProvider; fetching #e:[comment-id] fills gaps the root-scoped REQ can miss.
+ if (
+ event.kind === ExtendedKind.DISCUSSION &&
+ rootInfo.type === 'E' &&
+ regularReplies.length > 0
+ ) {
+ const commentKinds = [
+ ExtendedKind.COMMENT,
+ ExtendedKind.VOICE_COMMENT,
+ kinds.ShortTextNote
+ ]
+ const parentIds = regularReplies
+ .filter((evt) => commentKinds.includes(evt.kind))
+ .map((evt) => evt.id)
+ if (parentIds.length > 0) {
+ const nestedFilters: Filter[] = [
+ { '#e': parentIds, kinds: commentKinds, limit: LIMIT },
+ {
+ '#E': parentIds,
+ kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
+ limit: LIMIT
+ }
+ ]
+ const nestedReplies = await queryService.fetchEvents(finalRelayUrls, nestedFilters, {
+ onevent: (evt: NEvent) => {
+ if (fetchGeneration !== replyFetchGenRef.current) return
+ if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers))
+ return
+ if (!replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot)) return
+ addReplies([evt])
+ }
+ })
+ if (fetchGeneration !== replyFetchGenRef.current) return
+ const validNested = nestedReplies.filter(
+ (evt) =>
+ !shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers) &&
+ replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot)
+ )
+ if (validNested.length > 0) {
+ discussionFeedCache.setCachedReplies(rootInfo, validNested)
+ const merged = discussionFeedCache.getCachedReplies(rootInfo)
+ addReplies(merged ?? validNested)
+ }
+ }
+ }
} catch (error) {
logger.error('[ReplyNoteList] Error fetching replies:', error)
if (fetchGeneration !== replyFetchGenRef.current) return
@@ -962,10 +1261,12 @@ function ReplyNoteList({
event.id,
event.kind,
blockedRelays,
+ favoriteRelays,
browsingRelayUrls,
addReplies,
mutePubkeySet,
- hideContentMentioningMutedUsers
+ hideContentMentioningMutedUsers,
+ isDiscussionRoot
])
useEffect(() => {
@@ -1007,10 +1308,7 @@ function ReplyNoteList({
const events = await client.loadMoreTimeline(timelineKey, until, LIMIT)
const olderEvents = events.filter((evt) => {
if (!rootInfo) return false
- const matchesThread =
- rootInfo.type === 'I'
- ? isRssArticleUrlThreadInteraction(evt, rootInfo.id)
- : replyBelongsToNoteThread(evt, event, rootInfo)
+ const matchesThread = replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot)
if (!matchesThread) return false
return !shouldHideThreadResponseEvent(
evt,
@@ -1031,7 +1329,8 @@ function ReplyNoteList({
event,
mutePubkeySet,
hideContentMentioningMutedUsers,
- addReplies
+ addReplies,
+ isDiscussionRoot
])
const highlightReply = useCallback((eventId: string, scrollTo = true) => {
@@ -1095,6 +1394,7 @@ function ReplyNoteList({
)
return (
+
{loading &&
}
{!loading && until && (
@@ -1264,6 +1564,7 @@ function ReplyNoteList({
{loading &&
}
+
)
}
diff --git a/src/components/RssFeedItem/index.tsx b/src/components/RssFeedItem/index.tsx
index 5588fee4..7ed825cf 100644
--- a/src/components/RssFeedItem/index.tsx
+++ b/src/components/RssFeedItem/index.tsx
@@ -207,9 +207,12 @@ export default function RssFeedItem({
}
}
+ const eventTargetElement = (t: EventTarget | null): Element | null =>
+ t instanceof Element ? t : t instanceof Node ? t.parentElement : null
+
const handleMouseUp = (e: MouseEvent) => {
// Don't process if clicking on the highlight button itself
- if ((e.target as HTMLElement).closest('.highlight-button-container')) {
+ if (eventTargetElement(e.target)?.closest('.highlight-button-container')) {
return
}
@@ -222,8 +225,8 @@ export default function RssFeedItem({
const handleClick = (e: MouseEvent) => {
// Hide button if clicking outside the selection area and not on the button itself
- const target = e.target as HTMLElement
- if (showHighlightButton && !target.closest('.highlight-button-container')) {
+ const target = eventTargetElement(e.target)
+ if (showHighlightButton && !target?.closest('.highlight-button-container')) {
// Check if there's still a valid selection
const selection = window.getSelection()
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
@@ -464,7 +467,7 @@ export default function RssFeedItem({
.replace(/\]\]\s*>\s*$/g, '') // Remove trailing ]]> from CDATA
.replace(/^\s*]*\?>/gi, '') // Remove XML declarations
- .replace(/<\!DOCTYPE[^>]*>/gi, '') // Remove DOCTYPE declarations
+ .replace(/]*>/gi, '') // Remove DOCTYPE declarations
.trim()
// Basic sanitization: remove script tags and dangerous attributes
diff --git a/src/components/UserAvatar/index.tsx b/src/components/UserAvatar/index.tsx
index 0e95d574..b1a17cd9 100644
--- a/src/components/UserAvatar/index.tsx
+++ b/src/components/UserAvatar/index.tsx
@@ -8,7 +8,7 @@ import { seedProfileForNavigation } from '@/lib/profile-navigation-seed'
import { cn } from '@/lib/utils'
import { useSmartProfileNavigationOptional } from '@/PageManager'
import type { TProfile } from '@/types'
-import { useMemo, useState, useEffect, useRef, type RefObject } from 'react'
+import { useMemo, useState, useEffect, useLayoutEffect, useRef, type RefObject } from 'react'
/** Only defer network fetches for typical profile picture URLs (not data:, blob:, etc.). */
function isHttpOrHttpsUrl(url: string): boolean {
@@ -31,10 +31,35 @@ const loadedAvatarUrls = new Set()
*/
const AVATAR_HEAD_TIMEOUT_MS = 3000
+/** Pixels beyond the viewport edge to treat as “visible” for avatar load (matches IO rootMargin intent). */
+const AVATAR_VIEWPORT_MARGIN_PX = 320
+
+function elementIsNearViewport(el: HTMLElement, marginPx: number): boolean {
+ const rect = el.getBoundingClientRect()
+ const vh = window.innerHeight
+ const vw = window.innerWidth
+ return (
+ rect.bottom >= -marginPx &&
+ rect.top <= vh + marginPx &&
+ rect.right >= -marginPx &&
+ rect.left <= vw + marginPx
+ )
+}
+
+function isSameOriginUrl(url: string): boolean {
+ if (typeof window === 'undefined') return false
+ try {
+ return new URL(url).origin === window.location.origin
+ } catch {
+ return false
+ }
+}
+
async function fetchUrlSizeBytes(url: string): Promise {
if (urlSizeCache.has(url)) return urlSizeCache.get(url)!
- // Cross-origin HEAD to image/media URLs usually has no CORS — Firefox logs errors even when we catch.
- if (isImage(url) || isMedia(url)) {
+ // Cross-origin HEAD almost never exposes Content-Length to JS without CORS; browsers still log CORS failures.
+ // Skip HEAD for images/media (no point) and for all other cross-origin URLs (HTML homepages, libravatar, etc.).
+ if (isImage(url) || isMedia(url) || !isSameOriginUrl(url)) {
urlSizeCache.set(url, null)
return null
}
@@ -71,7 +96,9 @@ function useDeferRemoteProfileAvatar(
profileAvatar: string | undefined,
fallbackSrc: string,
containerRef: RefObject,
- maxFileSizeBytes?: number
+ maxFileSizeBytes?: number,
+ /** When false, load remote avatars immediately (threads / small lists where every face should appear fast). */
+ deferRemote = true
): string {
const remoteHttp = useMemo(() => {
const a = profileAvatar?.trim()
@@ -107,14 +134,31 @@ function useDeferRemoteProfileAvatar(
return ''
}, [profileAvatar])
- const [allowRemote, setAllowRemote] = useState(() => remoteHttp === '' || alreadyCached)
+ const [allowRemote, setAllowRemote] = useState(
+ () => !deferRemote || remoteHttp === '' || alreadyCached
+ )
- useEffect(() => {
- setAllowRemote(remoteHttp === '' || alreadyCached)
- }, [remoteHttp, alreadyCached])
+ // When metadata arrives, avoid resetting to identicon + waiting for IO on rows that are
+ // already on screen (previously: useEffect(false) then IntersectionObserver → noticeable delay).
+ useLayoutEffect(() => {
+ if (!deferRemote) {
+ setAllowRemote(true)
+ return
+ }
+ if (remoteHttp === '' || alreadyCached) {
+ setAllowRemote(true)
+ return
+ }
+ const el = containerRef.current
+ if (el && elementIsNearViewport(el, AVATAR_VIEWPORT_MARGIN_PX)) {
+ setAllowRemote(true)
+ return
+ }
+ setAllowRemote(false)
+ }, [remoteHttp, alreadyCached, deferRemote])
useEffect(() => {
- if (!remoteHttp || allowRemote) return
+ if (!deferRemote || !remoteHttp || allowRemote) return
if (typeof IntersectionObserver === 'undefined') {
setAllowRemote(true)
return
@@ -127,11 +171,11 @@ function useDeferRemoteProfileAvatar(
setAllowRemote(true)
}
},
- { root: null, rootMargin: '200px', threshold: 0.01 }
+ { root: null, rootMargin: `${AVATAR_VIEWPORT_MARGIN_PX}px`, threshold: 0.01 }
)
io.observe(el)
return () => io.disconnect()
- }, [remoteHttp, allowRemote, containerRef])
+ }, [remoteHttp, allowRemote, containerRef, deferRemote])
if (sizeBlocked) return fallbackSrc
return nonHttpAvatar || (remoteHttp && allowRemote ? remoteHttp : '') || fallbackSrc
@@ -153,7 +197,8 @@ export default function UserAvatar({
className,
size = 'normal',
prefetchedProfile,
- maxFileSizeKb = 2048
+ maxFileSizeKb = 2048,
+ deferRemoteAvatar = true
}: {
userId: string
className?: string
@@ -166,6 +211,11 @@ export default function UserAvatar({
* Defaults to 2048 (2 MB). Pass a lower value (e.g. 500) for dense feed contexts.
*/
maxFileSizeKb?: number
+ /**
+ * When false, start loading the remote picture as soon as metadata exists (no viewport deferral).
+ * Use in threads and short lists so participants are recognizable immediately.
+ */
+ deferRemoteAvatar?: boolean
}) {
const { profile: fetchedProfile } = useFetchProfile(userId)
const profile = useMemo(() => {
@@ -209,7 +259,8 @@ export default function UserAvatar({
profile?.avatar,
defaultAvatar,
containerRef,
- maxFileSizeKb != null ? maxFileSizeKb * 1024 : undefined
+ maxFileSizeKb != null ? maxFileSizeKb * 1024 : undefined,
+ deferRemoteAvatar
)
// All hooks must be called before any early returns
@@ -285,8 +336,9 @@ export default function UserAvatar({
style={{ display: 'block', position: 'static', margin: 0, padding: 0, top: 0, left: 0, right: 0, bottom: 0 }}
onError={handleImageError}
onLoad={handleImageLoad}
- loading="lazy"
+ loading={isHttpOrHttpsUrl(currentSrc) ? 'eager' : 'lazy'}
decoding="async"
+ fetchpriority={isHttpOrHttpsUrl(currentSrc) ? 'high' : undefined}
/>
)
) : (
@@ -304,13 +356,15 @@ export function SimpleUserAvatar({
size = 'normal',
className,
prefetchedProfile,
- maxFileSizeKb = 2048
+ maxFileSizeKb = 2048,
+ deferRemoteAvatar = true
}: {
userId: string
size?: 'large' | 'big' | 'semiBig' | 'normal' | 'medium' | 'small' | 'xSmall' | 'tiny'
className?: string
prefetchedProfile?: TProfile
maxFileSizeKb?: number
+ deferRemoteAvatar?: boolean
}) {
const { profile: fetchedProfile } = useFetchProfile(userId)
const profile = useMemo(() => {
@@ -350,7 +404,8 @@ export function SimpleUserAvatar({
profile?.avatar,
defaultAvatar,
containerRef,
- maxFileSizeKb != null ? maxFileSizeKb * 1024 : undefined
+ maxFileSizeKb != null ? maxFileSizeKb * 1024 : undefined,
+ deferRemoteAvatar
)
// All hooks must be called before any early returns
@@ -416,8 +471,9 @@ export function SimpleUserAvatar({
style={{ display: 'block', position: 'static', margin: 0, padding: 0, top: 0, left: 0, right: 0, bottom: 0 }}
onError={handleImageError}
onLoad={handleImageLoad}
- loading="lazy"
+ loading={isHttpOrHttpsUrl(currentSrc) ? 'eager' : 'lazy'}
decoding="async"
+ fetchpriority={isHttpOrHttpsUrl(currentSrc) ? 'high' : undefined}
/>
)
) : (
diff --git a/src/lib/index-relay-http.ts b/src/lib/index-relay-http.ts
index bcfe7be8..bc0e6b2d 100644
--- a/src/lib/index-relay-http.ts
+++ b/src/lib/index-relay-http.ts
@@ -35,11 +35,22 @@ function nostrFilterToIndexRelayBody(f: Filter): Record {
if (f.kinds?.length) body.kinds = f.kinds
if (f.since != null) body.since = f.since
if (f.until != null) body.until = f.until
+ /** Index relays expect NIP-01 lowercase single-letter tag keys (`#e` not `#E`). */
+ const tagBuckets = new Map()
for (const key of Object.keys(f)) {
- if (key.startsWith('#') && key.length === 2) {
- const v = (f as Record)[key]
- if (Array.isArray(v) && v.length > 0) body[key] = v
+ if (key.length !== 2 || !key.startsWith('#')) continue
+ const v = (f as Record)[key]
+ if (!Array.isArray(v) || v.length === 0) continue
+ const normKey = `#${key[1].toLowerCase()}`
+ const cur = tagBuckets.get(normKey) ?? []
+ for (const item of v) {
+ if (item != null && String(item).length > 0) cur.push(String(item))
}
+ tagBuckets.set(normKey, cur)
+ }
+ for (const [k, vals] of tagBuckets) {
+ if (vals.length === 0) continue
+ body[k] = [...new Set(vals)]
}
return body
}
@@ -50,6 +61,9 @@ const lastIndexRelayHttpWarnAtByEndpoint = new Map()
const DEV_INDEX_RELAY_TRANSPORT_HINT_MS = 60_000
let lastDevIndexRelayTransportHintAt = 0
+const DEV_INDEX_RELAY_HTTP_ERROR_HINT_MS = 60_000
+let lastDevIndexRelayHttpErrorHintAt = 0
+
function warnIndexRelayHttpThrottled(endpoint: string, message: string, meta: Record) {
const now = Date.now()
const prev = lastIndexRelayHttpWarnAtByEndpoint.get(endpoint) ?? 0
@@ -95,6 +109,23 @@ function maybeLogDevIndexRelayUnreachableHint(): void {
)
}
+/** Server responded (proxy works) but returned 5xx — distinct from connection refused / down relay. */
+function maybeLogDevIndexRelayHttpErrorHint(status: number, detail?: string): void {
+ if (import.meta.env.PROD || typeof window === 'undefined') return
+ const now = Date.now()
+ if (now - lastDevIndexRelayHttpErrorHintAt < DEV_INDEX_RELAY_HTTP_ERROR_HINT_MS) return
+ lastDevIndexRelayHttpErrorHintAt = now
+ const msg =
+ `[IndexRelayHttp] Dev index relay returned HTTP ${status} for POST /api/events/filter. ` +
+ 'The process behind VITE_DEV_INDEX_RELAY_TARGET (default http://127.0.0.1:4000) is reachable but errored — inspect that server’s logs, database, and version (expected: gc_index_relay-style API). ' +
+ 'To use a different relay, set VITE_DEV_INDEX_RELAY_TARGET in .env.local.'
+ if (detail) {
+ logger.warn(msg, { responseSnippet: detail })
+ } else {
+ logger.warn(msg)
+ }
+}
+
function handleFilterTransportFailure(endpoint: string, err?: unknown): void {
if (import.meta.env.DEV && isDevViteIndexRelayProxyPath(endpoint)) {
logger.debug('[IndexRelayHttp] filter unreachable', { endpoint })
@@ -165,8 +196,22 @@ export async function queryIndexRelay(
})
if (!res.ok) {
sawHardFailure = true
- if (isDevViteIndexRelayProxyPath(endpoint) && res.status === 500) {
- handleFilterTransportFailure(endpoint, `HTTP ${res.status}`)
+ if (isDevViteIndexRelayProxyPath(endpoint)) {
+ let detail = ''
+ try {
+ detail = (await res.text()).trim().slice(0, 400)
+ } catch {
+ /* ignore */
+ }
+ if (res.status >= 500 && res.status <= 599) {
+ maybeLogDevIndexRelayHttpErrorHint(res.status, detail || undefined)
+ } else {
+ logger.debug('[IndexRelayHttp] filter HTTP response', {
+ endpoint,
+ status: res.status,
+ detail: detail || undefined
+ })
+ }
} else {
warnIndexRelayHttpThrottled(endpoint, '[IndexRelayHttp] filter request failed', {
endpoint,
@@ -208,7 +253,8 @@ export async function queryIndexRelay(
}
function filterForIndexRelay(f: Filter): Filter {
- const { search: _s, ...rest } = f
+ const rest = { ...f } as Filter & { search?: unknown }
+ delete rest.search
return rest as Filter
}
diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx
index 540949cb..9b7c90b1 100644
--- a/src/pages/secondary/NotePage/index.tsx
+++ b/src/pages/secondary/NotePage/index.tsx
@@ -160,6 +160,12 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
// Fetch profile for author (for OpenGraph metadata)
const { profile: authorProfile } = useFetchProfile(finalEvent?.pubkey)
+ useEffect(() => {
+ const pk = finalEvent?.pubkey?.trim().toLowerCase()
+ if (!pk || !/^[0-9a-f]{64}$/.test(pk)) return
+ void client.fetchProfilesForPubkeys([pk])
+ }, [finalEvent?.id, finalEvent?.pubkey])
+
const getNoteTypeTitle = (kind: number): string => {
switch (kind) {
case 1: // kinds.ShortTextNote
@@ -552,7 +558,9 @@ function ParentNote({
navigateToNote(toNote(event ?? eventBech32Id))
}}
>
- {event && }
+ {event && (
+
+ )}
{
diff --git a/vite.config.ts b/vite.config.ts
index a76d7b48..0ce34146 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -84,6 +84,7 @@ function quietDevIndexRelayProxyErrors(devIndexRelayTarget: string): Plugin {
export default defineConfig(({ mode }) => {
// `.env.local` is not on `process.env` when this file is evaluated unless we load it.
const env = loadEnv(mode, process.cwd(), '')
+ /** gc_index_relay (or compatible) HTTP API; app POSTs to /api/events/filter. HTTP 500 in the browser means this process errored, not that Vite failed. */
const devIndexRelayTarget =
env.VITE_DEV_INDEX_RELAY_TARGET?.trim() || 'http://127.0.0.1:4000'