10 changed files with 460 additions and 151 deletions
@ -0,0 +1,156 @@
@@ -0,0 +1,156 @@
|
||||
import NoteCard from '@/components/NoteCard' |
||||
import MediaGridItem from '@/components/MediaGridItem' |
||||
import { useVirtualizer, useWindowVirtualizer } from '@tanstack/react-virtual' |
||||
import type { Event } from 'nostr-tools' |
||||
import { memo } from 'react' |
||||
|
||||
const ESTIMATE_NOTE_ROW_PX = 280 |
||||
const ESTIMATE_GRID_ROW_PX = 120 |
||||
const VIRTUAL_OVERSCAN = 10 |
||||
|
||||
export type VirtualizedFeedRowsProps = { |
||||
events: Event[] |
||||
gridLayout: boolean |
||||
filterMutedNotes: boolean |
||||
eventReasonLabelMap: Map<string, string> |
||||
/** When true, list scrolls with `window`; otherwise `scrollElement` must be set. */ |
||||
useWindowScroll: boolean |
||||
scrollElement: HTMLElement | null |
||||
/** Document offset of the list root (window virtualizer scroll margin). */ |
||||
scrollMarginTop: number |
||||
} |
||||
|
||||
const WindowRows = memo(function WindowRows({ |
||||
events, |
||||
gridLayout, |
||||
filterMutedNotes, |
||||
eventReasonLabelMap, |
||||
scrollMarginTop |
||||
}: Omit<VirtualizedFeedRowsProps, 'useWindowScroll' | 'scrollElement'>) { |
||||
const rowCount = gridLayout ? Math.ceil(events.length / 3) : events.length |
||||
const virtualizer = useWindowVirtualizer({ |
||||
count: rowCount, |
||||
estimateSize: () => (gridLayout ? ESTIMATE_GRID_ROW_PX : ESTIMATE_NOTE_ROW_PX), |
||||
overscan: VIRTUAL_OVERSCAN, |
||||
scrollMargin: scrollMarginTop, |
||||
getItemKey: (index) => (gridLayout ? `grid-${index}` : (events[index]?.id ?? `i-${index}`)) |
||||
}) |
||||
|
||||
return ( |
||||
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}> |
||||
{virtualizer.getVirtualItems().map((vi) => ( |
||||
<div |
||||
key={vi.key} |
||||
data-index={vi.index} |
||||
ref={virtualizer.measureElement} |
||||
className="absolute left-0 top-0 w-full" |
||||
style={{ transform: `translateY(${vi.start}px)` }} |
||||
> |
||||
{gridLayout ? ( |
||||
<div className="grid grid-cols-3 gap-0.5 pr-4"> |
||||
{events.slice(vi.index * 3, vi.index * 3 + 3).map((event) => ( |
||||
<MediaGridItem key={event.id} event={event} /> |
||||
))} |
||||
</div> |
||||
) : ( |
||||
<NoteCard |
||||
className="w-full" |
||||
event={events[vi.index]!} |
||||
filterMutedNotes={filterMutedNotes} |
||||
bottomNoteLabel={eventReasonLabelMap.get(events[vi.index]!.id)} |
||||
/> |
||||
)} |
||||
</div> |
||||
))} |
||||
</div> |
||||
) |
||||
}) |
||||
|
||||
const ElementRows = memo(function ElementRows({ |
||||
events, |
||||
gridLayout, |
||||
filterMutedNotes, |
||||
eventReasonLabelMap, |
||||
scrollElement |
||||
}: Omit<VirtualizedFeedRowsProps, 'useWindowScroll' | 'scrollMarginTop'> & { |
||||
scrollElement: HTMLElement |
||||
}) { |
||||
const rowCount = gridLayout ? Math.ceil(events.length / 3) : events.length |
||||
const virtualizer = useVirtualizer({ |
||||
count: rowCount, |
||||
getScrollElement: () => scrollElement, |
||||
estimateSize: () => (gridLayout ? ESTIMATE_GRID_ROW_PX : ESTIMATE_NOTE_ROW_PX), |
||||
overscan: VIRTUAL_OVERSCAN, |
||||
getItemKey: (index) => (gridLayout ? `grid-${index}` : (events[index]?.id ?? `i-${index}`)) |
||||
}) |
||||
|
||||
return ( |
||||
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}> |
||||
{virtualizer.getVirtualItems().map((vi) => ( |
||||
<div |
||||
key={vi.key} |
||||
data-index={vi.index} |
||||
ref={virtualizer.measureElement} |
||||
className="absolute left-0 top-0 w-full" |
||||
style={{ transform: `translateY(${vi.start}px)` }} |
||||
> |
||||
{gridLayout ? ( |
||||
<div className="grid grid-cols-3 gap-0.5 pr-4"> |
||||
{events.slice(vi.index * 3, vi.index * 3 + 3).map((event) => ( |
||||
<MediaGridItem key={event.id} event={event} /> |
||||
))} |
||||
</div> |
||||
) : ( |
||||
<NoteCard |
||||
className="w-full" |
||||
event={events[vi.index]!} |
||||
filterMutedNotes={filterMutedNotes} |
||||
bottomNoteLabel={eventReasonLabelMap.get(events[vi.index]!.id)} |
||||
/> |
||||
)} |
||||
</div> |
||||
))} |
||||
</div> |
||||
) |
||||
}) |
||||
|
||||
/** Window- or element-scrolling virtual list for feed rows (and 3-column media grid by row). */ |
||||
export default memo(function VirtualizedFeedRows({ |
||||
events, |
||||
gridLayout, |
||||
filterMutedNotes, |
||||
eventReasonLabelMap, |
||||
useWindowScroll, |
||||
scrollElement, |
||||
scrollMarginTop |
||||
}: VirtualizedFeedRowsProps) { |
||||
if (events.length === 0) { |
||||
return null |
||||
} |
||||
|
||||
if (useWindowScroll) { |
||||
return ( |
||||
<WindowRows |
||||
events={events} |
||||
gridLayout={gridLayout} |
||||
filterMutedNotes={filterMutedNotes} |
||||
eventReasonLabelMap={eventReasonLabelMap} |
||||
scrollMarginTop={scrollMarginTop} |
||||
/> |
||||
) |
||||
} |
||||
|
||||
if (!scrollElement) { |
||||
return null |
||||
} |
||||
|
||||
return ( |
||||
<ElementRows |
||||
events={events} |
||||
gridLayout={gridLayout} |
||||
filterMutedNotes={filterMutedNotes} |
||||
eventReasonLabelMap={eventReasonLabelMap} |
||||
scrollElement={scrollElement} |
||||
/> |
||||
) |
||||
}) |
||||
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
import { useCallback, useRef } from 'react' |
||||
|
||||
/** |
||||
* Stable callback identity with always-fresh implementation (React `useEvent` pattern). |
||||
* Avoids stale closures without forcing unrelated consumers to re-render. |
||||
*/ |
||||
export function useEventCallback<A extends readonly unknown[], R>(fn: (...args: A) => R): (...args: A) => R { |
||||
const ref = useRef(fn) |
||||
ref.current = fn |
||||
return useCallback((...args: A) => ref.current(...args), []) |
||||
} |
||||
Loading…
Reference in new issue