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(
return { url: cleaned, pubkey: eventPubkey } 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}). */ /** Author custom emoji image URL → slide index in the note lightbox ({@link lightboxSlideFromImeta}). */
type TInlineEmojiLightbox = { type TInlineEmojiLightbox = {
imageIndexMap: Map<string, number> imageIndexMap: Map<string, number>
@ -1925,9 +1932,13 @@ function parseMarkdownContentLegacy(
if (normalizedText) { if (normalizedText) {
const textContent = parseInlineMarkdown(normalizedText, `text-${patternIdx}-para-${paraIdx}-img-${imgIdx}`, footnotes, emojiInfos, undefined, emojiLightbox) const textContent = parseInlineMarkdown(normalizedText, `text-${patternIdx}-para-${paraIdx}-img-${imgIdx}`, footnotes, emojiInfos, undefined, emojiLightbox)
parts.push( 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} {textContent}
</p> </div>
) )
} }
} }
@ -1988,9 +1999,13 @@ function parseMarkdownContentLegacy(
if (normalizedText) { if (normalizedText) {
const textContent = parseInlineMarkdown(normalizedText, `text-${patternIdx}-para-${paraIdx}-final`, footnotes, emojiInfos, undefined, emojiLightbox) const textContent = parseInlineMarkdown(normalizedText, `text-${patternIdx}-para-${paraIdx}-final`, footnotes, emojiInfos, undefined, emojiLightbox)
parts.push( 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} {textContent}
</p> </div>
) )
} }
} }
@ -2009,9 +2024,13 @@ function parseMarkdownContentLegacy(
const paraContent = parseInlineMarkdown(normalizedPara, `text-${patternIdx}-para-${paraIdx}`, footnotes, emojiInfos, undefined, emojiLightbox) 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) // Wrap in paragraph tag (no whitespace-pre-wrap, let normal text wrapping handle it)
parts.push( 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} {paraContent}
</p> </div>
) )
} else if (paraIdx > 0) { } else if (paraIdx > 0) {
// Empty paragraph between non-empty paragraphs - add spacing // Empty paragraph between non-empty paragraphs - add spacing
@ -2446,9 +2465,13 @@ function parseMarkdownContentLegacy(
const paragraphContent = parseInlineMarkdown(paragraphText, `blockquote-${patternIdx}-para-${paraIdx}`, footnotes, emojiInfos, undefined, emojiLightbox) const paragraphContent = parseInlineMarkdown(paragraphText, `blockquote-${patternIdx}-para-${paraIdx}`, footnotes, emojiInfos, undefined, emojiLightbox)
return ( 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} {paragraphContent}
</p> </div>
) )
}) })
@ -2476,12 +2499,12 @@ function parseMarkdownContentLegacy(
}) })
parts.push( parts.push(
<span <div
key={`greentext-${patternIdx}`} key={`greentext-${patternIdx}`}
className="not-prose greentext my-1 block text-[#4a7c3a] dark:text-[#8fbc8f]" className="not-prose greentext my-1 block text-[#4a7c3a] dark:text-[#8fbc8f]"
> >
{greentextContent} {greentextContent}
</span> </div>
) )
} else if (pattern.type === 'fenced-code-block') { } else if (pattern.type === 'fenced-code-block') {
const { code, language } = pattern.data const { code, language } = pattern.data
@ -2735,9 +2758,13 @@ function parseMarkdownContentLegacy(
if (normalizedPara) { if (normalizedPara) {
const paraContent = parseInlineMarkdown(normalizedPara, `text-end-para-${imgIdx}-${paraIdx}`, footnotes, emojiInfos, undefined, emojiLightbox) const paraContent = parseInlineMarkdown(normalizedPara, `text-end-para-${imgIdx}-${paraIdx}`, footnotes, emojiInfos, undefined, emojiLightbox)
parts.push( 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} {paraContent}
</p> </div>
) )
} }
}) })
@ -2791,9 +2818,13 @@ function parseMarkdownContentLegacy(
if (normalizedPara) { if (normalizedPara) {
const paraContent = parseInlineMarkdown(normalizedPara, `text-end-final-para-${paraIdx}`, footnotes, emojiInfos, undefined, emojiLightbox) const paraContent = parseInlineMarkdown(normalizedPara, `text-end-final-para-${paraIdx}`, footnotes, emojiInfos, undefined, emojiLightbox)
parts.push( 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} {paraContent}
</p> </div>
) )
} }
}) })
@ -2810,9 +2841,13 @@ function parseMarkdownContentLegacy(
if (normalizedPara) { if (normalizedPara) {
const paraContent = parseInlineMarkdown(normalizedPara, `text-end-para-${paraIdx}`, footnotes, emojiInfos, undefined, emojiLightbox) const paraContent = parseInlineMarkdown(normalizedPara, `text-end-para-${paraIdx}`, footnotes, emojiInfos, undefined, emojiLightbox)
parts.push( 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} {paraContent}
</p> </div>
) )
} }
}) })
@ -2833,9 +2868,9 @@ function parseMarkdownContentLegacy(
if (!normalizedPara) return null if (!normalizedPara) return null
const paraContent = parseInlineMarkdown(normalizedPara, `text-only-para-${paraIdx}`, footnotes, emojiInfos, undefined, emojiLightbox) const paraContent = parseInlineMarkdown(normalizedPara, `text-only-para-${paraIdx}`, footnotes, emojiInfos, undefined, emojiLightbox)
return ( 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} {paraContent}
</p> </div>
) )
}).filter(Boolean) }).filter(Boolean)
return { nodes: formattedParagraphs, hashtagsInContent, footnotes, citations } return { nodes: formattedParagraphs, hashtagsInContent, footnotes, citations }
@ -2953,9 +2988,9 @@ function parseMarkdownContentLegacy(
// Render the original line with inline markdown processing // Render the original line with inline markdown processing
const lineContent = parseInlineMarkdown(originalLine, `single-list-item-${partIdx}`, footnotes, emojiInfos, undefined, emojiLightbox) const lineContent = parseInlineMarkdown(originalLine, `single-list-item-${partIdx}`, footnotes, emojiInfos, undefined, emojiLightbox)
wrappedParts.push( wrappedParts.push(
<span key={`list-item-content-${partIdx}`}> <div key={`list-item-content-${partIdx}`} className="inline">
{lineContent} {lineContent}
</span> </div>
) )
} else { } else {
// Fallback: render the list item content // Fallback: render the list item content
@ -3451,12 +3486,12 @@ function parseMarkdownContentMarked(
displayMode 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( {renderInlineTokens(
lexInlineProtected(seg.text.trim()), lexInlineProtected(seg.text.trim()),
`${key}-dmt-${idx}` `${key}-dmt-${idx}`
)} )}
</p> </div>
) )
)} )}
</div> </div>
@ -3673,9 +3708,9 @@ function parseMarkdownContentMarked(
} }
return ( 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}`)} {renderInlineTokens(lexInlineProtected(line) as any[], `${key}-line-inline-${lineIdx}`)}
</p> </div>
) )
} }
@ -3704,9 +3739,9 @@ function parseMarkdownContentMarked(
} }
return ( 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}`)} {renderInlineTokens(lexInlineProtected(line) as any[], `${key}-line-fallback-inline-${lineIdx}`)}
</p> </div>
) )
}) })
@ -3729,9 +3764,13 @@ function parseMarkdownContentMarked(
const before = rawParagraphText.slice(cursor, start) const before = rawParagraphText.slice(cursor, start)
if (before.trim().length > 0) { if (before.trim().length > 0) {
nodes.push( 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)} {parseInlineMarkdown(before, `${key}-nostr-raw-segment-${segmentIdx}`, footnotes, emojiInfos, navigateToHashtag, emojiLightbox)}
</p> </div>
) )
} }
if (bech32Id.startsWith('naddr') && fullCalendarInvite && bech32Id === fullCalendarInvite.naddr) { if (bech32Id.startsWith('naddr') && fullCalendarInvite && bech32Id === fullCalendarInvite.naddr) {
@ -3752,9 +3791,13 @@ function parseMarkdownContentMarked(
const after = rawParagraphText.slice(cursor) const after = rawParagraphText.slice(cursor)
if (after.trim().length > 0) { if (after.trim().length > 0) {
nodes.push( 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)} {parseInlineMarkdown(after, `${key}-nostr-raw-segment-${segmentIdx}`, footnotes, emojiInfos, navigateToHashtag, emojiLightbox)}
</p> </div>
) )
} }
if (nodes.length > 0) { if (nodes.length > 0) {
@ -3907,9 +3950,13 @@ function parseMarkdownContentMarked(
const flushInlineSegment = (segmentIdx: number) => { const flushInlineSegment = (segmentIdx: number) => {
if (inlineSegment.length === 0) return if (inlineSegment.length === 0) return
nodes.push( 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}`)} {renderInlineTokens(inlineSegment, `${key}-media-inline-segment-${segmentIdx}`)}
</p> </div>
) )
inlineSegment = [] inlineSegment = []
} }
@ -4015,9 +4062,13 @@ function parseMarkdownContentMarked(
const flushInlineSegment = (segmentIdx: number) => { const flushInlineSegment = (segmentIdx: number) => {
if (inlineSegment.length === 0) return if (inlineSegment.length === 0) return
nodes.push( 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}`)} {renderInlineTokens(inlineSegment, `${key}-nostr-inline-segment-${segmentIdx}`)}
</p> </div>
) )
inlineSegment = [] inlineSegment = []
} }
@ -4070,9 +4121,13 @@ function parseMarkdownContentMarked(
const flushInlineSegment = (segmentIdx: number) => { const flushInlineSegment = (segmentIdx: number) => {
if (inlineSegment.length === 0) return if (inlineSegment.length === 0) return
nodes.push( 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}`)} {renderInlineTokens(inlineSegment, `${key}-yt-inline-segment-${segmentIdx}`)}
</p> </div>
) )
inlineSegment = [] inlineSegment = []
} }
@ -4118,9 +4173,13 @@ function parseMarkdownContentMarked(
const flushInlineSegment = (segmentIdx: number) => { const flushInlineSegment = (segmentIdx: number) => {
if (inlineSegment.length === 0) return if (inlineSegment.length === 0) return
nodes.push( 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}`)} {renderInlineTokens(inlineSegment, `${key}-direct-media-inline-segment-${segmentIdx}`)}
</p> </div>
) )
inlineSegment = [] inlineSegment = []
} }
@ -4182,9 +4241,9 @@ function parseMarkdownContentMarked(
} }
if (!isImage(cleaned) || !isSafeMediaUrl(cleaned)) { if (!isImage(cleaned) || !isSafeMediaUrl(cleaned)) {
return ( 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`)} {renderInlineTokens(paragraphTokens, `${key}-img-inline-fallback`)}
</p> </div>
) )
} }
const imageIdx = imageIndexMap.get(cleaned) const imageIdx = imageIndexMap.get(cleaned)
@ -4208,7 +4267,11 @@ function parseMarkdownContentMarked(
} }
const inlineNodes = renderInlineTokens(paragraphTokens, `${key}-inline`) 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[] => { const renderBlockTokens = (tokens: any[], keyPrefix: string): React.ReactNode[] => {
@ -4448,9 +4511,9 @@ function parseMarkdownContentMarked(
nodes.push(...renderBlockTokens(token.tokens, `${key}-nested`)) nodes.push(...renderBlockTokens(token.tokens, `${key}-nested`))
} else if (typeof token.text === 'string' && token.text.trim()) { } else if (typeof token.text === 'string' && token.text.trim()) {
nodes.push( 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`)} {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'
import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { shouldHideInteractions } from '@/lib/event-filtering' import { shouldHideInteractions } from '@/lib/event-filtering'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useUserTrust } from '@/contexts/user-trust-context'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -15,15 +14,12 @@ const MAX_VISIBLE = 28
*/ */
export default function NoteBoostBadges({ event, className }: { event: Event; className?: string }) { export default function NoteBoostBadges({ event, className }: { event: Event; className?: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const noteStats = useNoteStatsById(event.id) const noteStats = useNoteStatsById(event.id)
const boosters = useMemo(() => { const boosters = useMemo(() => {
if (event.kind === ExtendedKind.DISCUSSION) return [] if (event.kind === ExtendedKind.DISCUSSION) return []
return (noteStats?.reposts ?? []) return [...(noteStats?.reposts ?? [])].sort((a, b) => b.created_at - a.created_at)
.filter((r) => !hideUntrustedInteractions || isUserTrusted(r.pubkey)) }, [noteStats, event.kind])
.sort((a, b) => b.created_at - a.created_at)
}, [noteStats, event.kind, hideUntrustedInteractions, isUserTrusted])
if (shouldHideInteractions(event) || boosters.length === 0) { if (shouldHideInteractions(event) || boosters.length === 0) {
return null return null

7
src/components/NoteCard/MainNoteCard.tsx

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

143
src/components/NoteList/index.tsx

@ -217,6 +217,63 @@ function feedTimelineAlreadyRepresentsNip18Target(targetId: string | undefined,
return false 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( function mergeEventBatchesById(
prev: Event[], prev: Event[],
incoming: Event[], incoming: Event[],
@ -894,30 +951,11 @@ const NoteList = forwardRef(
/** Pending pubkeys sync with rows so useFetchProfile skips per-note fetches before the debounced batch. */ /** Pending pubkeys sync with rows so useFetchProfile skips per-note fetches before the debounced batch. */
useLayoutEffect(() => { useLayoutEffect(() => {
const candidates = new Set<string>() 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) { for (const e of timelineEventsForFilter) {
addPk(e.pubkey) collectProfilePrefetchPubkeysFromEvent(e, candidates)
addPkFromEventTags(e)
} }
for (const e of newEvents) { for (const e of newEvents) {
addPk(e.pubkey) collectProfilePrefetchPubkeysFromEvent(e, candidates)
addPkFromEventTags(e)
} }
setFeedProfileBatch((prev) => { setFeedProfileBatch((prev) => {
@ -1286,8 +1324,34 @@ const NoteList = forwardRef(
[showFeedClientFilter, applyClientFeedFilter, filteredEvents] [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 [feedVirtualScrollParent, setFeedVirtualScrollParent] = useState<HTMLElement | null>(null)
const [feedVirtualScrollMarginTop, setFeedVirtualScrollMarginTop] = useState(0) 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(() => { useLayoutEffect(() => {
const root = feedRootRef.current const root = feedRootRef.current
if (!root) { if (!root) {
@ -1297,7 +1361,7 @@ const NoteList = forwardRef(
} }
setFeedVirtualScrollParent(getNearestScrollableAncestor(root)) setFeedVirtualScrollParent(getNearestScrollableAncestor(root))
setFeedVirtualScrollMarginTop(root.offsetTop) setFeedVirtualScrollMarginTop(root.offsetTop)
}, [timelineSubscriptionKey, refreshCount, clientFilteredEvents.length]) }, [timelineSubscriptionKey, refreshCount])
const clientFilteredNewEvents = useMemo( const clientFilteredNewEvents = useMemo(
() => () =>
@ -1350,28 +1414,14 @@ const NoteList = forwardRef(
const handle = window.setTimeout(() => { const handle = window.setTimeout(() => {
const gen = feedProfileBatchGenRef.current const gen = feedProfileBatchGenRef.current
const candidates = new Set<string>() 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) { for (const e of timelineEventsForFilter) {
addPk(e.pubkey) collectProfilePrefetchPubkeysFromEvent(e, candidates)
addPkFromEventTags(e)
} }
for (const e of newEvents) { for (const e of newEvents) {
addPk(e.pubkey) collectProfilePrefetchPubkeysFromEvent(e, candidates)
addPkFromEventTags(e) }
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)) const need = [...candidates].filter((pk) => !feedProfileLoadedRef.current.has(pk))
@ -1437,7 +1487,7 @@ const NoteList = forwardRef(
})() })()
}, FEED_PROFILE_BATCH_DEBOUNCE_MS) }, FEED_PROFILE_BATCH_DEBOUNCE_MS)
return () => window.clearTimeout(handle) return () => window.clearTimeout(handle)
}, [timelineEventsForFilter, newEvents]) }, [timelineEventsForFilter, newEvents, clientFilteredEvents, showCount, feedStatsProfileBump])
const scrollToTop = useCallback((behavior: ScrollBehavior = 'instant') => { const scrollToTop = useCallback((behavior: ScrollBehavior = 'instant') => {
setTimeout(() => { setTimeout(() => {
@ -3463,6 +3513,7 @@ const NoteList = forwardRef(
) : null} ) : null}
{clientFilteredEvents.length > 0 ? ( {clientFilteredEvents.length > 0 ? (
<VirtualizedFeedRows <VirtualizedFeedRows
key={`${timelineSubscriptionKey}@@${refreshCount}`}
events={clientFilteredEvents} events={clientFilteredEvents}
gridLayout={gridLayout} gridLayout={gridLayout}
filterMutedNotes={filterMutedNotes} filterMutedNotes={filterMutedNotes}
@ -3502,7 +3553,13 @@ const NoteList = forwardRef(
: 'min-h-4' : '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> </div>
) : listSourceEvents.length > 0 ? ( ) : listSourceEvents.length > 0 ? (
<div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div> <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
* Too low causes empty profiles and NIP-05 gaps when relays are slow or many URLs are queried. * 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_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. * 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) {
effectRunCountRef.current.delete(extractedPubkey) effectRunCountRef.current.delete(extractedPubkey)
return return
} }
if (fromBatch?.batchPlaceholder) {
initializedPubkeysRef.current.delete(extractedPubkey)
setProfile(fromBatch)
setPubkey(extractedPubkey)
setIsFetching(false)
setError(null)
}
if (noteFeed.pendingPubkeys.has(extractedPubkey)) { if (noteFeed.pendingPubkeys.has(extractedPubkey)) {
const pkLower = extractedPubkey.toLowerCase() const pkLower = extractedPubkey.toLowerCase()
const sessionEv = eventService.getSessionMetadataForPubkey(pkLower) const sessionEv = eventService.getSessionMetadataForPubkey(pkLower)
@ -450,7 +457,7 @@ export function useFetchProfile(id?: string, skipCache = false) {
// CRITICAL: Early exit if we already have a profile for this pubkey // CRITICAL: Early exit if we already have a profile for this pubkey
// This prevents re-fetching when we already have the profile // 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 // Ensure processingPubkeyRef is set to prevent re-fetch
if (processingPubkeyRef.current !== extractedPubkey) { if (processingPubkeyRef.current !== extractedPubkey) {
processingPubkeyRef.current = extractedPubkey processingPubkeyRef.current = extractedPubkey
@ -561,7 +568,7 @@ export function useFetchProfile(id?: string, skipCache = false) {
processingPubkeyRef.current = extractedPubkey 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)', { logger.debug('[useFetchProfile] Already have profile for this pubkey (safety check)', {
extractedPubkey extractedPubkey
}) })

21
src/providers/NostrProvider/index.tsx

@ -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) }) noteStatsService.beginPublishPriority()
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) })
try { 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) }) logger.debug('[Publish] Calling client.publishEvent()...', { relayCount: relays.length, eventId: event.id?.substring(0, 8) })
const publishResult = await client.publishEvent(relays, event, { favoriteRelayUrls }) const publishResult = await client.publishEvent(relays, event, { favoriteRelayUrls })
logger.debug('[Publish] publishEvent completed', { logger.debug('[Publish] publishEvent completed', {
@ -1383,6 +1384,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
// Re-throw the error so the UI can handle it appropriately // Re-throw the error so the UI can handle it appropriately
throw error throw error
} finally {
noteStatsService.endPublishPriority()
} }
} }

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

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

6
src/services/client.service.ts

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

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

@ -1068,7 +1068,9 @@ class IndexedDbService {
} }
private getReplaceableEventKey(pubkey: string, d?: string): string { 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 { private getStoreNameByKind(kind: number): string | undefined {

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

@ -78,6 +78,8 @@ class NoteStatsService {
private batchTimeout: NodeJS.Timeout | null = null private batchTimeout: NodeJS.Timeout | null = null
/** Prevents overlapping processBatch runs (reentrant calls corrupted pendingEvents). */ /** Prevents overlapping processBatch runs (reentrant calls corrupted pendingEvents). */
private processBatchRunning = false 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 private readonly BATCH_DELAY = 200
/** Small slices so a slow batch does not block newer cards (e.g. spell feed swaps placeholder rows → discussions). */ /** 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 private readonly MAX_BATCH_SIZE = 8
@ -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() { 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) { if (this.processBatchRunning) {
logger.debug('[NoteStats] processBatch: skipped (already running)', { logger.debug('[NoteStats] processBatch: skipped (already running)', {
pendingForeground: this.pendingForeground.size, pendingForeground: this.pendingForeground.size,

Loading…
Cancel
Save