Browse Source

bug-fix

imwald
Silberengel 1 month ago
parent
commit
f4f283c245
  1. 7
      src/PageManager.tsx
  2. 57
      src/components/Embedded/EmbeddedNote.tsx
  3. 6
      src/components/Note/index.tsx
  4. 5
      src/components/NoteCard/MainNoteCard.tsx
  5. 200
      src/components/NoteList/index.tsx
  6. 20
      src/components/ReplyNoteList/index.tsx
  7. 4
      src/constants.ts
  8. 41
      src/hooks/useEmojiInfosForEvent.ts
  9. 82
      src/lib/nip30-author-emojis.ts
  10. 13
      src/pages/secondary/NotePage/index.tsx
  11. 113
      src/services/client-events.service.ts
  12. 15
      src/services/client.service.ts

7
src/PageManager.tsx

@ -474,7 +474,7 @@ export function useSmartNoteNavigation() {
navigationEventStore.setEvent(event) navigationEventStore.setEvent(event)
client.addEventToCache(event) client.addEventToCache(event)
} }
// Pre-cache related events (parent, root, embedded) so NotePage avoids re-fetching // Pre-cache related events (parent, root) and nostr embeds so NotePage avoids skeletons.
if (relatedEvents?.length) { if (relatedEvents?.length) {
for (const ev of relatedEvents) { for (const ev of relatedEvents) {
if (ev && ev !== event) { if (ev && ev !== event) {
@ -483,6 +483,11 @@ export function useSmartNoteNavigation() {
} }
} }
} }
if (event) {
client.prefetchEmbeddedEventsForParents(
[event, ...(relatedEvents ?? []).filter((ev) => ev && ev !== event)]
)
}
// Build contextual URL based on current page // Build contextual URL based on current page
const contextualUrl = buildNoteUrl(noteId, currentPrimaryPage) const contextualUrl = buildNoteUrl(noteId, currentPrimaryPage)

57
src/components/Embedded/EmbeddedNote.tsx

@ -302,21 +302,25 @@ function EmbeddedNoteFetched({
} }
const runParallelFetch = async () => { const runParallelFetch = async () => {
const { fetchRelayOpts: opts, wideRelaysStatic: wide0 } = embedFetchCtxRef.current const { fetchRelayOpts: opts } = embedFetchCtxRef.current
const hex = hexEventIdFromNoteId(noteKey) const hex = hexEventIdFromNoteId(noteKey)
const primary = client.fetchEvent(noteKey, opts) const isUsable = (e: Event) =>
const wide = runWidePass(wide0) !isEventDeletedRef.current(e) && !shouldDropEventOnIngest(e)
const idb = const chosen = await firstResolvedUsableEmbedEvent(
hex && /^[0-9a-f]{64}$/i.test(hex) [
? indexedDb.getEventFromPublicationStore(hex.toLowerCase()).catch(() => undefined) () => client.fetchEvent(noteKey, opts),
: Promise.resolve(undefined) () =>
const [p, w, db] = await Promise.all([primary, wide, idb]) hex && /^[0-9a-f]{64}$/i.test(hex)
? indexedDb
.getEventFromPublicationStore(hex.toLowerCase())
.catch(() => undefined)
: Promise.resolve(undefined)
],
isUsable
)
if (cancelled) return if (cancelled) return
const chosen = pickUsableEvent([p, w, db], isEventDeletedRef.current)
if (chosen) { if (chosen) {
resolve(chosen) resolve(chosen)
setIsFetching(false)
return
} }
setIsFetching(false) setIsFetching(false)
} }
@ -619,15 +623,28 @@ async function loadAsyncEmbedRelayHints(noteId: string, containingEvent?: Event)
return dedupeRelayUrls(hintRelays) return dedupeRelayUrls(hintRelays)
} }
function pickUsableEvent( /** Resolve as soon as any fetch path returns a usable event (do not wait for slow wide-relay fan-out). */
candidates: (Event | undefined)[], function firstResolvedUsableEmbedEvent(
isEventDeleted: (e: Event) => boolean tasks: Array<() => Promise<Event | undefined>>,
): Event | undefined { isUsable: (e: Event) => boolean
for (const e of candidates) { ): Promise<Event | undefined> {
if (!e || isEventDeleted(e) || shouldDropEventOnIngest(e)) continue if (tasks.length === 0) return Promise.resolve(undefined)
return e return new Promise((resolve) => {
} let settled = 0
return undefined let resolved = false
const finish = (ev: Event | undefined) => {
settled++
if (!resolved && ev && isUsable(ev)) {
resolved = true
resolve(ev)
return
}
if (settled === tasks.length && !resolved) resolve(undefined)
}
for (const run of tasks) {
void run().then(finish).catch(() => finish(undefined))
}
})
} }
function EmbeddedNoteSkeleton({ className }: { className?: string }) { function EmbeddedNoteSkeleton({ className }: { className?: string }) {

6
src/components/Note/index.tsx

@ -32,7 +32,7 @@ import type { HighlightData } from '@/components/PostEditor/HighlightEditor'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { isCalendarEventKind } from '@/lib/calendar-event' import { isCalendarEventKind } from '@/lib/calendar-event'
import { mergeTranslatedNote, useNoteTranslation } from '@/lib/note-translation-display' import { mergeTranslatedNote, useNoteTranslation } from '@/lib/note-translation-display'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import {
getWebBookmarkArticleUrl, getWebBookmarkArticleUrl,
@ -269,6 +269,10 @@ export default function Note({
const noteTranslation = useNoteTranslation(event.id) const noteTranslation = useNoteTranslation(event.id)
const displayEvent = useMemo(() => mergeTranslatedNote(event, noteTranslation), [event, noteTranslation]) const displayEvent = useMemo(() => mergeTranslatedNote(event, noteTranslation), [event, noteTranslation])
useLayoutEffect(() => {
client.prefetchEmbeddedEventsForParents([event])
}, [event.id])
const reactionDisplay = useNotificationReactionDisplay(event) const reactionDisplay = useNotificationReactionDisplay(event)
const webReactionParentUrl = useMemo( const webReactionParentUrl = useMemo(
() => () =>

5
src/components/NoteCard/MainNoteCard.tsx

@ -108,7 +108,10 @@ export default function MainNoteCard({
<Pin className="size-4 shrink-0" strokeWidth={1.5} aria-hidden /> <Pin className="size-4 shrink-0" strokeWidth={1.5} aria-hidden />
</div> </div>
)} )}
<Collapsible alwaysExpand={embedded || isCalendarNoteKind}> <Collapsible
alwaysExpand={showFull || isCalendarNoteKind}
{...(embedded && !showFull ? { threshold: 480, collapsedHeight: 220 } : {})}
>
<RepostDescription className={embedded ? '' : notePadX} reposter={reposter} /> <RepostDescription className={embedded ? '' : notePadX} reposter={reposter} />
<Note <Note
className={embedded ? '' : notePadX} className={embedded ? '' : notePadX}

200
src/components/NoteList/index.tsx

@ -9,7 +9,6 @@ import {
SINGLE_RELAY_KINDLESS_REQ_LIMIT SINGLE_RELAY_KINDLESS_REQ_LIMIT
} from '@/constants' } from '@/constants'
import { import {
collectEmbeddedEventPrefetchTargets,
getNip18RepostTargetId, getNip18RepostTargetId,
getReplaceableCoordinateFromEvent, getReplaceableCoordinateFromEvent,
isMentioningMutedUsers, isMentioningMutedUsers,
@ -904,8 +903,6 @@ const NoteList = forwardRef(
* relay pagination based on raw `events.length - showCount` that difference is not unrevealed buffer. * relay pagination based on raw `events.length - showCount` that difference is not unrevealed buffer.
*/ */
const bufferExhaustedForVisibleQuotaRef = useRef(false) const bufferExhaustedForVisibleQuotaRef = useRef(false)
/** Batched profile + embed prefetch after timeline updates (avoids N×9s profile storms while relays stream). */
const timelinePrefetchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const lastEventsForTimelinePrefetchRef = useRef<Event[]>([]) const lastEventsForTimelinePrefetchRef = useRef<Event[]>([])
/** /**
* {@link client.subscribeTimeline} resolves asynchronously; cleanup used to only close via * {@link client.subscribeTimeline} resolves asynchronously; cleanup used to only close via
@ -2860,36 +2857,12 @@ const NoteList = forwardRef(
// Do not wait for full EOSE across many relays — otherwise loading/skeleton stays up for 10–30s+ // Do not wait for full EOSE across many relays — otherwise loading/skeleton stays up for 10–30s+
setLoading(false) setLoading(false)
// Defer profile + embed prefetch: streaming timelines fire onEvents often; starting // Embeds: fetch with each timeline batch (parent relay hints), not on a debounced follow-up.
// fetchProfilesForPubkeys on every update spams relays (multi-second each) and cancels hooks. if (narrowed.length > 0) {
if (timelinePrefetchDebounceRef.current) { client.prefetchEmbeddedEventsForParents(narrowed, {
clearTimeout(timelinePrefetchDebounceRef.current) relayHintsOnly: relayAuthoritativeFeedOnlyRef.current
})
} }
timelinePrefetchDebounceRef.current = setTimeout(() => {
timelinePrefetchDebounceRef.current = null
if (!effectActive) return
if (relayAuthoritativeFeedOnlyRef.current) return
const evs = lastEventsForTimelinePrefetchRef.current
if (evs.length === 0) return
const { hexIds, nip19Pointers } = mergePrefetchTargetsFromEvents(evs.slice(0, 50))
const hexIdsToFetch = hexIds.filter((id) => !prefetchedEventIdsRef.current.has(id))
const nip19ToFetch = nip19Pointers.filter((p) => !prefetchedEventIdsRef.current.has(p))
if (hexIdsToFetch.length > 0 || nip19ToFetch.length > 0) {
hexIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.add(id))
nip19ToFetch.forEach((p) => prefetchedEventIdsRef.current.add(p))
const run = async () => {
try {
await client.prefetchHexEventIds(hexIdsToFetch)
await Promise.all(nip19ToFetch.map((p) => client.fetchEvent(p)))
} catch {
hexIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.delete(id))
nip19ToFetch.forEach((p) => prefetchedEventIdsRef.current.delete(p))
}
}
void run()
}
}, 450)
} else if (eosed) { } else if (eosed) {
setLoading(false) setLoading(false)
} }
@ -3126,10 +3099,6 @@ const NoteList = forwardRef(
clearTimeout(kindlessEoseTimeoutRef.current) clearTimeout(kindlessEoseTimeoutRef.current)
kindlessEoseTimeoutRef.current = null kindlessEoseTimeoutRef.current = null
} }
if (timelinePrefetchDebounceRef.current) {
clearTimeout(timelinePrefetchDebounceRef.current)
timelinePrefetchDebounceRef.current = null
}
const syncClose = timelineEstablishedCloserRef.current const syncClose = timelineEstablishedCloserRef.current
timelineEstablishedCloserRef.current = null timelineEstablishedCloserRef.current = null
syncClose?.() syncClose?.()
@ -3945,32 +3914,8 @@ const NoteList = forwardRef(
// CRITICAL: Prefetch profiles for newly loaded events (optimized to reduce stuttering) // CRITICAL: Prefetch profiles for newly loaded events (optimized to reduce stuttering)
// Only prefetch if we're not currently loading to avoid blocking scroll // Only prefetch if we're not currently loading to avoid blocking scroll
if (toAppend.length > 0 && !loadingRef.current) { if (toAppend.length > 0 && !loadingRef.current) {
// Use requestIdleCallback if available, otherwise setTimeout with longer delay client.prefetchEmbeddedEventsForParents(toAppend.slice(0, 30), {
const schedulePrefetch = (callback: () => void) => { relayHintsOnly: relayAuthoritativeFeedOnlyRef.current
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(callback, { timeout: 500 })
} else {
setTimeout(callback, 300)
}
}
schedulePrefetch(() => {
const { hexIds, nip19Pointers } = mergePrefetchTargetsFromEvents(toAppend.slice(0, 30))
const hexIdsToFetch = hexIds.filter((id) => !prefetchedEventIdsRef.current.has(id))
const nip19ToFetch = nip19Pointers.filter((p) => !prefetchedEventIdsRef.current.has(p))
if (hexIdsToFetch.length === 0 && nip19ToFetch.length === 0) return
hexIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.add(id))
nip19ToFetch.forEach((p) => prefetchedEventIdsRef.current.add(p))
const run = async () => {
try {
await client.prefetchHexEventIds(hexIdsToFetch)
await Promise.all(nip19ToFetch.map((p) => client.fetchEvent(p)))
} catch {
hexIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.delete(id))
nip19ToFetch.forEach((p) => prefetchedEventIdsRef.current.delete(p))
}
}
void run()
}) })
} }
} catch { } catch {
@ -4096,130 +4041,15 @@ const NoteList = forwardRef(
} }
}, [timelineSubscriptionKey]) }, [timelineSubscriptionKey])
// CRITICAL: Prefetch embedded events (referenced in e tags, a tags, and content) // Eager embed prefetch for visible rows (deduped in EventService; ingest also prefetches on add).
// This ensures embedded events are ready before user scrolls to them
const prefetchedEventIdsRef = useRef<Set<string>>(new Set())
const prefetchEmbeddedEventsTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const mergePrefetchTargetsFromEvents = useCallback((evts: Event[]) => {
const hex = new Set<string>()
const nip19 = new Set<string>()
for (const e of evts) {
const t = collectEmbeddedEventPrefetchTargets(e)
t.hexIds.forEach((id) => hex.add(id))
t.nip19Pointers.forEach((p) => nip19.add(p))
}
return { hexIds: Array.from(hex), nip19Pointers: Array.from(nip19) }
}, [])
// CRITICAL: Prefetch embedded events for visible events
useEffect(() => {
// Throttle embedded event prefetching to reduce frequency during rapid scrolling
// Clear any existing timeout
if (prefetchEmbeddedEventsTimeoutRef.current) {
clearTimeout(prefetchEmbeddedEventsTimeoutRef.current)
}
// Debounce embedded event prefetching by 400ms to reduce frequency during rapid scrolling
prefetchEmbeddedEventsTimeoutRef.current = setTimeout(() => {
const visibleTargets = mergePrefetchTargetsFromEvents(clientFilteredEvents.slice(0, 40))
const upcomingTargets = mergePrefetchTargetsFromEvents(events.slice(0, 80))
const hexIds = Array.from(
new Set([...visibleTargets.hexIds, ...upcomingTargets.hexIds])
)
const nip19Pointers = Array.from(
new Set([...visibleTargets.nip19Pointers, ...upcomingTargets.nip19Pointers])
)
const hexIdsToFetch = hexIds.filter((id) => !prefetchedEventIdsRef.current.has(id))
const nip19ToFetch = nip19Pointers.filter((p) => !prefetchedEventIdsRef.current.has(p))
if (hexIdsToFetch.length === 0 && nip19ToFetch.length === 0) return
hexIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.add(id))
nip19ToFetch.forEach((p) => prefetchedEventIdsRef.current.add(p))
const scheduleFetch = (callback: () => void) => {
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(callback, { timeout: 500 })
} else {
setTimeout(callback, 0)
}
}
scheduleFetch(() => {
const run = async () => {
try {
await client.prefetchHexEventIds(hexIdsToFetch)
await Promise.all(nip19ToFetch.map((p) => client.fetchEvent(p)))
} catch {
hexIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.delete(id))
nip19ToFetch.forEach((p) => prefetchedEventIdsRef.current.delete(p))
}
}
void run()
})
}, 400) // Debounce by 400ms to reduce frequency during rapid scrolling
return () => {
if (prefetchEmbeddedEventsTimeoutRef.current) {
clearTimeout(prefetchEmbeddedEventsTimeoutRef.current)
prefetchEmbeddedEventsTimeoutRef.current = null
}
}
}, [clientFilteredEvents, events, mergePrefetchTargetsFromEvents])
// Also prefetch when loading more events (scrolling down)
// Throttled to reduce frequency during rapid scrolling
const prefetchNewEventsTimeoutRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => { useEffect(() => {
if (loading || !hasMore) return if (loading) return
const slice = clientFilteredEvents.slice(0, Math.max(showCount, 40))
// Clear any existing timeout if (slice.length === 0) return
if (prefetchNewEventsTimeoutRef.current) { client.prefetchEmbeddedEventsForParents(slice, {
clearTimeout(prefetchNewEventsTimeoutRef.current) relayHintsOnly: relayAuthoritativeFeedOnlyRef.current
} })
}, [clientFilteredEvents, showCount, loading])
// Debounce embedded-event prefetch for newly revealed rows (profiles use NoteFeed batcher above)
prefetchNewEventsTimeoutRef.current = setTimeout(() => {
const { hexIds, nip19Pointers } = mergePrefetchTargetsFromEvents(
events.slice(showCount, showCount + 50)
)
const hexIdsToFetch = hexIds.filter((id) => !prefetchedEventIdsRef.current.has(id))
const nip19ToFetch = nip19Pointers.filter((p) => !prefetchedEventIdsRef.current.has(p))
if (hexIdsToFetch.length === 0 && nip19ToFetch.length === 0) return
hexIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.add(id))
nip19ToFetch.forEach((p) => prefetchedEventIdsRef.current.add(p))
const scheduleFetch = (callback: () => void) => {
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(callback, { timeout: 500 })
} else {
setTimeout(callback, 0)
}
}
scheduleFetch(() => {
const run = async () => {
try {
await client.prefetchHexEventIds(hexIdsToFetch)
await Promise.all(nip19ToFetch.map((p) => client.fetchEvent(p)))
} catch {
hexIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.delete(id))
nip19ToFetch.forEach((p) => prefetchedEventIdsRef.current.delete(p))
}
}
void run()
})
}, 400) // Debounce by 400ms to reduce frequency during rapid scrolling
return () => {
if (prefetchNewEventsTimeoutRef.current) {
clearTimeout(prefetchNewEventsTimeoutRef.current)
prefetchNewEventsTimeoutRef.current = null
}
}
}, [events.length, showCount, loading, hasMore, mergePrefetchTargetsFromEvents])
const showNewEvents = () => { const showNewEvents = () => {
flushPendingNewEventsIntoTimeline() flushPendingNewEventsIntoTimeline()

20
src/components/ReplyNoteList/index.tsx

@ -2,8 +2,7 @@ import {
E_TAG_FILTER_BLOCKED_RELAY_URLS, E_TAG_FILTER_BLOCKED_RELAY_URLS,
ExtendedKind, ExtendedKind,
NOTE_STATS_OP_REFERENCE_KINDS, NOTE_STATS_OP_REFERENCE_KINDS,
NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT, NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT
THREAD_BACKLINK_STREAM_KINDS
} from '@/constants' } from '@/constants'
import { isDiscussionDownvoteEmoji, isDiscussionUpvoteEmoji } from '@/lib/discussion-votes' import { isDiscussionDownvoteEmoji, isDiscussionUpvoteEmoji } from '@/lib/discussion-votes'
import { import {
@ -223,8 +222,8 @@ function moveReportsToEndPreserveOrder(events: NEvent[]): NEvent[] {
return [...non, ...rep] return [...non, ...rep]
} }
/** Shown after thread replies for E/A roots (quote stream + kind 1 #q-only); matches {@link THREAD_BACKLINK_STREAM_KINDS}. */ /** Shown after thread replies for E/A roots (quote stream + kind 1 #q-only); matches {@link NOTE_STATS_OP_REFERENCE_KINDS}. */
const EA_THREAD_TAIL_REFERENCE_KINDS = new Set<number>(THREAD_BACKLINK_STREAM_KINDS) const EA_THREAD_TAIL_REFERENCE_KINDS = new Set<number>(NOTE_STATS_OP_REFERENCE_KINDS)
function isWebThreadTailKind(kind: number): boolean { function isWebThreadTailKind(kind: number): boolean {
return EA_THREAD_TAIL_REFERENCE_KINDS.has(kind) return EA_THREAD_TAIL_REFERENCE_KINDS.has(kind)
@ -351,7 +350,7 @@ function isKind1QuoteOnlyOfEaRoot(evt: NEvent, root: TRootInfo): boolean {
return kind1QuotesThreadRoot(evt, root) return kind1QuotesThreadRoot(evt, root)
} }
/** E/A roots: #q-only kind 1 + relay “reply” rows for {@link THREAD_BACKLINK_STREAM_KINDS} belong in backlinks tail, not the chronological middle. */ /** E/A roots: #q-only kind 1 + relay “reply” rows for {@link NOTE_STATS_OP_REFERENCE_KINDS} belong in backlinks tail, not the chronological middle. */
function isEaThreadTailBacklinkCandidate(evt: NEvent, root: TRootInfo): boolean { function isEaThreadTailBacklinkCandidate(evt: NEvent, root: TRootInfo): boolean {
if (root.type !== 'E' && root.type !== 'A') return false if (root.type !== 'E' && root.type !== 'A') return false
if (isKind1QuoteOnlyOfEaRoot(evt, root)) return true if (isKind1QuoteOnlyOfEaRoot(evt, root)) return true
@ -1616,7 +1615,16 @@ function ReplyNoteList({
}, 1500) }, 1500)
}, []) }, [])
const visibleFeed = mergedFeed.slice(0, showCount) /** Paginate replies only; always show the backlinks tail (quotes, highlights, bookmarks, …). */
const visibleFeed = useMemo(() => {
const backlinks: NEvent[] = []
const main: NEvent[] = []
for (const item of mergedFeed) {
if (quoteUiIdSet.has(item.id)) backlinks.push(item)
else main.push(item)
}
return [...main.slice(0, showCount), ...backlinks]
}, [mergedFeed, showCount, quoteUiIdSet])
const shouldShowFeedItem = useCallback( const shouldShowFeedItem = useCallback(
(item: NEvent) => { (item: NEvent) => {

4
src/constants.ts

@ -264,6 +264,10 @@ export const PROFILE_BATCH_NETWORK_LOAD_TIMEOUT_MS = 12_000
export const SINGLE_EVENT_BY_ID_QUERY_EOSE_TIMEOUT_MS = 5_000 export const SINGLE_EVENT_BY_ID_QUERY_EOSE_TIMEOUT_MS = 5_000
export const SINGLE_EVENT_BY_ID_QUERY_GLOBAL_TIMEOUT_MS = 28_000 export const SINGLE_EVENT_BY_ID_QUERY_GLOBAL_TIMEOUT_MS = 28_000
/** Parent-tag / seen-on relay hints only — before big-relay fan-out ({@link EventService._fetchEvent}). */
export const HINTED_EVENT_FETCH_EOSE_TIMEOUT_MS = 2_000
export const HINTED_EVENT_FETCH_GLOBAL_TIMEOUT_MS = 5_000
/** Wide REQ for embeds / explicit external lists ({@link EventService.fetchEventWithExternalRelays}). */ /** Wide REQ for embeds / explicit external lists ({@link EventService.fetchEventWithExternalRelays}). */
export const EXTERNAL_RELAY_EVENT_FETCH_EOSE_TIMEOUT_MS = 14_000 export const EXTERNAL_RELAY_EVENT_FETCH_EOSE_TIMEOUT_MS = 14_000
export const EXTERNAL_RELAY_EVENT_FETCH_GLOBAL_TIMEOUT_MS = 40_000 export const EXTERNAL_RELAY_EVENT_FETCH_GLOBAL_TIMEOUT_MS = 40_000

41
src/hooks/useEmojiInfosForEvent.ts

@ -1,18 +1,21 @@
import { EMOJI_SHORT_CODE_REGEX } from '@/lib/content-patterns' import { EMOJI_SHORT_CODE_REGEX } from '@/lib/content-patterns'
import { import {
EMPTY_AUTHOR_NIP30_EMOJIS,
fetchAuthorNip30EmojiInfos, fetchAuthorNip30EmojiInfos,
fetchAuthorNip30EmojiInfosFromIndexedDb fetchAuthorNip30EmojiInfosFromIndexedDb,
getAuthorNip30EmojiCache,
subscribeAuthorNip30EmojiCache
} from '@/lib/nip30-author-emojis' } from '@/lib/nip30-author-emojis'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag' import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import { TEmoji } from '@/types' import { TEmoji } from '@/types'
import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji' import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji'
import { type Event } from 'nostr-tools' import { type Event } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useSyncExternalStore } from 'react'
/** Event `emoji` tags override the same shortcode from the author's kind 0. */ /** Event `emoji` tags override the same shortcode from the author's kind 0. */
export function mergeEmojiInfosEventOverridesAuthor( export function mergeEmojiInfosEventOverridesAuthor(
fromAuthor: TEmoji[], fromAuthor: readonly TEmoji[],
fromEvent: TEmoji[] fromEvent: readonly TEmoji[]
): TEmoji[] { ): TEmoji[] {
const m = new Map<string, TEmoji>() const m = new Map<string, TEmoji>()
for (const e of fromAuthor) m.set(e.shortcode, e) for (const e of fromAuthor) m.set(e.shortcode, e)
@ -51,31 +54,17 @@ export function useEmojiInfosForEvent(event: Event | undefined | null): TEmoji[]
const pubkey = event?.pubkey?.trim().toLowerCase() ?? '' const pubkey = event?.pubkey?.trim().toLowerCase() ?? ''
const validPk = /^[0-9a-f]{64}$/.test(pubkey) const validPk = /^[0-9a-f]{64}$/.test(pubkey)
const [fromAuthor, setFromAuthor] = useState<TEmoji[]>([]) const fromAuthor = useSyncExternalStore(
(onStoreChange) =>
validPk && needsLookup ? subscribeAuthorNip30EmojiCache(pubkey, onStoreChange) : () => {},
() => (validPk && needsLookup ? getAuthorNip30EmojiCache(pubkey) : EMPTY_AUTHOR_NIP30_EMOJIS),
() => EMPTY_AUTHOR_NIP30_EMOJIS
)
useEffect(() => { useEffect(() => {
if (!needsLookup || !validPk) { if (!needsLookup || !validPk) return
setFromAuthor([]) void fetchAuthorNip30EmojiInfosFromIndexedDb(pubkey)
return
}
let cancelled = false
let fullResolved = false
void fetchAuthorNip30EmojiInfosFromIndexedDb(pubkey).then((infos) => {
if (cancelled || fullResolved) return
setFromAuthor(infos)
})
void fetchAuthorNip30EmojiInfos(pubkey) void fetchAuthorNip30EmojiInfos(pubkey)
.then((infos) => {
if (cancelled) return
fullResolved = true
setFromAuthor(infos)
})
.catch(() => {
fullResolved = true
})
return () => {
cancelled = true
}
}, [needsLookup, validPk, pubkey]) }, [needsLookup, validPk, pubkey])
return useMemo( return useMemo(

82
src/lib/nip30-author-emojis.ts

@ -89,16 +89,78 @@ async function loadAuthorNip30FromIndexedDbUncached(pubkey: string): Promise<TEm
const inflightAuthorEmoji = new Map<string, Promise<TEmoji[]>>() const inflightAuthorEmoji = new Map<string, Promise<TEmoji[]>>()
const inflightAuthorEmojiIdb = new Map<string, Promise<TEmoji[]>>() const inflightAuthorEmojiIdb = new Map<string, Promise<TEmoji[]>>()
/** Shared author inventory so every mounted note row updates when NIP-30 emoji loads. */
const authorEmojiCache = new Map<string, TEmoji[]>()
const authorEmojiListeners = new Map<string, Set<() => void>>()
/** Stable empty snapshot for {@link useSyncExternalStore} (must not allocate `[]` per read). */
export const EMPTY_AUTHOR_NIP30_EMOJIS: readonly TEmoji[] = []
function authorEmojiListsEqual(a: readonly TEmoji[], b: readonly TEmoji[]): boolean {
if (a === b) return true
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
if (a[i].shortcode !== b[i].shortcode || a[i].url !== b[i].url) return false
}
return true
}
function publishAuthorEmojiCache(pk: string, infos: TEmoji[]) {
if (infos.length === 0) return
const prev = authorEmojiCache.get(pk)
if (prev && authorEmojiListsEqual(prev, infos)) return
authorEmojiCache.set(pk, infos)
authorEmojiListeners.get(pk)?.forEach((fn) => fn())
}
export function getAuthorNip30EmojiCache(pubkey: string): readonly TEmoji[] {
const pk = pubkey.trim().toLowerCase()
return authorEmojiCache.get(pk) ?? EMPTY_AUTHOR_NIP30_EMOJIS
}
export function subscribeAuthorNip30EmojiCache(pubkey: string, onStoreChange: () => void): () => void {
const pk = pubkey.trim().toLowerCase()
let set = authorEmojiListeners.get(pk)
if (!set) {
set = new Set()
authorEmojiListeners.set(pk, set)
}
set.add(onStoreChange)
return () => {
set!.delete(onStoreChange)
if (set!.size === 0) authorEmojiListeners.delete(pk)
}
}
/** Start NIP-30 emoji inventory loads for authors (deduped; updates {@link getAuthorNip30EmojiCache}). */
export function prefetchAuthorNip30EmojisForPubkeys(pubkeys: readonly string[]): void {
for (const raw of pubkeys) {
const pk = raw.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(pk)) continue
if (authorEmojiCache.has(pk)) continue
void fetchAuthorNip30EmojiInfosFromIndexedDb(pk).then((infos) => publishAuthorEmojiCache(pk, infos))
void fetchAuthorNip30EmojiInfos(pk).then((infos) => publishAuthorEmojiCache(pk, infos))
}
}
export function fetchAuthorNip30EmojiInfos(pubkey: string): Promise<TEmoji[]> { export function fetchAuthorNip30EmojiInfos(pubkey: string): Promise<TEmoji[]> {
const pk = pubkey.trim().toLowerCase() const pk = pubkey.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(pk)) return Promise.resolve([]) if (!/^[0-9a-f]{64}$/.test(pk)) return Promise.resolve([])
const cached = authorEmojiCache.get(pk)
if (cached?.length) return Promise.resolve(cached)
const existing = inflightAuthorEmoji.get(pk) const existing = inflightAuthorEmoji.get(pk)
if (existing) return existing if (existing) return existing
const p = loadAuthorNip30EmojiInfosUncached(pk).finally(() => { const p = loadAuthorNip30EmojiInfosUncached(pk)
if (inflightAuthorEmoji.get(pk) === p) inflightAuthorEmoji.delete(pk) .then((infos) => {
}) publishAuthorEmojiCache(pk, infos)
return infos
})
.finally(() => {
if (inflightAuthorEmoji.get(pk) === p) inflightAuthorEmoji.delete(pk)
})
inflightAuthorEmoji.set(pk, p) inflightAuthorEmoji.set(pk, p)
return p return p
} }
@ -108,12 +170,20 @@ export function fetchAuthorNip30EmojiInfosFromIndexedDb(pubkey: string): Promise
const pk = pubkey.trim().toLowerCase() const pk = pubkey.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(pk)) return Promise.resolve([]) if (!/^[0-9a-f]{64}$/.test(pk)) return Promise.resolve([])
const cached = authorEmojiCache.get(pk)
if (cached?.length) return Promise.resolve(cached)
const existing = inflightAuthorEmojiIdb.get(pk) const existing = inflightAuthorEmojiIdb.get(pk)
if (existing) return existing if (existing) return existing
const p = loadAuthorNip30FromIndexedDbUncached(pk).finally(() => { const p = loadAuthorNip30FromIndexedDbUncached(pk)
if (inflightAuthorEmojiIdb.get(pk) === p) inflightAuthorEmojiIdb.delete(pk) .then((infos) => {
}) publishAuthorEmojiCache(pk, infos)
return infos
})
.finally(() => {
if (inflightAuthorEmojiIdb.get(pk) === p) inflightAuthorEmojiIdb.delete(pk)
})
inflightAuthorEmojiIdb.set(pk, p) inflightAuthorEmojiIdb.set(pk, p)
return p return p
} }

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

@ -17,7 +17,6 @@ import { useNostr } from '@/providers/NostrProvider'
import noteStatsService from '@/services/note-stats.service' import noteStatsService from '@/services/note-stats.service'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { import {
collectEmbeddedEventPrefetchTargets,
getParentBech32Id, getParentBech32Id,
getParentETag, getParentETag,
getParentEventHexId, getParentEventHexId,
@ -35,7 +34,7 @@ import { cn } from '@/lib/utils'
import { Ellipsis } from 'lucide-react' import { Ellipsis } from 'lucide-react'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { kinds, nip19 } from 'nostr-tools' import { kinds, nip19 } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useMemo, useState, type MouseEvent } from 'react' import { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useState, type MouseEvent } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { NOSTR_URI_NADDR_REGEX } from '@/lib/content-patterns' import { NOSTR_URI_NADDR_REGEX } from '@/lib/content-patterns'
import { import {
@ -228,14 +227,10 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
void client.fetchProfilesForPubkeys([pk]) void client.fetchProfilesForPubkeys([pk])
}, [finalEvent?.id, finalEvent?.pubkey]) }, [finalEvent?.id, finalEvent?.pubkey])
/** Warm session cache so markdown/embed cards resolve before each {@link EmbeddedNote} mounts. */ /** Resolve nostr embeds with the open note (parent relay hints), before embed cards mount. */
useEffect(() => { useLayoutEffect(() => {
if (!finalEvent) return if (!finalEvent) return
const { hexIds, nip19Pointers } = collectEmbeddedEventPrefetchTargets(finalEvent) client.prefetchEmbeddedEventsForParents([finalEvent])
if (hexIds.length > 0) void client.prefetchHexEventIds(hexIds)
for (const pointer of nip19Pointers) {
void client.fetchEvent(pointer)
}
}, [finalEvent?.id]) }, [finalEvent?.id])
const getNoteTypeTitle = (kind: number): string => { const getNoteTypeTitle = (kind: number): string => {

113
src/services/client-events.service.ts

@ -3,6 +3,8 @@ import {
ExtendedKind, ExtendedKind,
EXTERNAL_RELAY_EVENT_FETCH_EOSE_TIMEOUT_MS, EXTERNAL_RELAY_EVENT_FETCH_EOSE_TIMEOUT_MS,
EXTERNAL_RELAY_EVENT_FETCH_GLOBAL_TIMEOUT_MS, EXTERNAL_RELAY_EVENT_FETCH_GLOBAL_TIMEOUT_MS,
HINTED_EVENT_FETCH_EOSE_TIMEOUT_MS,
HINTED_EVENT_FETCH_GLOBAL_TIMEOUT_MS,
isDocumentRelayKind, isDocumentRelayKind,
NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT, NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT,
SINGLE_EVENT_BY_ID_QUERY_EOSE_TIMEOUT_MS, SINGLE_EVENT_BY_ID_QUERY_EOSE_TIMEOUT_MS,
@ -10,6 +12,7 @@ import {
} from '@/constants' } from '@/constants'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { import {
collectEmbeddedEventPrefetchTargets,
getParentATag, getParentATag,
getParentETag, getParentETag,
getQuotedReferenceFromQTags, getQuotedReferenceFromQTags,
@ -21,7 +24,8 @@ import {
isReplyNoteEvent, isReplyNoteEvent,
isReplaceableEvent, isReplaceableEvent,
kind1QuotesThreadRoot, kind1QuotesThreadRoot,
normalizeReplaceableCoordinateString normalizeReplaceableCoordinateString,
relayHintWssUrlsFromEvent
} from '@/lib/event' } from '@/lib/event'
import { getFirstHexEventIdFromETags, tagNameEquals } from '@/lib/tag' import { getFirstHexEventIdFromETags, tagNameEquals } from '@/lib/tag'
import type { Event as NEvent, Filter } from 'nostr-tools' import type { Event as NEvent, Filter } from 'nostr-tools'
@ -95,6 +99,21 @@ async function buildComprehensiveRelayListForEvents(
const PREFETCH_HEX_IDS_CHUNK = 48 const PREFETCH_HEX_IDS_CHUNK = 48
/** Parent kinds that often embed `nostr:…` notes — prefetch targets on ingest with the parent. */
const EMBEDDED_NOTE_PREFETCH_ON_INGEST_KINDS = new Set<number>([
kinds.ShortTextNote,
kinds.LongFormArticle,
kinds.Highlights,
kinds.Repost,
ExtendedKind.GENERIC_REPOST,
ExtendedKind.PUBLICATION_CONTENT,
ExtendedKind.WIKI_ARTICLE,
ExtendedKind.WIKI_ARTICLE_MARKDOWN,
ExtendedKind.COMMENT,
ExtendedKind.VOICE_COMMENT,
ExtendedKind.DISCUSSION
])
/** Cap session LRU scan per note-stats target — cache iterates newest-first; avoids O(session)×batch stalls. */ /** Cap session LRU scan per note-stats target — cache iterates newest-first; avoids O(session)×batch stalls. */
const NOTE_STATS_SESSION_PREMERGE_SCAN_MAX = 6000 const NOTE_STATS_SESSION_PREMERGE_SCAN_MAX = 6000
@ -433,11 +452,71 @@ export class EventService {
return this.fetchEvent(eventId, opts) return this.fetchEvent(eventId, opts)
} }
private readonly embeddedPrefetchHexScheduled = new Set<string>()
private readonly embeddedPrefetchNip19Scheduled = new Set<string>()
/**
* Resolve embed targets from parent notes immediately (same relay hints as the parent).
* Dedupes across feed batches / Note mounts; notifies session waiters when hits land.
*/
prefetchEmbeddedEventsForParents(
parents: readonly NEvent[],
opts?: { relayHintsOnly?: boolean }
): void {
if (parents.length === 0) return
const hexSet = new Set<string>()
const nip19Set = new Set<string>()
const relayHintSet = new Set<string>()
for (const parent of parents) {
const { hexIds, nip19Pointers } = collectEmbeddedEventPrefetchTargets(parent)
for (const id of hexIds) hexSet.add(id)
for (const p of nip19Pointers) nip19Set.add(p)
for (const url of relayHintWssUrlsFromEvent(parent)) {
const n = normalizeUrl(url)
if (n) relayHintSet.add(n)
}
}
const hexIds = [...hexSet].filter((id) => {
if (this.getSessionEventIfAllowed(id)) return false
if (this.embeddedPrefetchHexScheduled.has(id)) return false
this.embeddedPrefetchHexScheduled.add(id)
return true
})
const nip19Pointers = [...nip19Set].filter((p) => {
if (this.embeddedPrefetchNip19Scheduled.has(p)) return false
this.embeddedPrefetchNip19Scheduled.add(p)
return true
})
if (hexIds.length === 0 && nip19Pointers.length === 0) return
const relayHints = [...relayHintSet]
const fetchOpts = relayHints.length > 0 ? { relayHints } : undefined
void (async () => {
try {
if (hexIds.length > 0) {
await this.prefetchHexEventIds(hexIds, { relayHints, relayHintsOnly: opts?.relayHintsOnly })
}
await Promise.all(
nip19Pointers.map((pointer) => this.fetchEvent(pointer, fetchOpts))
)
} catch {
for (const id of hexIds) this.embeddedPrefetchHexScheduled.delete(id)
for (const p of nip19Pointers) this.embeddedPrefetchNip19Scheduled.delete(p)
}
})()
}
/** /**
* Batch-prefetch events by hex id into session cache (single REQ per chunk). * Batch-prefetch events by hex id into session cache (single REQ per chunk).
* Used by feeds so embedded notes resolve without N parallel fetches. * Used by feeds so embedded notes resolve without N parallel fetches.
*/ */
async prefetchHexEventIds(rawIds: readonly string[]): Promise<void> { async prefetchHexEventIds(
rawIds: readonly string[],
opts?: { relayHints?: string[]; relayHintsOnly?: boolean }
): Promise<void> {
const hexIds = [ const hexIds = [
...new Set( ...new Set(
rawIds rawIds
@ -455,7 +534,13 @@ export class EventService {
toFetch = toFetch.filter((id) => !this.getSessionEventIfAllowed(id)) toFetch = toFetch.filter((id) => !this.getSessionEventIfAllowed(id))
if (toFetch.length === 0) return if (toFetch.length === 0) return
const relayUrls = await buildComprehensiveRelayListForEvents(undefined, [], [], []) const hints = (opts?.relayHints ?? [])
.map((u) => normalizeUrl(u))
.filter((u): u is string => Boolean(u))
const relayUrls =
opts?.relayHintsOnly && hints.length > 0
? [...new Set(hints)]
: await buildComprehensiveRelayListForEvents(undefined, hints, hints, hints)
if (!relayUrls.length) return if (!relayUrls.length) return
for (let i = 0; i < toFetch.length; i += PREFETCH_HEX_IDS_CHUNK) { for (let i = 0; i < toFetch.length; i += PREFETCH_HEX_IDS_CHUNK) {
@ -466,8 +551,8 @@ export class EventService {
undefined, undefined,
{ {
immediateReturn: false, immediateReturn: false,
eoseTimeout: 2500, eoseTimeout: hints.length > 0 ? 1800 : 2500,
globalTimeout: 12000 globalTimeout: hints.length > 0 ? 8000 : 12000
} }
) )
for (const ev of events) { for (const ev of events) {
@ -581,6 +666,9 @@ export class EventService {
void client.prefetchAuthorCoreReplaceables([pk.toLowerCase()]) void client.prefetchAuthorCoreReplaceables([pk.toLowerCase()])
} }
} }
if (EMBEDDED_NOTE_PREFETCH_ON_INGEST_KINDS.has(cleanEvent.kind)) {
this.prefetchEmbeddedEventsForParents([cleanEvent as NEvent])
}
this.notifySessionEventWaiters(id) this.notifySessionEventWaiters(id)
this.notifyReplaceableCoordinateWaiters(cleanEvent as NEvent) this.notifyReplaceableCoordinateWaiters(cleanEvent as NEvent)
queuePersistSeenEvent(cleanEvent as NEvent) queuePersistSeenEvent(cleanEvent as NEvent)
@ -1156,6 +1244,21 @@ export class EventService {
} }
} }
if (relays.length > 0) {
const hintedEvents = await this.queryService.query(relays, filter, undefined, {
immediateReturn: true,
eoseTimeout: HINTED_EVENT_FETCH_EOSE_TIMEOUT_MS,
globalTimeout: HINTED_EVENT_FETCH_GLOBAL_TIMEOUT_MS
})
const hinted = hintedEvents
.filter((e) => !shouldDropEventOnIngest(e, ingestOpts))
.sort((a, b) => b.created_at - a.created_at)[0]
if (hinted) {
this.addEventToCache(hinted, ingestOpts)
return hinted
}
}
// Try big relays first (uses user's inboxes + defaults) // Try big relays first (uses user's inboxes + defaults)
if (filter.ids?.length) { if (filter.ids?.length) {
const event = await this.fetchEventFromBigRelaysDataloader.load(filter.ids[0]) const event = await this.fetchEventFromBigRelaysDataloader.load(filter.ids[0])

15
src/services/client.service.ts

@ -3433,8 +3433,19 @@ class ClientService extends EventTarget {
} }
/** Batch-prefetch by hex id into session cache (feed embeds). */ /** Batch-prefetch by hex id into session cache (feed embeds). */
async prefetchHexEventIds(hexIds: readonly string[]): Promise<void> { async prefetchHexEventIds(
return this.eventService.prefetchHexEventIds(hexIds) hexIds: readonly string[],
opts?: { relayHints?: string[]; relayHintsOnly?: boolean }
): Promise<void> {
return this.eventService.prefetchHexEventIds(hexIds, opts)
}
/** Prefetch nostr embeds referenced by parent notes (with parent relay hints). */
prefetchEmbeddedEventsForParents(
parents: readonly NEvent[],
opts?: { relayHintsOnly?: boolean }
): void {
this.eventService.prefetchEmbeddedEventsForParents(parents, opts)
} }
async fetchEventWithExternalRelays(eventId: string, externalRelays: string[]): Promise<NEvent | undefined> { async fetchEventWithExternalRelays(eventId: string, externalRelays: string[]): Promise<NEvent | undefined> {

Loading…
Cancel
Save