Browse Source

fix scrolling

imwald
Silberengel 2 weeks ago
parent
commit
34fb8b81df
  1. 77
      src/components/Embedded/EmbeddedNote.tsx
  2. 212
      src/components/NoteList/index.tsx
  3. 2
      src/components/Profile/ProfileMediaFeed.tsx
  4. 4
      src/hooks/useFetchEvent.tsx

77
src/components/Embedded/EmbeddedNote.tsx

@ -183,33 +183,24 @@ function SuppressedLiveStreamEmbed({ noteId, className }: { noteId: string; clas @@ -183,33 +183,24 @@ function SuppressedLiveStreamEmbed({ noteId, className }: { noteId: string; clas
)
}
function EmbeddedNoteContent({
/**
* Fetches and renders an embedded note. Split out so we never call {@link useFetchEvent} with `undefined`
* (skipping fetch for suppressed live naddrs is handled in the parent without that hook).
*/
function EmbeddedNoteFetched({
noteId,
className,
containingEvent,
showFull = false
showFull,
allowLiveEmbeds
}: {
noteId: string
className?: string
containingEvent?: Event
showFull?: boolean
showFull: boolean
allowLiveEmbeds: boolean
}) {
const { showKinds, feedKindFilterBypass } = useKindFilterOrDefaults()
const allowLiveEmbeds = liveActivityKindsEnabledInPicker(showKinds, feedKindFilterBypass)
const naddrTargetsLiveActivityOnly = useMemo(() => {
try {
const dec = nip19.decode(noteId.trim())
if (dec.type !== 'naddr') return false
return LIVE_ACTIVITY_KINDS.includes(dec.data.kind as (typeof LIVE_ACTIVITY_KINDS)[number])
} catch {
return false
}
}, [noteId])
const skipLiveActivityFetch = naddrTargetsLiveActivityOnly && !allowLiveEmbeds
const { event, isFetching } = useFetchEvent(skipLiveActivityFetch ? undefined : noteId)
const { event, isFetching } = useFetchEvent(noteId)
const [retryEvent, setRetryEvent] = useState<Event | undefined>(undefined)
const [isRetrying, setIsRetrying] = useState(false)
const [retryCount, setRetryCount] = useState(0)
@ -217,7 +208,6 @@ function EmbeddedNoteContent({ @@ -217,7 +208,6 @@ function EmbeddedNoteContent({
// If the first fetch fails, try a force retry (max 3 attempts)
useEffect(() => {
if (skipLiveActivityFetch) return
if (!isFetching && !event && !isRetrying && retryCount < maxRetries) {
setIsRetrying(true)
setRetryCount(prev => prev + 1)
@ -240,11 +230,7 @@ function EmbeddedNoteContent({ @@ -240,11 +230,7 @@ function EmbeddedNoteContent({
setIsRetrying(false)
})
}
}, [isFetching, event, noteId, isRetrying, retryCount, skipLiveActivityFetch])
if (skipLiveActivityFetch) {
return <SuppressedLiveStreamEmbed noteId={noteId} className={className} />
}
}, [isFetching, event, noteId, isRetrying, retryCount])
const finalEvent = event || retryEvent
const finalIsFetching = isFetching || (isRetrying && retryCount <= maxRetries)
@ -330,6 +316,47 @@ function EmbeddedNoteContent({ @@ -330,6 +316,47 @@ function EmbeddedNoteContent({
)
}
function EmbeddedNoteContent({
noteId,
className,
containingEvent,
showFull = false
}: {
noteId: string
className?: string
containingEvent?: Event
showFull?: boolean
}) {
const { showKinds, feedKindFilterBypass } = useKindFilterOrDefaults()
const allowLiveEmbeds = liveActivityKindsEnabledInPicker(showKinds, feedKindFilterBypass)
const naddrTargetsLiveActivityOnly = useMemo(() => {
try {
const dec = nip19.decode(noteId.trim())
if (dec.type !== 'naddr') return false
return LIVE_ACTIVITY_KINDS.includes(dec.data.kind as (typeof LIVE_ACTIVITY_KINDS)[number])
} catch {
return false
}
}, [noteId])
const skipLiveActivityFetch = naddrTargetsLiveActivityOnly && !allowLiveEmbeds
if (skipLiveActivityFetch) {
return <SuppressedLiveStreamEmbed noteId={noteId} className={className} />
}
return (
<EmbeddedNoteFetched
noteId={noteId}
className={className}
containingEvent={containingEvent}
showFull={showFull}
allowLiveEmbeds={allowLiveEmbeds}
/>
)
}
function EmbeddedNoteSkeleton({ className }: { className?: string }) {
return (
<div

212
src/components/NoteList/index.tsx

@ -80,7 +80,7 @@ import { @@ -80,7 +80,7 @@ import {
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
import MediaGridItem from '../MediaGridItem'
const LIMIT = 100 // Increased from 200 to load more events per request
const LIMIT = 150 // Per-shard REQ limit for timeline + loadMore (larger batches = fewer round-trips)
const ALGO_LIMIT = 200 // Increased from 500 for algorithm feeds
/** Single-relay explore: kindless REQ cap (relay returns whatever it has, up to this many). */
const RELAY_EXPLORE_LIMIT = SINGLE_RELAY_KINDLESS_REQ_LIMIT
@ -97,7 +97,55 @@ if (import.meta.env.DEV && import.meta.hot) { @@ -97,7 +97,55 @@ if (import.meta.env.DEV && import.meta.hot) {
import.meta.hot.on('vite:beforeUpdate', bumpSuppressRelayEmptyFeedToast)
import.meta.hot.on('vite:beforeFullReload', bumpSuppressRelayEmptyFeedToast)
}
const SHOW_COUNT = 20 // Increased from 10 to show more events at once, reducing scroll load frequency
const SHOW_COUNT = 36 // Initial visible-row quota (filtered); higher = more rows on first paint
/** Extra visible-row quota each time the user reaches the bottom while draining an already-loaded timeline. */
const REVEAL_BATCH_STEP = 96
/**
* One load more chains relay pages until at least this many **new** events (after kind filter + id de-dupe) are
* collected, so sparse kind filters do not feel stuck at ~10 rows per scroll.
*/
const LOAD_MORE_MIN_NEW_EVENTS = 22
const LOAD_MORE_MAX_CHAIN_PAGES = 12
/** Wall-clock cap for chained load-more fetches (sparse filters + slow relays). */
const LOAD_MORE_CHAIN_BUDGET_MS = 5_000
/**
* IntersectionObserver: extend the viewport root downward so the bottom sentinel can fire load-more while the
* user is still well above the physical list end (px).
*/
const LOAD_MORE_IO_ROOT_MARGIN_BOTTOM_PX = 3200
/**
* When the user scrolls down inside the feed scroll container and is within this distance of the bottom (px),
* start load-more (uses viewport height of that container, with a floor).
*/
const LOAD_MORE_SCROLL_PREFETCH_VIEWPORT_MULT = 2.35
const LOAD_MORE_SCROLL_PREFETCH_MIN_PX = 960
/** Min ms between scroll-driven load-more attempts (loadMore also throttles internally). */
const LOAD_MORE_SCROLL_PREFETCH_COOLDOWN_MS = 180
function getNearestScrollableAncestor(node: HTMLElement | null): HTMLElement | null {
if (!node) return null
let el: HTMLElement | null = node.parentElement
while (el && el !== document.documentElement) {
const { overflowY } = getComputedStyle(el)
if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') return el
el = el.parentElement
}
return null
}
function distanceFromScrollBottom(scrollRoot: HTMLElement | Window): number {
if (scrollRoot === window) {
const doc = document.documentElement
return doc.scrollHeight - window.scrollY - window.innerHeight
}
const el = scrollRoot as HTMLElement
return el.scrollHeight - el.scrollTop - el.clientHeight
}
function scrollRootClientHeight(scrollRoot: HTMLElement | Window): number {
return scrollRoot === window ? window.innerHeight : (scrollRoot as HTMLElement).clientHeight
}
/**
* When building visible rows, scan this many merged-timeline events at most. Previously we only looked at the first
* {@link showCount} events then filtered with posts only, kind filters, and mutes, most of those could be hidden
@ -433,7 +481,7 @@ const NoteList = forwardRef( @@ -433,7 +481,7 @@ const NoteList = forwardRef(
oneShotFirstRelayGraceMs,
/** Max events kept after merging one-shot REQ batches (default 100). */
oneShotMergedCap,
/** Initial visible rows and each “reveal more” step when scrolling cached events (default first {@link SHOW_COUNT}, then 2× per step). */
/** Initial visible rows and each “reveal more” step when scrolling cached events (default first {@link SHOW_COUNT}, then {@link REVEAL_BATCH_STEP} per step unless overridden). */
revealBatchSize,
/** When set with {@link oneShotFetch}, logs fetch + filter diagnostics to the console (e.g. faux spells). */
oneShotDebugLabel,
@ -569,10 +617,18 @@ const NoteList = forwardRef( @@ -569,10 +617,18 @@ const NoteList = forwardRef(
displayTimelineSourceRef.current = timelineEventsForFilter
}, [timelineEventsForFilter])
const bottomRef = useRef<HTMLDivElement | null>(null)
/** List root for resolving the feed’s scroll container (desktop primary layout scrolls a div, not `window`). */
const feedRootRef = useRef<HTMLDivElement | null>(null)
const topRef = useRef<HTMLDivElement | null>(null)
const spellFeedFirstPaintLoggedKeyRef = useRef('')
const consecutiveEmptyRef = useRef(0) // Track consecutive empty results to prevent infinite retries
const loadMoreTimeoutRef = useRef<NodeJS.Timeout | null>(null) // Throttle loadMore calls to prevent stuttering
/**
* True when the scan for {@link filteredEvents} reached the end of the loaded timeline but still has fewer
* than {@link showCount} visible rows (aggressive kind/reply/mute filters). {@link loadMore} must not skip
* relay pagination based on raw `events.length - showCount` that difference is not unrevealed buffer.
*/
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[]>([])
@ -968,7 +1024,7 @@ const NoteList = forwardRef( @@ -968,7 +1024,7 @@ const NoteList = forwardRef(
shouldHideEventRef.current = shouldHideEvent
}, [shouldHideEvent])
const filteredEvents = useMemo(() => {
const { items: filteredEvents, bufferExhaustedForVisibleQuota } = useMemo(() => {
const idSet = new Set<string>()
const out: Event[] = []
const target = showCount
@ -977,8 +1033,9 @@ const NoteList = forwardRef( @@ -977,8 +1033,9 @@ const NoteList = forwardRef(
Math.min(MAX_TIMELINE_EVENTS_SCAN_FOR_VISIBLE, Math.max(target * 60, 400))
)
for (let i = 0; i < maxScan && out.length < target; i++) {
const evt = timelineEventsForFilter[i]
let i = 0
for (; i < maxScan && i < timelineEventsForFilter.length && out.length < target; i++) {
const evt = timelineEventsForFilter[i]!
if (applyKindPickerInUi) {
if (!effectiveShowKinds.includes(evt.kind)) continue
if (evt.kind === kinds.ShortTextNote) {
@ -996,7 +1053,9 @@ const NoteList = forwardRef( @@ -996,7 +1053,9 @@ const NoteList = forwardRef(
idSet.add(id)
out.push(evt)
}
return out
const scannedToEndOfBuffer = i >= timelineEventsForFilter.length
const exhausted = out.length < target && scannedToEndOfBuffer
return { items: out, bufferExhaustedForVisibleQuota: exhausted }
}, [
timelineEventsForFilter,
showCount,
@ -1009,6 +1068,10 @@ const NoteList = forwardRef( @@ -1009,6 +1068,10 @@ const NoteList = forwardRef(
applyKindPickerInUi
])
useEffect(() => {
bufferExhaustedForVisibleQuotaRef.current = bufferExhaustedForVisibleQuota
}, [bufferExhaustedForVisibleQuota])
useLayoutEffect(() => {
if (!feedPaintSessionPendingRef.current && !feedPaintRelayPendingRef.current) return
@ -2524,8 +2587,7 @@ const NoteList = forwardRef( @@ -2524,8 +2587,7 @@ const NoteList = forwardRef(
useEffect(() => {
const options: IntersectionObserverInit = {
root: null,
// Trigger when user is 400px from the bottom so we start loading before they reach the end
rootMargin: '0px 0px 400px 0px',
rootMargin: `0px 0px ${LOAD_MORE_IO_ROOT_MARGIN_BOTTOM_PX}px 0px`,
threshold: 0
}
@ -2544,15 +2606,20 @@ const NoteList = forwardRef( @@ -2544,15 +2606,20 @@ const NoteList = forwardRef(
// Show more events immediately if we have them cached
if (currentShowCount < currentEvents.length) {
const remaining = currentEvents.length - currentShowCount
const step = revealBatchSize ?? SHOW_COUNT * 2
const step = revealBatchSize ?? REVEAL_BATCH_STEP
const increment = Math.min(step, remaining)
setShowCount((prev) => prev + increment)
// Only preload more if we have plenty cached (more than 3/4 of LIMIT)
// BUT: Always try to load more if we have very few events (might be due to filtering)
if (currentEvents.length - currentShowCount > LIMIT * 0.75 && currentEvents.length >= 50) {
// `showCount` is a *visible-row quota*, not an offset into the raw merged timeline. Skipping relay
// fetch when `events.length - showCount` is large breaks sparse feeds (e.g. only zap receipts): the
// buffer can hold many raw events while every visible row is already shown — we must still REQ.
const exhausted = bufferExhaustedForVisibleQuotaRef.current
if (
!exhausted &&
currentEvents.length >= 50 &&
currentEvents.length - currentShowCount > LIMIT * 0.75
) {
return
}
// If we have very few events, always try to load more (might be aggressive filtering)
if (currentEvents.length < 50) {
// Continue to loadMore below even if we have cached events
// This ensures we keep loading when filtering is aggressive
@ -2623,32 +2690,47 @@ const NoteList = forwardRef( @@ -2623,32 +2690,47 @@ const NoteList = forwardRef(
return
}
let fetchBatch = newEvents
const narrowLoadMore =
useFilterAsIsRef.current &&
clientSideKindFilterRef.current &&
withKindFilterRef.current &&
!seeAllFeedEventsRef.current &&
(!allowKindlessRelayExploreRef.current || !showAllKindsRef.current)
let toAppend = narrowLoadMore
? fetchBatch.filter((e) => effectiveShowKindsRef.current.includes(e.kind))
: fetchBatch
if (
narrowLoadMore &&
toAppend.length === 0 &&
fetchBatch.length > 0
) {
let skipUntil = Math.min(...fetchBatch.map((e) => e.created_at)) - 1
for (let depth = 0; depth < 8 && toAppend.length === 0; depth++) {
fetchBatch = await client.loadMoreTimeline(latestTimelineKey, skipUntil, LIMIT)
if (fetchBatch.length === 0) break
toAppend = fetchBatch.filter((e) => effectiveShowKindsRef.current.includes(e.kind))
if (toAppend.length > 0) break
skipUntil = Math.min(...fetchBatch.map((e) => e.created_at)) - 1
const existingIds = new Set(latestEvents.map((e) => e.id))
const kindPasses = (e: Event) =>
!narrowLoadMore || effectiveShowKindsRef.current.includes(e.kind)
const noveltyFromBatch = (batch: Event[]) => {
const out: Event[] = []
for (const e of batch) {
if (!kindPasses(e)) continue
if (existingIds.has(e.id)) continue
existingIds.add(e.id)
out.push(e)
}
return out
}
let fetchBatch: Event[] = newEvents
const accumulated: Event[] = noveltyFromBatch(fetchBatch)
const chainDeadlineMs = Date.now() + LOAD_MORE_CHAIN_BUDGET_MS
for (
let chain = 0;
chain < LOAD_MORE_MAX_CHAIN_PAGES && accumulated.length < LOAD_MORE_MIN_NEW_EVENTS;
chain++
) {
if (fetchBatch.length === 0) break
if (Date.now() >= chainDeadlineMs) break
const skipUntil = Math.min(...fetchBatch.map((e) => e.created_at)) - 1
fetchBatch = await client.loadMoreTimeline(latestTimelineKey, skipUntil, LIMIT)
if (fetchBatch.length === 0) break
accumulated.push(...noveltyFromBatch(fetchBatch))
}
const toAppend = accumulated
if (toAppend.length === 0) {
consecutiveEmptyRef.current += 1
const eventCount = latestEvents.length
@ -2726,6 +2808,61 @@ const NoteList = forwardRef( @@ -2726,6 +2808,61 @@ const NoteList = forwardRef(
}, 50) // Reduced delay from 100ms to 50ms for more responsive scrolling
}
let scrollPrefetchTarget: HTMLElement | Window | null = null
let scrollPrefetchRafId = 0
let lastScrollTopForPrefetchDir = 0
let lastScrollPrefetchInvokeMs = 0
const onScrollPrefetch = () => {
if (scrollPrefetchRafId) return
scrollPrefetchRafId = requestAnimationFrame(() => {
scrollPrefetchRafId = 0
const now = Date.now()
if (now - lastScrollPrefetchInvokeMs < LOAD_MORE_SCROLL_PREFETCH_COOLDOWN_MS) return
if (loadingRef.current) return
if (feedFullSearchEventsRef.current !== null) return
const t = scrollPrefetchTarget
if (!t) return
const top = t === window ? window.scrollY : (t as HTMLElement).scrollTop
if (top <= lastScrollTopForPrefetchDir + 6) {
lastScrollTopForPrefetchDir = top
return
}
lastScrollTopForPrefetchDir = top
const ch = scrollRootClientHeight(t)
const threshold = Math.max(
LOAD_MORE_SCROLL_PREFETCH_MIN_PX,
ch * LOAD_MORE_SCROLL_PREFETCH_VIEWPORT_MULT
)
if (distanceFromScrollBottom(t) >= threshold) return
lastScrollPrefetchInvokeMs = now
const ev = eventsRef.current
const sc = showCountRef.current
if (sc < ev.length || hasMoreRef.current) {
loadMore()
}
})
}
const wireScrollPrefetch = () => {
const anchor = feedRootRef.current
const parent = getNearestScrollableAncestor(anchor)
const next: HTMLElement | Window = parent ?? window
if (scrollPrefetchTarget && scrollPrefetchTarget !== next) {
scrollPrefetchTarget.removeEventListener('scroll', onScrollPrefetch)
}
scrollPrefetchTarget = next
lastScrollTopForPrefetchDir =
next === window ? window.scrollY : (next as HTMLElement).scrollTop
next.addEventListener('scroll', onScrollPrefetch, { passive: true })
}
const wireScrollPrefetchSoonId = window.setTimeout(() => {
wireScrollPrefetch()
}, 0)
const observerInstance = new IntersectionObserver((entries) => {
if (!entries[0].isIntersecting || loadingRef.current) return
const ev = eventsRef.current
@ -2742,6 +2879,15 @@ const NoteList = forwardRef( @@ -2742,6 +2879,15 @@ const NoteList = forwardRef(
}
return () => {
if (scrollPrefetchRafId) {
cancelAnimationFrame(scrollPrefetchRafId)
scrollPrefetchRafId = 0
}
window.clearTimeout(wireScrollPrefetchSoonId)
if (scrollPrefetchTarget) {
scrollPrefetchTarget.removeEventListener('scroll', onScrollPrefetch)
scrollPrefetchTarget = null
}
if (observerInstance && currentBottomRef) {
observerInstance.unobserve(currentBottomRef)
}
@ -2751,9 +2897,7 @@ const NoteList = forwardRef( @@ -2751,9 +2897,7 @@ const NoteList = forwardRef(
loadMoreTimeoutRef.current = null
}
}
// Dependencies are handled via refs to avoid stale closures in async callbacks
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}, [timelineSubscriptionKey])
// CRITICAL: Prefetch embedded events (referenced in e tags, a tags, and content)
// This ensures embedded events are ready before user scrolls to them
@ -3232,7 +3376,7 @@ const NoteList = forwardRef( @@ -3232,7 +3376,7 @@ const NoteList = forwardRef(
)
return (
<div>
<div ref={feedRootRef}>
<div ref={topRef} className="scroll-mt-[calc(6rem+1px)]" />
<NoteFeedProfileContext.Provider value={noteFeedProfileContextValue}>
{supportTouch ? (

2
src/components/Profile/ProfileMediaFeed.tsx

@ -133,7 +133,7 @@ const ProfileMediaFeed = forwardRef<TNoteListRef, { pubkey: string }>(({ pubkey @@ -133,7 +133,7 @@ const ProfileMediaFeed = forwardRef<TNoteListRef, { pubkey: string }>(({ pubkey
preserveTimelineOnSubRequestsChange
mergeTimelineWhenSubRequestFiltersMatch
/** Same live {@link client.subscribeTimeline} path as {@link useProfileTimeline} on the Posts tab; filter is native media kinds only. */
revealBatchSize={20}
revealBatchSize={48}
filterMutedNotes={false}
showKind1OPs
showKind1Replies

4
src/hooks/useFetchEvent.tsx

@ -21,7 +21,9 @@ export function useFetchEvent(eventId?: string, initialEvent?: Event) { @@ -21,7 +21,9 @@ export function useFetchEvent(eventId?: string, initialEvent?: Event) {
useEffect(() => {
if (!eventId) {
setIsFetching(false)
setError(new Error('No id provided'))
setEvent(undefined)
// Do not setError here: this effect re-runs when callback deps (e.g. addReplies) change identity;
// allocating a new Error each time would force updates and can exceed React's max update depth.
return
}

Loading…
Cancel
Save