diff --git a/src/components/NoteCard/RepostNoteCard.tsx b/src/components/NoteCard/RepostNoteCard.tsx index 1a392454..6dca60b7 100644 --- a/src/components/NoteCard/RepostNoteCard.tsx +++ b/src/components/NoteCard/RepostNoteCard.tsx @@ -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({ event={targetEvent} pinned={pinned} bottomNoteLabel={bottomNoteLabel} + deferAuthorAvatar={deferAuthorAvatar} /> ) } diff --git a/src/components/NoteCard/index.tsx b/src/components/NoteCard/index.tsx index f5edb315..5ff3de4c 100644 --- a/src/components/NoteCard/index.tsx +++ b/src/components/NoteCard/index.tsx @@ -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({ filterMutedNotes={filterMutedNotes} pinned={pinned} bottomNoteLabel={bottomNoteLabel} + deferAuthorAvatar={deferAuthorAvatar} /> ) } diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 72ad72fe..e81acde2 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -4336,6 +4336,7 @@ const NoteList = forwardRef( event={event} filterMutedNotes={filterMutedNotes} bottomNoteLabel={eventReasonLabelMap.get(event.id)} + deferAuthorAvatar /> )) )} diff --git a/src/components/NoteStats/index.tsx b/src/components/NoteStats/index.tsx index 3c7ab743..dd27ff9c 100644 --- a/src/components/NoteStats/index.tsx +++ b/src/components/NoteStats/index.tsx @@ -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({ classNames, fetchIfNotExisting = false, foregroundStats = false, + deferFetchUntilNearViewport, useIconOnlyLikeTrigger = false }: { event: Event @@ -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({ statsRelaysRef.current = statsRelays const isZapPoll = event.kind === ExtendedKind.ZAP_POLL + const shouldDeferStatsFetch = + deferFetchUntilNearViewport ?? (fetchIfNotExisting && !foregroundStats) + const containerRef = useRef(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({ event.sig, fetchIfNotExisting, foregroundStats, + shouldDeferStatsFetch, + isNearViewport, pubkey, statsRelayFetchTier, currentRelaysKey @@ -86,7 +101,12 @@ export default function NoteStats({ if (isSmallScreen) { return ( -
e.stopPropagation()}> +
e.stopPropagation()} + >
e.stopPropagation()}> +
e.stopPropagation()} + >
{ + 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) + }) +}) diff --git a/src/hooks/useNearViewport.ts b/src/hooks/useNearViewport.ts new file mode 100644 index 00000000..7e7b8ab6 --- /dev/null +++ b/src/hooks/useNearViewport.ts @@ -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, + 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 +}