Browse Source

bug-fixes

imwald
Silberengel 3 weeks ago
parent
commit
d1698b468b
  1. 32
      src/components/Note/PublicationCard.tsx
  2. 14
      src/components/Note/index.tsx
  3. 7
      src/components/NoteStats/index.tsx
  4. 8
      src/components/ReplyNote/index.tsx
  5. 2
      src/components/ReplyNoteList/ThreadQuoteBacklink.tsx
  6. 331
      src/components/ReplyNoteList/index.tsx
  7. 11
      src/components/RssFeedItem/index.tsx
  8. 90
      src/components/UserAvatar/index.tsx
  9. 58
      src/lib/index-relay-http.ts
  10. 10
      src/pages/secondary/NotePage/index.tsx
  11. 1
      vite.config.ts

32
src/components/Note/PublicationCard.tsx

@ -1,5 +1,6 @@ @@ -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({ @@ -41,7 +42,7 @@ export default function PublicationCard({
}
const bookstrMetadataComponent = isBookstrEvent && (
<div className="text-xs text-muted-foreground space-x-2">
<div className="flex min-w-0 max-w-full flex-wrap gap-x-2 gap-y-1 text-xs text-muted-foreground">
{bookMetadata.type && <span>Type: {bookMetadata.type}</span>}
{bookMetadata.book && <span>Book: {formatBookName(bookMetadata.book)}</span>}
{bookMetadata.chapter && <span>Chapter: {bookMetadata.chapter}</span>}
@ -51,41 +52,44 @@ export default function PublicationCard({ @@ -51,41 +52,44 @@ export default function PublicationCard({
)
const tagsComponent = metadata.tags.length > 0 && (
<div className="flex gap-1 flex-wrap">
<div className="flex w-full min-w-0 max-w-full flex-wrap gap-1 content-start">
{metadata.tags.map((tag) => (
<div
key={tag}
className="flex items-center rounded-full text-xs px-2.5 py-0.5 bg-muted text-muted-foreground max-w-32 cursor-pointer hover:bg-accent hover:text-accent-foreground"
className="flex max-w-full min-w-0 items-center gap-0.5 rounded-full bg-muted px-2.5 py-0.5 text-xs text-muted-foreground cursor-pointer hover:bg-accent hover:text-accent-foreground sm:max-w-[min(100%,8rem)]"
onClick={(e) => {
e.stopPropagation()
push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] }))
}}
>
#<span className="truncate">{tag}</span>
<span className="shrink-0">#</span>
<span className="min-w-0 truncate">{tag}</span>
</div>
))}
</div>
)
const summaryComponent = metadata.summary && (
<div className="text-base text-muted-foreground line-clamp-4">{metadata.summary}</div>
<div className="min-w-0 max-w-full text-base text-muted-foreground line-clamp-4 break-words">
{metadata.summary}
</div>
)
if (isSmallScreen) {
return (
<div className={className}>
<div className={cn('w-full min-w-0', className)}>
<div
className="cursor-pointer rounded-lg border p-4 hover:bg-muted/50 transition-colors"
className="min-w-0 cursor-pointer rounded-lg border p-4 transition-colors hover:bg-muted/50"
onClick={handleCardClick}
>
{metadata.image && autoLoadMedia && (
<Image
image={{ url: metadata.image, pubkey: event.pubkey }}
className="w-full max-w-[400px] aspect-video mb-3"
className="mb-3 aspect-video w-full max-w-full"
hideIfError
/>
)}
<div className="space-y-2">
<div className="min-w-0 space-y-2 overflow-hidden">
{titleComponent}
{bookstrMetadataComponent}
{!titleComponent && bookstrMetadataComponent && <div className="h-0" />}
@ -98,20 +102,20 @@ export default function PublicationCard({ @@ -98,20 +102,20 @@ export default function PublicationCard({
}
return (
<div className={className}>
<div className={cn('w-full min-w-0', className)}>
<div
className="cursor-pointer rounded-lg border p-4 hover:bg-muted/50 transition-colors"
className="min-w-0 cursor-pointer overflow-hidden rounded-lg border p-4 transition-colors hover:bg-muted/50"
onClick={handleCardClick}
>
<div className="flex gap-4">
<div className="flex min-w-0 gap-4">
{metadata.image && autoLoadMedia && (
<Image
image={{ url: metadata.image, pubkey: event.pubkey }}
className="rounded-lg aspect-[4/3] xl:aspect-video object-cover bg-foreground h-44 max-w-[400px]"
className="aspect-[4/3] h-44 max-h-44 w-auto max-w-[min(400px,42%)] min-w-0 shrink rounded-lg bg-foreground object-cover xl:aspect-video xl:max-w-[400px]"
hideIfError
/>
)}
<div className="flex-1 w-0 space-y-2">
<div className="min-h-0 min-w-[10rem] flex-1 basis-0 space-y-2 overflow-hidden">
{titleComponent}
{bookstrMetadataComponent}
{!titleComponent && bookstrMetadataComponent && <div className="h-0" />}

14
src/components/Note/index.tsx

@ -433,7 +433,12 @@ export default function Note({ @@ -433,7 +433,12 @@ export default function Note({
) : (
<ReactionEmojiDisplay event={event} />
)}
<UserAvatar userId={event.pubkey} size={size === 'small' ? 'medium' : 'normal'} maxFileSizeKb={500} />
<UserAvatar
userId={event.pubkey}
size={size === 'small' ? 'medium' : 'normal'}
maxFileSizeKb={showFull ? 2048 : 500}
deferRemoteAvatar={!showFull}
/>
<div className="flex min-w-0 flex-1 flex-nowrap items-center gap-2 overflow-hidden">
<Username
userId={event.pubkey}
@ -480,7 +485,12 @@ export default function Note({ @@ -480,7 +485,12 @@ export default function Note({
</>
) : (
<>
<UserAvatar userId={event.pubkey} size={size === 'small' ? 'medium' : 'normal'} maxFileSizeKb={500} />
<UserAvatar
userId={event.pubkey}
size={size === 'small' ? 'medium' : 'normal'}
maxFileSizeKb={showFull ? 2048 : 500}
deferRemoteAvatar={!showFull}
/>
<div className="flex-1 w-0">
<div className="flex gap-2 items-center">
<Username

7
src/components/NoteStats/index.tsx

@ -2,6 +2,7 @@ import { cn } from '@/lib/utils' @@ -2,6 +2,7 @@ import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints'
import { useRssUrlThreadQueryRelays } from '@/hooks/useRssUrlThreadQueryRelays'
import noteStatsService from '@/services/note-stats.service'
import { ExtendedKind } from '@/constants'
import { useReplyUnderDiscussionRoot } from '@/hooks/useReplyUnderDiscussionRoot'
@ -33,7 +34,8 @@ export default function NoteStats({ @@ -33,7 +34,8 @@ export default function NoteStats({
}) {
const { isSmallScreen } = useScreenSize()
const { pubkey } = useNostr()
const { relays: statsRelays, key: statsRelaysKey } = useNoteStatsRelayHints()
const { relays: hintRelays, key: hintRelaysKey } = useNoteStatsRelayHints()
const { relayUrls: rssUrlThreadRelays, key: rssUrlThreadRelaysKey } = useRssUrlThreadQueryRelays()
const [loading, setLoading] = useState(false)
// Hide boost button for discussion events and replies to discussions
@ -45,6 +47,9 @@ export default function NoteStats({ @@ -45,6 +47,9 @@ export default function NoteStats({
/** Synthetic RSS article root: no boost/quote/zap; still show reaction breakdown (NIP-25 + kind-17 web). */
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
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. */

8
src/components/ReplyNote/index.tsx

@ -103,7 +103,13 @@ export default function ReplyNote({ @@ -103,7 +103,13 @@ export default function ReplyNote({
>
<Collapsible>
<div className="flex space-x-2 items-start px-4 pt-3">
<UserAvatar userId={headerUserId} size="medium" className="shrink-0 mt-0.5" maxFileSizeKb={500} />
<UserAvatar
userId={headerUserId}
size="medium"
className="shrink-0 mt-0.5"
maxFileSizeKb={2048}
deferRemoteAvatar={false}
/>
<div className="w-full overflow-hidden">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 w-0">

2
src/components/ReplyNoteList/ThreadQuoteBacklink.tsx

@ -136,6 +136,7 @@ export function BacklinkAvatarStrip({ @@ -136,6 +136,7 @@ export function BacklinkAvatarStrip({
userId={e.pubkey}
size="medium"
className="ring-1 ring-border/40"
deferRemoteAvatar={false}
/>
</button>
)
@ -205,6 +206,7 @@ export default function ThreadQuoteBacklink({ @@ -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}
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">

331
src/components/ReplyNoteList/index.tsx

@ -33,15 +33,23 @@ import { useReply } from '@/providers/ReplyProvider' @@ -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 = @@ -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 { @@ -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<string, { events: NEvent[]; eventIdSet: Set<string> }>,
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({ @@ -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<TRootInfo | undefined>(undefined)
const { repliesMap, addReplies } = useReply()
@ -382,7 +431,7 @@ function ReplyNoteList({ @@ -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({ @@ -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({ @@ -549,6 +599,124 @@ function ReplyNoteList({
return zapsThenTimeSorted(merged, 'desc')
}, [replies, filteredQuoteEvents, showQuotes, sort, replyIdSet, rootInfo])
const parentNoteFeed = useNoteFeedProfileContext()
const threadProfileLoadedRef = useRef<Set<string>>(new Set())
const threadProfileBatchGenRef = useRef(0)
const [threadProfileBatch, setThreadProfileBatch] = useState<{
profiles: Map<string, TProfile>
pending: Set<string>
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<NoteFeedProfileContextValue>(() => {
const profiles = new Map<string, TProfile>(parentNoteFeed?.profiles ?? [])
for (const [k, v] of threadProfileBatch.profiles) profiles.set(k, v)
const pending = new Set<string>(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<string>()
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<string | undefined>(undefined)
const [until, setUntil] = useState<number | undefined>(undefined)
const [loading, setLoading] = useState<boolean>(false)
@ -692,6 +860,72 @@ function ReplyNoteList({ @@ -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<Set<string>>(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({ @@ -718,7 +952,7 @@ function ReplyNoteList({
const handleEventPublished = (data: Event) => {
const ce = data as CustomEvent<NEvent>
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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -1095,6 +1394,7 @@ function ReplyNoteList({
)
return (
<NoteFeedProfileContext.Provider value={threadNoteFeedProfileValue}>
<div className="min-h-[80vh] pb-12">
{loading && <LoadingBar />}
{!loading && until && (
@ -1264,6 +1564,7 @@ function ReplyNoteList({ @@ -1264,6 +1564,7 @@ function ReplyNoteList({
<div ref={bottomRef} />
{loading && <ReplyNoteSkeleton />}
</div>
</NoteFeedProfileContext.Provider>
)
}

11
src/components/RssFeedItem/index.tsx

@ -207,9 +207,12 @@ export default function RssFeedItem({ @@ -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({ @@ -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({ @@ -464,7 +467,7 @@ export default function RssFeedItem({
.replace(/\]\]\s*>\s*$/g, '') // Remove trailing ]]> from CDATA
.replace(/^\s*<!\[CDATA\[/g, '') // Remove leading CDATA declaration
.replace(/<\?xml[^>]*\?>/gi, '') // Remove XML declarations
.replace(/<\!DOCTYPE[^>]*>/gi, '') // Remove DOCTYPE declarations
.replace(/<!DOCTYPE[^>]*>/gi, '') // Remove DOCTYPE declarations
.trim()
// Basic sanitization: remove script tags and dangerous attributes

90
src/components/UserAvatar/index.tsx

@ -8,7 +8,7 @@ import { seedProfileForNavigation } from '@/lib/profile-navigation-seed' @@ -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<string>() @@ -31,10 +31,35 @@ const loadedAvatarUrls = new Set<string>()
*/
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<number | null> {
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( @@ -71,7 +96,9 @@ function useDeferRemoteProfileAvatar(
profileAvatar: string | undefined,
fallbackSrc: string,
containerRef: RefObject<HTMLDivElement | null>,
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( @@ -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( @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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}
/>
)
) : (

58
src/lib/index-relay-http.ts

@ -35,11 +35,22 @@ function nostrFilterToIndexRelayBody(f: Filter): Record<string, unknown> { @@ -35,11 +35,22 @@ function nostrFilterToIndexRelayBody(f: Filter): Record<string, unknown> {
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<string, string[]>()
for (const key of Object.keys(f)) {
if (key.startsWith('#') && key.length === 2) {
const v = (f as Record<string, unknown>)[key]
if (Array.isArray(v) && v.length > 0) body[key] = v
if (key.length !== 2 || !key.startsWith('#')) continue
const v = (f as Record<string, unknown>)[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<string, number>() @@ -50,6 +61,9 @@ const lastIndexRelayHttpWarnAtByEndpoint = new Map<string, number>()
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<string, unknown>) {
const now = Date.now()
const prev = lastIndexRelayHttpWarnAtByEndpoint.get(endpoint) ?? 0
@ -95,6 +109,23 @@ function maybeLogDevIndexRelayUnreachableHint(): void { @@ -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( @@ -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( @@ -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
}

10
src/pages/secondary/NotePage/index.tsx

@ -160,6 +160,12 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: @@ -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({ @@ -552,7 +558,9 @@ function ParentNote({
navigateToNote(toNote(event ?? eventBech32Id))
}}
>
{event && <UserAvatar userId={event.pubkey} size="tiny" className="shrink-0" />}
{event && (
<UserAvatar userId={event.pubkey} size="tiny" className="shrink-0" deferRemoteAvatar={false} />
)}
<div
className="truncate flex-1"
onClick={(e) => {

1
vite.config.ts

@ -84,6 +84,7 @@ function quietDevIndexRelayProxyErrors(devIndexRelayTarget: string): Plugin { @@ -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'

Loading…
Cancel
Save