Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
6a426fe14b
  1. 149
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  2. 8
      src/components/NoteBoostBadges/index.tsx
  3. 7
      src/components/NoteCard/MainNoteCard.tsx
  4. 10
      src/components/NoteList/VirtualizedFeedRows.tsx
  5. 143
      src/components/NoteList/index.tsx
  6. 7
      src/constants.ts
  7. 11
      src/hooks/useFetchProfile.tsx
  8. 21
      src/providers/NostrProvider/index.tsx
  9. 123
      src/services/client-replaceable-events.service.ts
  10. 6
      src/services/client.service.ts
  11. 4
      src/services/indexed-db.service.ts
  12. 21
      src/services/note-stats.service.ts

149
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -92,6 +92,13 @@ function resolveImetaForMarkdownImageUrl( @@ -92,6 +92,13 @@ function resolveImetaForMarkdownImageUrl(
return { url: cleaned, pubkey: eventPubkey }
}
/**
* Host for marked paragraph bodies that may include block-level nodes from {@link renderInlineTokens}
* (e.g. `![](https://…mp4)` {@link MediaPlayer}). `<p>` cannot wrap `<div>`; use flow
* `<div role="paragraph">` so the DOM stays valid and React stops `validateDOMNesting` warnings.
*/
const MD_PARAGRAPH_FLOW_CLASS = 'mb-1 last:mb-0'
/** Author custom emoji image URL → slide index in the note lightbox ({@link lightboxSlideFromImeta}). */
type TInlineEmojiLightbox = {
imageIndexMap: Map<string, number>
@ -1925,9 +1932,13 @@ function parseMarkdownContentLegacy( @@ -1925,9 +1932,13 @@ function parseMarkdownContentLegacy(
if (normalizedText) {
const textContent = parseInlineMarkdown(normalizedText, `text-${patternIdx}-para-${paraIdx}-img-${imgIdx}`, footnotes, emojiInfos, undefined, emojiLightbox)
parts.push(
<p key={`text-${patternIdx}-para-${paraIdx}-img-${imgIdx}`} className="mb-1 last:mb-0">
<div
key={`text-${patternIdx}-para-${paraIdx}-img-${imgIdx}`}
role="paragraph"
className={MD_PARAGRAPH_FLOW_CLASS}
>
{textContent}
</p>
</div>
)
}
}
@ -1988,9 +1999,13 @@ function parseMarkdownContentLegacy( @@ -1988,9 +1999,13 @@ function parseMarkdownContentLegacy(
if (normalizedText) {
const textContent = parseInlineMarkdown(normalizedText, `text-${patternIdx}-para-${paraIdx}-final`, footnotes, emojiInfos, undefined, emojiLightbox)
parts.push(
<p key={`text-${patternIdx}-para-${paraIdx}-final`} className="mb-1 last:mb-0">
<div
key={`text-${patternIdx}-para-${paraIdx}-final`}
role="paragraph"
className={MD_PARAGRAPH_FLOW_CLASS}
>
{textContent}
</p>
</div>
)
}
}
@ -2009,9 +2024,13 @@ function parseMarkdownContentLegacy( @@ -2009,9 +2024,13 @@ function parseMarkdownContentLegacy(
const paraContent = parseInlineMarkdown(normalizedPara, `text-${patternIdx}-para-${paraIdx}`, footnotes, emojiInfos, undefined, emojiLightbox)
// Wrap in paragraph tag (no whitespace-pre-wrap, let normal text wrapping handle it)
parts.push(
<p key={`text-${patternIdx}-para-${paraIdx}`} className="mb-1 last:mb-0">
<div
key={`text-${patternIdx}-para-${paraIdx}`}
role="paragraph"
className={MD_PARAGRAPH_FLOW_CLASS}
>
{paraContent}
</p>
</div>
)
} else if (paraIdx > 0) {
// Empty paragraph between non-empty paragraphs - add spacing
@ -2446,9 +2465,13 @@ function parseMarkdownContentLegacy( @@ -2446,9 +2465,13 @@ function parseMarkdownContentLegacy(
const paragraphContent = parseInlineMarkdown(paragraphText, `blockquote-${patternIdx}-para-${paraIdx}`, footnotes, emojiInfos, undefined, emojiLightbox)
return (
<p key={`blockquote-${patternIdx}-para-${paraIdx}`} className="mb-1 last:mb-0 whitespace-pre-line">
<div
key={`blockquote-${patternIdx}-para-${paraIdx}`}
role="paragraph"
className={cn(MD_PARAGRAPH_FLOW_CLASS, 'whitespace-pre-line')}
>
{paragraphContent}
</p>
</div>
)
})
@ -2476,12 +2499,12 @@ function parseMarkdownContentLegacy( @@ -2476,12 +2499,12 @@ function parseMarkdownContentLegacy(
})
parts.push(
<span
<div
key={`greentext-${patternIdx}`}
className="not-prose greentext my-1 block text-[#4a7c3a] dark:text-[#8fbc8f]"
>
{greentextContent}
</span>
</div>
)
} else if (pattern.type === 'fenced-code-block') {
const { code, language } = pattern.data
@ -2735,9 +2758,13 @@ function parseMarkdownContentLegacy( @@ -2735,9 +2758,13 @@ function parseMarkdownContentLegacy(
if (normalizedPara) {
const paraContent = parseInlineMarkdown(normalizedPara, `text-end-para-${imgIdx}-${paraIdx}`, footnotes, emojiInfos, undefined, emojiLightbox)
parts.push(
<p key={`text-end-para-${imgIdx}-${paraIdx}`} className="mb-1 last:mb-0">
<div
key={`text-end-para-${imgIdx}-${paraIdx}`}
role="paragraph"
className={MD_PARAGRAPH_FLOW_CLASS}
>
{paraContent}
</p>
</div>
)
}
})
@ -2791,9 +2818,13 @@ function parseMarkdownContentLegacy( @@ -2791,9 +2818,13 @@ function parseMarkdownContentLegacy(
if (normalizedPara) {
const paraContent = parseInlineMarkdown(normalizedPara, `text-end-final-para-${paraIdx}`, footnotes, emojiInfos, undefined, emojiLightbox)
parts.push(
<p key={`text-end-final-para-${paraIdx}`} className="mb-1 last:mb-0">
<div
key={`text-end-final-para-${paraIdx}`}
role="paragraph"
className={MD_PARAGRAPH_FLOW_CLASS}
>
{paraContent}
</p>
</div>
)
}
})
@ -2810,9 +2841,13 @@ function parseMarkdownContentLegacy( @@ -2810,9 +2841,13 @@ function parseMarkdownContentLegacy(
if (normalizedPara) {
const paraContent = parseInlineMarkdown(normalizedPara, `text-end-para-${paraIdx}`, footnotes, emojiInfos, undefined, emojiLightbox)
parts.push(
<p key={`text-end-para-${paraIdx}`} className="mb-1 last:mb-0">
<div
key={`text-end-para-${paraIdx}`}
role="paragraph"
className={MD_PARAGRAPH_FLOW_CLASS}
>
{paraContent}
</p>
</div>
)
}
})
@ -2833,9 +2868,9 @@ function parseMarkdownContentLegacy( @@ -2833,9 +2868,9 @@ function parseMarkdownContentLegacy(
if (!normalizedPara) return null
const paraContent = parseInlineMarkdown(normalizedPara, `text-only-para-${paraIdx}`, footnotes, emojiInfos, undefined, emojiLightbox)
return (
<p key={`text-only-para-${paraIdx}`} className="mb-1 last:mb-0">
<div key={`text-only-para-${paraIdx}`} role="paragraph" className={MD_PARAGRAPH_FLOW_CLASS}>
{paraContent}
</p>
</div>
)
}).filter(Boolean)
return { nodes: formattedParagraphs, hashtagsInContent, footnotes, citations }
@ -2953,9 +2988,9 @@ function parseMarkdownContentLegacy( @@ -2953,9 +2988,9 @@ function parseMarkdownContentLegacy(
// Render the original line with inline markdown processing
const lineContent = parseInlineMarkdown(originalLine, `single-list-item-${partIdx}`, footnotes, emojiInfos, undefined, emojiLightbox)
wrappedParts.push(
<span key={`list-item-content-${partIdx}`}>
<div key={`list-item-content-${partIdx}`} className="inline">
{lineContent}
</span>
</div>
)
} else {
// Fallback: render the list item content
@ -3451,12 +3486,12 @@ function parseMarkdownContentMarked( @@ -3451,12 +3486,12 @@ function parseMarkdownContentMarked(
displayMode
/>
) : (
<p key={`${key}-dmt-${idx}`} className="mb-1 last:mb-0">
<div key={`${key}-dmt-${idx}`} role="paragraph" className={MD_PARAGRAPH_FLOW_CLASS}>
{renderInlineTokens(
lexInlineProtected(seg.text.trim()),
`${key}-dmt-${idx}`
)}
</p>
</div>
)
)}
</div>
@ -3673,9 +3708,9 @@ function parseMarkdownContentMarked( @@ -3673,9 +3708,9 @@ function parseMarkdownContentMarked(
}
return (
<p key={`${key}-line-${lineIdx}`} className="mb-1 last:mb-0">
<div key={`${key}-line-${lineIdx}`} role="paragraph" className={MD_PARAGRAPH_FLOW_CLASS}>
{renderInlineTokens(lexInlineProtected(line) as any[], `${key}-line-inline-${lineIdx}`)}
</p>
</div>
)
}
@ -3704,9 +3739,9 @@ function parseMarkdownContentMarked( @@ -3704,9 +3739,9 @@ function parseMarkdownContentMarked(
}
return (
<p key={`${key}-line-fallback-${lineIdx}`} className="mb-1 last:mb-0">
<div key={`${key}-line-fallback-${lineIdx}`} role="paragraph" className={MD_PARAGRAPH_FLOW_CLASS}>
{renderInlineTokens(lexInlineProtected(line) as any[], `${key}-line-fallback-inline-${lineIdx}`)}
</p>
</div>
)
})
@ -3729,9 +3764,13 @@ function parseMarkdownContentMarked( @@ -3729,9 +3764,13 @@ function parseMarkdownContentMarked(
const before = rawParagraphText.slice(cursor, start)
if (before.trim().length > 0) {
nodes.push(
<p key={`${key}-nostr-raw-segment-${segmentIdx++}`} className="mb-1 last:mb-0">
<div
key={`${key}-nostr-raw-segment-${segmentIdx++}`}
role="paragraph"
className={MD_PARAGRAPH_FLOW_CLASS}
>
{parseInlineMarkdown(before, `${key}-nostr-raw-segment-${segmentIdx}`, footnotes, emojiInfos, navigateToHashtag, emojiLightbox)}
</p>
</div>
)
}
if (bech32Id.startsWith('naddr') && fullCalendarInvite && bech32Id === fullCalendarInvite.naddr) {
@ -3752,9 +3791,13 @@ function parseMarkdownContentMarked( @@ -3752,9 +3791,13 @@ function parseMarkdownContentMarked(
const after = rawParagraphText.slice(cursor)
if (after.trim().length > 0) {
nodes.push(
<p key={`${key}-nostr-raw-segment-${segmentIdx++}`} className="mb-1 last:mb-0">
<div
key={`${key}-nostr-raw-segment-${segmentIdx++}`}
role="paragraph"
className={MD_PARAGRAPH_FLOW_CLASS}
>
{parseInlineMarkdown(after, `${key}-nostr-raw-segment-${segmentIdx}`, footnotes, emojiInfos, navigateToHashtag, emojiLightbox)}
</p>
</div>
)
}
if (nodes.length > 0) {
@ -3907,9 +3950,13 @@ function parseMarkdownContentMarked( @@ -3907,9 +3950,13 @@ function parseMarkdownContentMarked(
const flushInlineSegment = (segmentIdx: number) => {
if (inlineSegment.length === 0) return
nodes.push(
<p key={`${key}-media-inline-segment-${segmentIdx}`} className="mb-1 last:mb-0">
<div
key={`${key}-media-inline-segment-${segmentIdx}`}
role="paragraph"
className={MD_PARAGRAPH_FLOW_CLASS}
>
{renderInlineTokens(inlineSegment, `${key}-media-inline-segment-${segmentIdx}`)}
</p>
</div>
)
inlineSegment = []
}
@ -4015,9 +4062,13 @@ function parseMarkdownContentMarked( @@ -4015,9 +4062,13 @@ function parseMarkdownContentMarked(
const flushInlineSegment = (segmentIdx: number) => {
if (inlineSegment.length === 0) return
nodes.push(
<p key={`${key}-nostr-inline-segment-${segmentIdx}`} className="mb-1 last:mb-0">
<div
key={`${key}-nostr-inline-segment-${segmentIdx}`}
role="paragraph"
className={MD_PARAGRAPH_FLOW_CLASS}
>
{renderInlineTokens(inlineSegment, `${key}-nostr-inline-segment-${segmentIdx}`)}
</p>
</div>
)
inlineSegment = []
}
@ -4070,9 +4121,13 @@ function parseMarkdownContentMarked( @@ -4070,9 +4121,13 @@ function parseMarkdownContentMarked(
const flushInlineSegment = (segmentIdx: number) => {
if (inlineSegment.length === 0) return
nodes.push(
<p key={`${key}-yt-inline-segment-${segmentIdx}`} className="mb-1 last:mb-0">
<div
key={`${key}-yt-inline-segment-${segmentIdx}`}
role="paragraph"
className={MD_PARAGRAPH_FLOW_CLASS}
>
{renderInlineTokens(inlineSegment, `${key}-yt-inline-segment-${segmentIdx}`)}
</p>
</div>
)
inlineSegment = []
}
@ -4118,9 +4173,13 @@ function parseMarkdownContentMarked( @@ -4118,9 +4173,13 @@ function parseMarkdownContentMarked(
const flushInlineSegment = (segmentIdx: number) => {
if (inlineSegment.length === 0) return
nodes.push(
<p key={`${key}-direct-media-inline-segment-${segmentIdx}`} className="mb-1 last:mb-0">
<div
key={`${key}-direct-media-inline-segment-${segmentIdx}`}
role="paragraph"
className={MD_PARAGRAPH_FLOW_CLASS}
>
{renderInlineTokens(inlineSegment, `${key}-direct-media-inline-segment-${segmentIdx}`)}
</p>
</div>
)
inlineSegment = []
}
@ -4182,9 +4241,9 @@ function parseMarkdownContentMarked( @@ -4182,9 +4241,9 @@ function parseMarkdownContentMarked(
}
if (!isImage(cleaned) || !isSafeMediaUrl(cleaned)) {
return (
<p key={`${key}-img-inline-fallback`} className="mb-1 last:mb-0">
<div key={`${key}-img-inline-fallback`} role="paragraph" className={MD_PARAGRAPH_FLOW_CLASS}>
{renderInlineTokens(paragraphTokens, `${key}-img-inline-fallback`)}
</p>
</div>
)
}
const imageIdx = imageIndexMap.get(cleaned)
@ -4208,7 +4267,11 @@ function parseMarkdownContentMarked( @@ -4208,7 +4267,11 @@ function parseMarkdownContentMarked(
}
const inlineNodes = renderInlineTokens(paragraphTokens, `${key}-inline`)
return <p key={`${key}-p`} className="mb-1 last:mb-0">{inlineNodes}</p>
return (
<div key={`${key}-p`} role="paragraph" className={MD_PARAGRAPH_FLOW_CLASS}>
{inlineNodes}
</div>
)
}
const renderBlockTokens = (tokens: any[], keyPrefix: string): React.ReactNode[] => {
@ -4448,9 +4511,9 @@ function parseMarkdownContentMarked( @@ -4448,9 +4511,9 @@ function parseMarkdownContentMarked(
nodes.push(...renderBlockTokens(token.tokens, `${key}-nested`))
} else if (typeof token.text === 'string' && token.text.trim()) {
nodes.push(
<p key={`${key}-fallback`} className="mb-1 last:mb-0">
<div key={`${key}-fallback`} role="paragraph" className={MD_PARAGRAPH_FLOW_CLASS}>
{renderInlineTokens(lexInlineProtected(String(token.text ?? token.raw ?? '')) as any[], `${key}-fallback-inline`)}
</p>
</div>
)
}
}

8
src/components/NoteBoostBadges/index.tsx

@ -2,7 +2,6 @@ import { ExtendedKind } from '@/constants' @@ -2,7 +2,6 @@ import { ExtendedKind } from '@/constants'
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { shouldHideInteractions } from '@/lib/event-filtering'
import { cn } from '@/lib/utils'
import { useUserTrust } from '@/contexts/user-trust-context'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@ -15,15 +14,12 @@ const MAX_VISIBLE = 28 @@ -15,15 +14,12 @@ const MAX_VISIBLE = 28
*/
export default function NoteBoostBadges({ event, className }: { event: Event; className?: string }) {
const { t } = useTranslation()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const noteStats = useNoteStatsById(event.id)
const boosters = useMemo(() => {
if (event.kind === ExtendedKind.DISCUSSION) return []
return (noteStats?.reposts ?? [])
.filter((r) => !hideUntrustedInteractions || isUserTrusted(r.pubkey))
.sort((a, b) => b.created_at - a.created_at)
}, [noteStats, event.kind, hideUntrustedInteractions, isUserTrusted])
return [...(noteStats?.reposts ?? [])].sort((a, b) => b.created_at - a.created_at)
}, [noteStats, event.kind])
if (shouldHideInteractions(event) || boosters.length === 0) {
return null

7
src/components/NoteCard/MainNoteCard.tsx

@ -1,4 +1,3 @@ @@ -1,4 +1,3 @@
import { useNip84HighlightTargetEvents } from '@/hooks'
import { ExtendedKind } from '@/constants'
import { Separator } from '@/components/ui/separator'
import { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
@ -6,7 +5,7 @@ import { toNote } from '@/lib/link' @@ -6,7 +5,7 @@ import { toNote } from '@/lib/link'
import { useSmartNoteNavigationOptional } from '@/PageManager'
import client from '@/services/client.service'
import { Pin } from 'lucide-react'
import { Event, kinds } from 'nostr-tools'
import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next'
import Collapsible from '../Collapsible'
import NoteBoostBadges from '../NoteBoostBadges'
@ -41,9 +40,6 @@ export default function MainNoteCard({ @@ -41,9 +40,6 @@ export default function MainNoteCard({
}) {
const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigationOptional()
const nip84HighlightEvents = useNip84HighlightTargetEvents(
event.kind === kinds.ShortTextNote ? event : null
)
const isZapFeedCard =
event.kind === ExtendedKind.ZAP_RECEIPT || event.kind === ExtendedKind.ZAP_REQUEST
const showNoteStatsRow = !embedded || isZapFeedCard
@ -104,7 +100,6 @@ export default function MainNoteCard({ @@ -104,7 +100,6 @@ export default function MainNoteCard({
hideParentNotePreview={hideParentNotePreview}
zapPollVoteHighlightOption={zapPollVoteHighlightOption}
showFull={showFull}
nip84HighlightEvents={nip84HighlightEvents}
/>
</Collapsible>
{!embedded ? <NoteBoostBadges event={event} className="mt-2 px-4" /> : null}

10
src/components/NoteList/VirtualizedFeedRows.tsx

@ -37,7 +37,10 @@ const WindowRows = memo(function WindowRows({ @@ -37,7 +37,10 @@ const WindowRows = memo(function WindowRows({
})
return (
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
<div
className="relative w-full overflow-hidden"
style={{ height: virtualizer.getTotalSize() }}
>
{virtualizer.getVirtualItems().map((vi) => (
<div
key={vi.key}
@ -85,7 +88,10 @@ const ElementRows = memo(function ElementRows({ @@ -85,7 +88,10 @@ const ElementRows = memo(function ElementRows({
})
return (
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
<div
className="relative w-full overflow-hidden"
style={{ height: virtualizer.getTotalSize() }}
>
{virtualizer.getVirtualItems().map((vi) => (
<div
key={vi.key}

143
src/components/NoteList/index.tsx

@ -217,6 +217,63 @@ function feedTimelineAlreadyRepresentsNip18Target(targetId: string | undefined, @@ -217,6 +217,63 @@ function feedTimelineAlreadyRepresentsNip18Target(targetId: string | undefined,
return false
}
const FEED_PROFILE_PREFETCH_MAX_P_TAGS = 64
const FEED_STATS_PROFILE_REPOSTS_CAP = 48
const FEED_STATS_PROFILE_LIKES_PER_NOTE = 8
function addLowerHexPubkeyCandidate(candidates: Set<string>, raw: string | undefined) {
if (!raw) return
const t = raw.trim()
if (t.length === 64 && /^[0-9a-f]{64}$/i.test(t)) {
candidates.add(t.toLowerCase())
}
}
/** Kind-0 prefetch targets for feed rows: author, mentions, `e`/`E` pubkey hints, NIP-18 embedded author. */
function collectProfilePrefetchPubkeysFromEvent(e: Event, candidates: Set<string>) {
addLowerHexPubkeyCandidate(candidates, e.pubkey)
let pCount = 0
for (const tag of e.tags) {
if (tag[0] === 'p' && tag[1]) {
addLowerHexPubkeyCandidate(candidates, tag[1])
pCount++
if (pCount >= FEED_PROFILE_PREFETCH_MAX_P_TAGS) break
}
if ((tag[0] === 'e' || tag[0] === 'E') && tag[4]) {
addLowerHexPubkeyCandidate(candidates, tag[4])
}
}
if (!isNip18RepostKind(e.kind)) return
const raw = e.content?.trim()
if (!raw) return
try {
const emb = JSON.parse(raw) as { pubkey?: string; pubKey?: string }
const pk = emb.pubkey ?? emb.pubKey
if (pk) addLowerHexPubkeyCandidate(candidates, pk)
} catch {
/* ignore */
}
}
function collectProfilePrefetchPubkeysFromNoteStats(
st: { reposts?: { pubkey: string }[]; likes?: { pubkey: string }[] } | undefined,
candidates: Set<string>
) {
if (!st) return
if (st.reposts?.length) {
for (const r of st.reposts.slice(0, FEED_STATS_PROFILE_REPOSTS_CAP)) {
addLowerHexPubkeyCandidate(candidates, r.pubkey)
}
}
if (st.likes?.length) {
for (const l of st.likes.slice(0, FEED_STATS_PROFILE_LIKES_PER_NOTE)) {
addLowerHexPubkeyCandidate(candidates, l.pubkey)
}
}
}
function mergeEventBatchesById(
prev: Event[],
incoming: Event[],
@ -894,30 +951,11 @@ const NoteList = forwardRef( @@ -894,30 +951,11 @@ const NoteList = forwardRef(
/** Pending pubkeys sync with rows so useFetchProfile skips per-note fetches before the debounced batch. */
useLayoutEffect(() => {
const candidates = new Set<string>()
const addPk = (p: string | undefined) => {
if (!p) return
const t = p.trim()
if (t.length === 64 && /^[0-9a-f]{64}$/i.test(t)) {
candidates.add(t.toLowerCase())
}
}
const addPkFromEventTags = (e: Event) => {
let n = 0
for (const tag of e.tags) {
if (tag[0] === 'p' && tag[1]) {
addPk(tag[1])
n++
if (n >= 4) break
}
}
}
for (const e of timelineEventsForFilter) {
addPk(e.pubkey)
addPkFromEventTags(e)
collectProfilePrefetchPubkeysFromEvent(e, candidates)
}
for (const e of newEvents) {
addPk(e.pubkey)
addPkFromEventTags(e)
collectProfilePrefetchPubkeysFromEvent(e, candidates)
}
setFeedProfileBatch((prev) => {
@ -1286,8 +1324,34 @@ const NoteList = forwardRef( @@ -1286,8 +1324,34 @@ const NoteList = forwardRef(
[showFeedClientFilter, applyClientFeedFilter, filteredEvents]
)
/** Bumps when {@link noteStatsService} updates any visible row so profile batch can include boosters/likers. */
const [feedStatsProfileBump, setFeedStatsProfileBump] = useState(0)
const visibleNoteIdsForStatsPrefetchKey = useMemo(
() =>
clientFilteredEvents
.slice(0, Math.min(120, Math.max(showCount + 64, 64)))
.map((e) => e.id)
.join('\n'),
[clientFilteredEvents, showCount]
)
useEffect(() => {
if (!visibleNoteIdsForStatsPrefetchKey) return
const ids = visibleNoteIdsForStatsPrefetchKey.split('\n').filter(Boolean)
const bump = () => setFeedStatsProfileBump((n) => n + 1)
const unsubs = ids.map((id) => noteStatsService.subscribeNoteStats(id, bump))
return () => {
unsubs.forEach((u) => u())
}
}, [visibleNoteIdsForStatsPrefetchKey])
const [feedVirtualScrollParent, setFeedVirtualScrollParent] = useState<HTMLElement | null>(null)
const [feedVirtualScrollMarginTop, setFeedVirtualScrollMarginTop] = useState(0)
/**
* Resolve the scroll container once per feed / refresh not on every {@link clientFilteredEvents} length tick.
* Re-running this on each timeline merge re-set scroll state and interacted badly with the virtualizer while rows
* were still settling (absolute rows could paint past the list bounds).
*/
useLayoutEffect(() => {
const root = feedRootRef.current
if (!root) {
@ -1297,7 +1361,7 @@ const NoteList = forwardRef( @@ -1297,7 +1361,7 @@ const NoteList = forwardRef(
}
setFeedVirtualScrollParent(getNearestScrollableAncestor(root))
setFeedVirtualScrollMarginTop(root.offsetTop)
}, [timelineSubscriptionKey, refreshCount, clientFilteredEvents.length])
}, [timelineSubscriptionKey, refreshCount])
const clientFilteredNewEvents = useMemo(
() =>
@ -1350,28 +1414,14 @@ const NoteList = forwardRef( @@ -1350,28 +1414,14 @@ const NoteList = forwardRef(
const handle = window.setTimeout(() => {
const gen = feedProfileBatchGenRef.current
const candidates = new Set<string>()
const addPk = (p: string | undefined) => {
if (p && p.length === 64 && /^[0-9a-f]{64}$/.test(p)) {
candidates.add(p.toLowerCase())
}
}
const addPkFromEventTags = (e: Event) => {
let n = 0
for (const tag of e.tags) {
if (tag[0] === 'p' && tag[1]) {
addPk(tag[1])
n++
if (n >= 4) break
}
}
}
for (const e of timelineEventsForFilter) {
addPk(e.pubkey)
addPkFromEventTags(e)
collectProfilePrefetchPubkeysFromEvent(e, candidates)
}
for (const e of newEvents) {
addPk(e.pubkey)
addPkFromEventTags(e)
collectProfilePrefetchPubkeysFromEvent(e, candidates)
}
for (const e of clientFilteredEvents.slice(0, Math.min(120, Math.max(showCount + 64, 64)))) {
collectProfilePrefetchPubkeysFromNoteStats(noteStatsService.getNoteStats(e.id), candidates)
}
const need = [...candidates].filter((pk) => !feedProfileLoadedRef.current.has(pk))
@ -1437,7 +1487,7 @@ const NoteList = forwardRef( @@ -1437,7 +1487,7 @@ const NoteList = forwardRef(
})()
}, FEED_PROFILE_BATCH_DEBOUNCE_MS)
return () => window.clearTimeout(handle)
}, [timelineEventsForFilter, newEvents])
}, [timelineEventsForFilter, newEvents, clientFilteredEvents, showCount, feedStatsProfileBump])
const scrollToTop = useCallback((behavior: ScrollBehavior = 'instant') => {
setTimeout(() => {
@ -3463,6 +3513,7 @@ const NoteList = forwardRef( @@ -3463,6 +3513,7 @@ const NoteList = forwardRef(
) : null}
{clientFilteredEvents.length > 0 ? (
<VirtualizedFeedRows
key={`${timelineSubscriptionKey}@@${refreshCount}`}
events={clientFilteredEvents}
gridLayout={gridLayout}
filterMutedNotes={filterMutedNotes}
@ -3502,7 +3553,13 @@ const NoteList = forwardRef( @@ -3502,7 +3553,13 @@ const NoteList = forwardRef(
: 'min-h-4'
}
>
{loading ? <NoteCardLoadingSkeleton /> : null}
{loading ? (
clientFilteredEvents.length > 0 ? (
<div className="mx-2 h-2 max-w-md rounded-full bg-muted/60 animate-pulse" aria-hidden />
) : (
<NoteCardLoadingSkeleton />
)
) : null}
</div>
) : listSourceEvents.length > 0 ? (
<div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div>

7
src/constants.ts

@ -196,7 +196,12 @@ export const ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS = 24 * 60 * 60 * 10 @@ -196,7 +196,12 @@ export const ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS = 24 * 60 * 60 * 10
* Too low causes empty profiles and NIP-05 gaps when relays are slow or many URLs are queried.
*/
export const METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS = 16000
export const METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS = 500
/** After all relays EOSE, wait this long before closing so slow EVENTs still land (slot queue + TLS). */
export const METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS = 2800
/**
* Max `authors` per REQ for batched kind-0; large arrays are split so relays return more complete rows.
*/
export const METADATA_BATCH_AUTHORS_CHUNK = 22
/**
* useFetchProfile: outer Promise.race on fetchProfileEvent and wait-for-shared-promise timeouts.

11
src/hooks/useFetchProfile.tsx

@ -377,6 +377,13 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -377,6 +377,13 @@ export function useFetchProfile(id?: string, skipCache = false) {
effectRunCountRef.current.delete(extractedPubkey)
return
}
if (fromBatch?.batchPlaceholder) {
initializedPubkeysRef.current.delete(extractedPubkey)
setProfile(fromBatch)
setPubkey(extractedPubkey)
setIsFetching(false)
setError(null)
}
if (noteFeed.pendingPubkeys.has(extractedPubkey)) {
const pkLower = extractedPubkey.toLowerCase()
const sessionEv = eventService.getSessionMetadataForPubkey(pkLower)
@ -450,7 +457,7 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -450,7 +457,7 @@ export function useFetchProfile(id?: string, skipCache = false) {
// CRITICAL: Early exit if we already have a profile for this pubkey
// This prevents re-fetching when we already have the profile
if (extractedPubkey && profile && profile.pubkey === extractedPubkey) {
if (extractedPubkey && profile && profile.pubkey === extractedPubkey && !profile.batchPlaceholder) {
// Ensure processingPubkeyRef is set to prevent re-fetch
if (processingPubkeyRef.current !== extractedPubkey) {
processingPubkeyRef.current = extractedPubkey
@ -561,7 +568,7 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -561,7 +568,7 @@ export function useFetchProfile(id?: string, skipCache = false) {
processingPubkeyRef.current = extractedPubkey
}
if (profile && profile.pubkey === extractedPubkey) {
if (profile && profile.pubkey === extractedPubkey && !profile.batchPlaceholder) {
logger.debug('[useFetchProfile] Already have profile for this pubkey (safety check)', {
extractedPubkey
})

21
src/providers/NostrProvider/index.tsx

@ -1287,16 +1287,17 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1287,16 +1287,17 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}
}
logger.debug('[Publish] Determining target relays...', { kind: event.kind, pubkey: event.pubkey?.substring(0, 8) })
const favoriteRelayUrls = favoriteRelayUrlsForPublish(favoriteRelaysEvent, account.pubkey)
const relays = await client.determineTargetRelays(event, {
...options,
favoriteRelayUrls,
blockedRelayUrls: options.blockedRelayUrls ?? blockedRelayUrlsFromEvent(blockedRelaysEvent)
})
logger.debug('[Publish] Target relays determined', { relayCount: relays.length, relays: relays.slice(0, 5) })
noteStatsService.beginPublishPriority()
try {
logger.debug('[Publish] Determining target relays...', { kind: event.kind, pubkey: event.pubkey?.substring(0, 8) })
const favoriteRelayUrls = favoriteRelayUrlsForPublish(favoriteRelaysEvent, account.pubkey)
const relays = await client.determineTargetRelays(event, {
...options,
favoriteRelayUrls,
blockedRelayUrls: options.blockedRelayUrls ?? blockedRelayUrlsFromEvent(blockedRelaysEvent)
})
logger.debug('[Publish] Target relays determined', { relayCount: relays.length, relays: relays.slice(0, 5) })
logger.debug('[Publish] Calling client.publishEvent()...', { relayCount: relays.length, eventId: event.id?.substring(0, 8) })
const publishResult = await client.publishEvent(relays, event, { favoriteRelayUrls })
logger.debug('[Publish] publishEvent completed', {
@ -1383,6 +1384,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1383,6 +1384,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
// Re-throw the error so the UI can handle it appropriately
throw error
} finally {
noteStatsService.endPublishPriority()
}
}

123
src/services/client-replaceable-events.service.ts

@ -3,6 +3,7 @@ import { @@ -3,6 +3,7 @@ import {
FAST_READ_RELAY_URLS,
FAST_WRITE_RELAY_URLS,
MAX_CONCURRENT_RELAY_CONNECTIONS,
METADATA_BATCH_AUTHORS_CHUNK,
METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS,
METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS,
PROFILE_FETCH_RELAY_URLS,
@ -323,18 +324,31 @@ export class ReplaceableEventService { @@ -323,18 +324,31 @@ export class ReplaceableEventService {
needsIndexedDb.push({ pubkey, index })
}
await Promise.allSettled(
needsIndexedDb.map(async ({ pubkey, index }) => {
try {
const event = await indexedDb.getReplaceableEvent(pubkey, kind)
if (event) {
results[index] = event
if (needsIndexedDb.length > 0) {
try {
const orderedPubkeys = needsIndexedDb.map((n) => n.pubkey)
const fromIdb = await indexedDb.getManyReplaceableEvents(orderedPubkeys, kind)
fromIdb.forEach((event, i) => {
if (event && !shouldDropEventOnIngest(event)) {
const slot = needsIndexedDb[i]
if (slot) results[slot.index] = event
}
} catch {
/* ignore */
}
})
)
})
} catch {
await Promise.allSettled(
needsIndexedDb.map(async ({ pubkey, index }) => {
try {
const event = await indexedDb.getReplaceableEvent(pubkey, kind)
if (event && !shouldDropEventOnIngest(event)) {
results[index] = event
}
} catch {
/* ignore */
}
})
)
}
}
const stillMissing = needsIndexedDb.filter(({ index }) => results[index] === undefined)
if (stillMissing.length > 0) {
@ -521,7 +535,8 @@ export class ReplaceableEventService { @@ -521,7 +535,8 @@ export class ReplaceableEventService {
includeFastReadRelays: true,
includeFavoriteRelays: true,
includeLocalRelays: true,
includeFastWriteRelays: false,
/** Many users publish kind 0 to NIP-65 write relays; batch path skipped these before. */
includeFastWriteRelays: true,
includeSearchableRelays: false
})
} catch {
@ -575,19 +590,40 @@ export class ReplaceableEventService { @@ -575,19 +590,40 @@ export class ReplaceableEventService {
// (many `authors` in one filter) that stops the subscription while most profiles are still in flight.
const useReplaceableRace =
!isSlowReplaceableBatch || !multiAuthorBatch
const events = await this.queryService.query(
relayUrls,
{
authors: pubkeys,
kinds: [kind]
},
undefined,
{
replaceableRace: useReplaceableRace,
eoseTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS : 100,
globalTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS : 2000
const queryOpts = {
replaceableRace: useReplaceableRace,
eoseTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS : 100,
globalTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS : 2000
}
let events: NEvent[]
if (kind === kinds.Metadata && pubkeys.length > METADATA_BATCH_AUTHORS_CHUNK) {
const merged: NEvent[] = []
for (let off = 0; off < missingItems.length; off += METADATA_BATCH_AUTHORS_CHUNK) {
const slice = missingItems.slice(off, off + METADATA_BATCH_AUTHORS_CHUNK)
const chunkPubkeys = slice.map((m) => m.pubkey)
const chunkMulti = chunkPubkeys.length > 1
const chunkRace = !isSlowReplaceableBatch || !chunkMulti
const evts = await this.queryService.query(
relayUrls,
{ authors: chunkPubkeys, kinds: [kind] },
undefined,
{ ...queryOpts, replaceableRace: chunkRace }
)
merged.push(...evts)
}
)
events = merged
} else {
events = await this.queryService.query(
relayUrls,
{
authors: pubkeys,
kinds: [kind]
},
undefined,
queryOpts
)
}
// Only log at info level for large batches or if many events found
if (pubkeys.length > 50 || events.length > 100) {
logger.debug('[ReplaceableEventService] Query completed for batch', {
@ -657,6 +693,23 @@ export class ReplaceableEventService { @@ -657,6 +693,23 @@ export class ReplaceableEventService {
}
}
const idbFill = missingItems.filter(({ index }) => results[index] == null)
if (idbFill.length > 0) {
try {
const order = idbFill.map((m) => m.pubkey)
const late = await indexedDb.getManyReplaceableEvents(order, kind)
late.forEach((ev, j) => {
if (!ev || shouldDropEventOnIngest(ev)) return
const slot = idbFill[j]
if (!slot) return
results[slot.index] = ev
eventsMap.set(`${slot.pubkey}:${kind}`, ev)
})
} catch {
/* ignore */
}
}
// Log when no events are found (helps debug relay failures)
if (kind === kinds.Metadata && events.length === 0 && pubkeys.length > 0) {
logger.debug('[ReplaceableEventService] No profile events found from relays', {
@ -1043,7 +1096,27 @@ export class ReplaceableEventService { @@ -1043,7 +1096,27 @@ export class ReplaceableEventService {
async fetchProfilesForPubkeys(pubkeys: string[]): Promise<TProfile[]> {
const deduped = Array.from(new Set(pubkeys.filter((p) => p && p.length === 64)))
if (deduped.length === 0) return []
const events = await this.fetchReplaceableEventsFromProfileFetchRelays(deduped, kinds.Metadata)
let events = await this.fetchReplaceableEventsFromProfileFetchRelays(deduped, kinds.Metadata)
const gapIdx: number[] = []
for (let i = 0; i < deduped.length; i++) {
if (!events[i]) gapIdx.push(i)
}
if (gapIdx.length > 0) {
try {
const order = gapIdx.map((i) => deduped[i]!)
const late = await indexedDb.getManyReplaceableEvents(order, kinds.Metadata)
const patched = [...events]
gapIdx.forEach((origIdx, j) => {
const ev = late[j]
if (ev && !shouldDropEventOnIngest(ev)) {
patched[origIdx] = ev
}
})
events = patched
} catch {
/* ignore */
}
}
const profiles: TProfile[] = []
for (let i = 0; i < deduped.length; i++) {
const ev = events[i]

6
src/services/client.service.ts

@ -1486,10 +1486,11 @@ class ClientService extends EventTarget { @@ -1486,10 +1486,11 @@ class ClientService extends EventTarget {
logger.debug('[PublishEvent] Starting Promise.allSettled for all relays')
const relayPublishAllSettled = Promise.allSettled(
uniqueRelayUrls.map(async (url, index) => {
const startMs = Date.now()
logger.debug(`[PublishEvent] Starting relay ${index + 1}/${uniqueRelayUrls.length}`, { url })
// eslint-disable-next-line @typescript-eslint/no-this-alias
const that = this
await that.queryService.acquireGlobalRelayConnectionSlot()
const startMs = Date.now()
logger.debug(`[PublishEvent] Starting relay ${index + 1}/${uniqueRelayUrls.length}`, { url })
const isLocal = isLocalNetworkUrl(url)
const connectionTimeout = isLocal ? 5_000 : 8_000 // 5s for local, 8s for remote
const publishTimeout = isLocal ? 5_000 : 8_000 // 5s for local, 8s for remote
@ -1623,6 +1624,7 @@ class ClientService extends EventTarget { @@ -1623,6 +1624,7 @@ class ClientService extends EventTarget {
})
that.recordSessionRelayFailure(url)
} finally {
that.queryService.releaseGlobalRelayConnectionSlot()
clearTimeout(relayTimeout)
const currentFinished = ++finishedCount
logger.debug(`[PublishEvent] Relay finished`, {

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

@ -1068,7 +1068,9 @@ class IndexedDbService { @@ -1068,7 +1068,9 @@ class IndexedDbService {
}
private getReplaceableEventKey(pubkey: string, d?: string): string {
return d === undefined ? pubkey : `${pubkey}:${d}`
const trimmed = pubkey.trim()
const canonPk = /^[0-9a-f]{64}$/i.test(trimmed) ? trimmed.toLowerCase() : trimmed
return d === undefined ? canonPk : `${canonPk}:${d}`
}
private getStoreNameByKind(kind: number): string | undefined {

21
src/services/note-stats.service.ts

@ -78,6 +78,8 @@ class NoteStatsService { @@ -78,6 +78,8 @@ class NoteStatsService {
private batchTimeout: NodeJS.Timeout | null = null
/** Prevents overlapping processBatch runs (reentrant calls corrupted pendingEvents). */
private processBatchRunning = false
/** While greater than zero, {@link processBatch} defers so user publishes are not starved for WebSocket pool / bandwidth. */
private publishPriorityDepth = 0
private readonly BATCH_DELAY = 200
/** Small slices so a slow batch does not block newer cards (e.g. spell feed swaps placeholder rows → discussions). */
private readonly MAX_BATCH_SIZE = 8
@ -236,7 +238,26 @@ class NoteStatsService { @@ -236,7 +238,26 @@ class NoteStatsService {
})
}
/** Call around user-initiated {@link client.publishEvent} so stats REQ waves defer briefly. */
beginPublishPriority(): void {
this.publishPriorityDepth++
}
endPublishPriority(): void {
this.publishPriorityDepth = Math.max(0, this.publishPriorityDepth - 1)
}
private async processBatch() {
if (this.publishPriorityDepth > 0) {
if (this.batchTimeout) {
clearTimeout(this.batchTimeout)
}
this.batchTimeout = setTimeout(() => {
this.batchTimeout = null
void this.processBatch()
}, 450)
return
}
if (this.processBatchRunning) {
logger.debug('[NoteStats] processBatch: skipped (already running)', {
pendingForeground: this.pendingForeground.size,

Loading…
Cancel
Save