Browse Source

delay stats fetching until the event is visible

imwald
Silberengel 4 weeks ago
parent
commit
597e7957a4
  1. 5
      src/components/NoteCard/RepostNoteCard.tsx
  2. 3
      src/components/NoteCard/index.tsx
  3. 1
      src/components/NoteList/index.tsx
  4. 29
      src/components/NoteStats/index.tsx
  5. 1
      src/hooks/index.tsx
  6. 32
      src/hooks/useNearViewport.test.ts
  7. 76
      src/hooks/useNearViewport.ts

5
src/components/NoteCard/RepostNoteCard.tsx

@ -15,13 +15,15 @@ export default function RepostNoteCard({ @@ -15,13 +15,15 @@ export default function RepostNoteCard({
className,
filterMutedNotes = true,
pinned = false,
bottomNoteLabel
bottomNoteLabel,
deferAuthorAvatar = true
}: {
event: Event
className?: string
filterMutedNotes?: boolean
pinned?: boolean
bottomNoteLabel?: string
deferAuthorAvatar?: boolean
}) {
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
@ -99,6 +101,7 @@ export default function RepostNoteCard({ @@ -99,6 +101,7 @@ export default function RepostNoteCard({
event={targetEvent}
pinned={pinned}
bottomNoteLabel={bottomNoteLabel}
deferAuthorAvatar={deferAuthorAvatar}
/>
)
}

3
src/components/NoteCard/index.tsx

@ -17,7 +17,7 @@ const NoteCard = memo(function NoteCard({ @@ -17,7 +17,7 @@ const NoteCard = memo(function NoteCard({
zapPollVoteHighlightOption,
bottomNoteLabel,
fetchNoteStatsIfMissing = true,
deferAuthorAvatar = false,
deferAuthorAvatar = true,
searchListPreview = false
}: {
event: Event
@ -54,6 +54,7 @@ const NoteCard = memo(function NoteCard({ @@ -54,6 +54,7 @@ const NoteCard = memo(function NoteCard({
filterMutedNotes={filterMutedNotes}
pinned={pinned}
bottomNoteLabel={bottomNoteLabel}
deferAuthorAvatar={deferAuthorAvatar}
/>
)
}

1
src/components/NoteList/index.tsx

@ -4336,6 +4336,7 @@ const NoteList = forwardRef( @@ -4336,6 +4336,7 @@ const NoteList = forwardRef(
event={event}
filterMutedNotes={filterMutedNotes}
bottomNoteLabel={eventReasonLabelMap.get(event.id)}
deferAuthorAvatar
/>
))
)}

29
src/components/NoteStats/index.tsx

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useNearViewport } from '@/hooks/useNearViewport'
import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints'
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { useRssUrlThreadQueryRelays } from '@/hooks/useRssUrlThreadQueryRelays'
@ -24,6 +25,7 @@ export default function NoteStats({ @@ -24,6 +25,7 @@ export default function NoteStats({
classNames,
fetchIfNotExisting = false,
foregroundStats = false,
deferFetchUntilNearViewport,
useIconOnlyLikeTrigger = false
}: {
event: Event
@ -34,6 +36,11 @@ export default function NoteStats({ @@ -34,6 +36,11 @@ export default function NoteStats({
fetchIfNotExisting?: boolean
/** Jump ahead of spell-feed backlog so counts resolve on the open note / article. */
foregroundStats?: boolean
/**
* When true, {@link fetchNoteStats} waits until the stats row is near the viewport.
* Defaults to on for feed cards (`fetchIfNotExisting` && !`foregroundStats`).
*/
deferFetchUntilNearViewport?: boolean
/**
* Thread rows for kind-7 reactions: like control shows icon + total only (body already shows the reaction glyph).
*/
@ -63,8 +70,14 @@ export default function NoteStats({ @@ -63,8 +70,14 @@ export default function NoteStats({
statsRelaysRef.current = statsRelays
const isZapPoll = event.kind === ExtendedKind.ZAP_POLL
const shouldDeferStatsFetch =
deferFetchUntilNearViewport ?? (fetchIfNotExisting && !foregroundStats)
const containerRef = useRef<HTMLDivElement>(null)
const isNearViewport = useNearViewport(containerRef, { enabled: shouldDeferStatsFetch })
useEffect(() => {
if (!fetchIfNotExisting) return
if (shouldDeferStatsFetch && !isNearViewport) return
setLoading(true)
noteStatsService
.fetchNoteStats(event, pubkey, statsRelaysRef.current, { foreground: foregroundStats })
@ -79,6 +92,8 @@ export default function NoteStats({ @@ -79,6 +92,8 @@ export default function NoteStats({
event.sig,
fetchIfNotExisting,
foregroundStats,
shouldDeferStatsFetch,
isNearViewport,
pubkey,
statsRelayFetchTier,
currentRelaysKey
@ -86,7 +101,12 @@ export default function NoteStats({ @@ -86,7 +101,12 @@ export default function NoteStats({
if (isSmallScreen) {
return (
<div className={cn('select-none', className)} data-note-stats onClick={(e) => e.stopPropagation()}>
<div
ref={containerRef}
className={cn('select-none', className)}
data-note-stats
onClick={(e) => e.stopPropagation()}
>
<div
className={cn(
'flex justify-between items-center h-5 [&_svg]:size-5',
@ -117,7 +137,12 @@ export default function NoteStats({ @@ -117,7 +137,12 @@ export default function NoteStats({
}
return (
<div className={cn('select-none', className)} data-note-stats onClick={(e) => e.stopPropagation()}>
<div
ref={containerRef}
className={cn('select-none', className)}
data-note-stats
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between h-5 [&_svg]:size-4">
<div
className={cn('flex items-center', loading ? 'animate-pulse' : '')}

1
src/hooks/index.tsx

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
export * from './useNearViewport'
export * from './useFetchCalendarRsvps'
export * from './useFetchEvent'
export * from './useFetchFollowings'

32
src/hooks/useNearViewport.test.ts

@ -0,0 +1,32 @@ @@ -0,0 +1,32 @@
import { elementIsNearViewport } from '@/hooks/useNearViewport'
import { describe, expect, it } from 'vitest'
describe('elementIsNearViewport', () => {
it('returns true when element rect overlaps expanded viewport', () => {
const el = {
getBoundingClientRect: () => ({
top: 10,
bottom: 50,
left: 0,
right: 100
})
} as HTMLElement
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true })
Object.defineProperty(window, 'innerWidth', { value: 1200, configurable: true })
expect(elementIsNearViewport(el, 100)).toBe(true)
})
it('returns false when element is far below the fold', () => {
const el = {
getBoundingClientRect: () => ({
top: 2000,
bottom: 2100,
left: 0,
right: 100
})
} as HTMLElement
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true })
Object.defineProperty(window, 'innerWidth', { value: 1200, configurable: true })
expect(elementIsNearViewport(el, 50)).toBe(false)
})
})

76
src/hooks/useNearViewport.ts

@ -0,0 +1,76 @@ @@ -0,0 +1,76 @@
import { useEffect, useLayoutEffect, useState, type RefObject } from 'react'
/** Pixels beyond the viewport edge to treat as visible (prefetch before scroll lands). */
export const NEAR_VIEWPORT_MARGIN_PX = 320
export function elementIsNearViewport(el: HTMLElement, marginPx: number): boolean {
const rect = el.getBoundingClientRect()
const vh = window.innerHeight
const vw = window.innerWidth
return (
rect.bottom >= -marginPx &&
rect.top <= vh + marginPx &&
rect.right >= -marginPx &&
rect.left <= vw + marginPx
)
}
/**
* True when `ref` points at an element intersecting the viewport (with margin).
* When `enabled` is false, returns true immediately (no deferral).
*/
export function useNearViewport(
ref: RefObject<HTMLElement | null>,
options?: { enabled?: boolean; marginPx?: number }
): boolean {
const enabled = options?.enabled !== false
const marginPx = options?.marginPx ?? NEAR_VIEWPORT_MARGIN_PX
const [isNear, setIsNear] = useState(() => !enabled)
useLayoutEffect(() => {
if (!enabled) {
setIsNear(true)
return
}
const el = ref.current
if (!el) {
setIsNear(false)
return
}
if (elementIsNearViewport(el, marginPx)) {
setIsNear(true)
return
}
setIsNear(false)
}, [enabled, marginPx, ref])
useEffect(() => {
if (!enabled || isNear) return
if (typeof IntersectionObserver === 'undefined') {
setIsNear(true)
return
}
let io: IntersectionObserver | null = null
let raf = 0
const attach = () => {
const el = ref.current
if (!el || io) return
io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
setIsNear(true)
}
},
{ root: null, rootMargin: `${marginPx}px`, threshold: 0.01 }
)
io.observe(el)
}
raf = requestAnimationFrame(attach)
return () => {
cancelAnimationFrame(raf)
io?.disconnect()
}
}, [enabled, isNear, marginPx, ref])
return isNear
}
Loading…
Cancel
Save