From d1698b468b3160df5713eb81ec8d1e4c6453f3ba Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 11 Apr 2026 00:09:34 +0200 Subject: [PATCH] bug-fixes --- src/components/Note/PublicationCard.tsx | 32 +- src/components/Note/index.tsx | 14 +- src/components/NoteStats/index.tsx | 7 +- src/components/ReplyNote/index.tsx | 8 +- .../ReplyNoteList/ThreadQuoteBacklink.tsx | 2 + src/components/ReplyNoteList/index.tsx | 331 +++++++++++++++++- src/components/RssFeedItem/index.tsx | 11 +- src/components/UserAvatar/index.tsx | 90 ++++- src/lib/index-relay-http.ts | 58 ++- src/pages/secondary/NotePage/index.tsx | 10 +- vite.config.ts | 1 + 11 files changed, 503 insertions(+), 61 deletions(-) diff --git a/src/components/Note/PublicationCard.tsx b/src/components/Note/PublicationCard.tsx index dc594539..6bfd5331 100644 --- a/src/components/Note/PublicationCard.tsx +++ b/src/components/Note/PublicationCard.tsx @@ -1,5 +1,6 @@ import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { toNote, toNoteList } from '@/lib/link' +import { cn } from '@/lib/utils' import { useSecondaryPageOptional } from '@/PageManager' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider' @@ -41,7 +42,7 @@ export default function PublicationCard({ } const bookstrMetadataComponent = isBookstrEvent && ( -
+
{bookMetadata.type && Type: {bookMetadata.type}} {bookMetadata.book && Book: {formatBookName(bookMetadata.book)}} {bookMetadata.chapter && Chapter: {bookMetadata.chapter}} @@ -51,41 +52,44 @@ export default function PublicationCard({ ) const tagsComponent = metadata.tags.length > 0 && ( -
+
{metadata.tags.map((tag) => (
{ e.stopPropagation() push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] })) }} > - #{tag} + # + {tag}
))}
) const summaryComponent = metadata.summary && ( -
{metadata.summary}
+
+ {metadata.summary} +
) if (isSmallScreen) { return ( -
+
{metadata.image && autoLoadMedia && ( )} -
+
{titleComponent} {bookstrMetadataComponent} {!titleComponent && bookstrMetadataComponent &&
} @@ -98,20 +102,20 @@ export default function PublicationCard({ } return ( -
+
-
+
{metadata.image && autoLoadMedia && ( )} -
+
{titleComponent} {bookstrMetadataComponent} {!titleComponent && bookstrMetadataComponent &&
} diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index f076c2e3..e14e0e00 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -433,7 +433,12 @@ export default function Note({ ) : ( )} - +
) : ( <> - +
- +
diff --git a/src/components/ReplyNoteList/ThreadQuoteBacklink.tsx b/src/components/ReplyNoteList/ThreadQuoteBacklink.tsx index 5c956c55..839d02cf 100644 --- a/src/components/ReplyNoteList/ThreadQuoteBacklink.tsx +++ b/src/components/ReplyNoteList/ThreadQuoteBacklink.tsx @@ -136,6 +136,7 @@ export function BacklinkAvatarStrip({ userId={e.pubkey} size="medium" className="ring-1 ring-border/40" + deferRemoteAvatar={false} /> ) @@ -205,6 +206,7 @@ export default function ThreadQuoteBacklink({ 'mt-0.5 ring-1', isWarning ? 'ring-amber-600/35 dark:ring-amber-400/35' : 'ring-border/40' )} + deferRemoteAvatar={false} />
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'