mirror of
https://github.com/hoornet/vega.git
synced 2026-06-08 14:11:55 -07:00
fix: feed virtualization — overlapping cards and phantom gaps
Three compounding causes in the v0.12.17 virtualized feed: - Measurement cache was keyed by list index; the feed reorders (refresh merge, WoT filter, tab switch, cached->fresh swap) so cached heights reattached to the wrong note. Fixed with getItemKey by note id. - <video> and single <img> had no reserved height and resized after metadata/image load — after the virtualizer measured the row. They now sit in fixed aspect-ratio boxes. - The custom measureElement returned the 140px estimate for not-yet- measured rows on backward scroll (e.g. notes revealed by toggling WoT), so tall cards rendered far bigger than their recorded size. Removed — stable box heights make the flicker workaround obsolete. Media boxes always render (stable height); the <img>/<video> inside mounts only when the card is on screen, keeping scroll light.
This commit is contained in:
@@ -149,17 +149,21 @@ export function Feed() {
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 140,
|
||||
overscan: 6,
|
||||
// Re-measuring rows during upward scroll shifts scrollTop a frame late →
|
||||
// visible flicker. On backward scroll, reuse the cached measurement instead.
|
||||
// (TanStack/virtual#659; falls back to a real measure if never cached.)
|
||||
measureElement: (element, _entry, instance) => {
|
||||
if (instance.scrollDirection === "forward" || instance.scrollDirection === null) {
|
||||
return element.getBoundingClientRect().height;
|
||||
}
|
||||
const index = Number(element.getAttribute("data-index"));
|
||||
const cached = instance.getVirtualItems().find((v) => v.index === index)?.size;
|
||||
return cached ?? element.getBoundingClientRect().height;
|
||||
},
|
||||
// Key measurements by note id, not list index. The feed list mutates order
|
||||
// constantly — new notes prepend (flushPendingNotes), the WoT filter removes
|
||||
// mid-list items, tab switches swap the whole array. With the default
|
||||
// index-based key, a cached row height would be reapplied to whatever note
|
||||
// now sits at that index → cards overlap or leave gaps. Stable id keys pin
|
||||
// each measurement to its own note.
|
||||
getItemKey: (index) => filteredNotes[index]?.id ?? index,
|
||||
// Default measureElement (real getBoundingClientRect + ResizeObserver).
|
||||
// A previous custom measureElement reused cached sizes on backward scroll
|
||||
// to dodge upward-scroll flicker (TanStack/virtual#659) — but for rows not
|
||||
// yet measured (e.g. notes revealed by toggling the WoT filter) the
|
||||
// "cache" was just the 140px estimate, so tall image notes rendered far
|
||||
// bigger than their recorded size and overlapped their neighbours. Media
|
||||
// now sits in fixed-aspect boxes, so row heights are stable and the
|
||||
// flicker workaround is no longer needed.
|
||||
});
|
||||
|
||||
// Keyboard nav (j/k) moves focusedNoteIndex; virtualization unmounts off-screen
|
||||
|
||||
@@ -2,20 +2,31 @@ import { ContentSegment } from "../../lib/parsing";
|
||||
import { usePodcastStore } from "../../stores/podcast";
|
||||
import { safeHttpUrl } from "../../lib/utils";
|
||||
|
||||
export function VideoBlock({ sources }: { sources: string[] }) {
|
||||
export function VideoBlock({ sources, inView }: { sources: string[]; inView: boolean }) {
|
||||
if (sources.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
{sources.map((src, i) => (
|
||||
<video
|
||||
// Fixed-aspect wrapper: the <video> resizes to the clip's intrinsic
|
||||
// dimensions once `preload="metadata"` resolves. Inside a virtualized
|
||||
// feed that late resize desyncs row measurements and overlaps cards,
|
||||
// so the box height is deterministic. The <video> only mounts when the
|
||||
// card is on screen (inView) — off-screen rows keep the empty box.
|
||||
<div
|
||||
key={i}
|
||||
src={src}
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className="max-w-full max-h-80 rounded-sm bg-bg-raised border border-border"
|
||||
onError={(e) => { (e.target as HTMLVideoElement).style.display = "none"; }}
|
||||
/>
|
||||
className="w-full aspect-video rounded-sm bg-bg-raised border border-border overflow-hidden"
|
||||
>
|
||||
{inView && (
|
||||
<video
|
||||
src={src}
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className="w-full h-full object-contain"
|
||||
onError={(e) => { (e.target as HTMLVideoElement).style.display = "none"; }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -183,7 +183,11 @@ export const NoteCard = memo(function NoteCard({ event, focused, onReplyInThread
|
||||
<div>
|
||||
<NoteContent content={event.content} inline />
|
||||
</div>
|
||||
{inView && <NoteContent content={event.content} mediaOnly />}
|
||||
{/* Media boxes always render (fixed aspect ratio → stable row
|
||||
height for the virtualizer), but the heavy <img>/<video> inside
|
||||
them only mounts when the card is on screen (mediaInView), so
|
||||
off-screen overscan rows stay light and scrolling stays smooth. */}
|
||||
<NoteContent content={event.content} mediaOnly mediaInView={inView} />
|
||||
|
||||
{/* Poll options — kind 1068 */}
|
||||
{event.kind === 1068 && <PollWidget event={event} />}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { renderTextSegments } from "./TextSegments";
|
||||
import { VideoBlock, AudioBlock, YouTubeCard, VimeoCard, SpotifyCard, TidalCard } from "./MediaCards";
|
||||
import { FountainCard } from "./FountainCard";
|
||||
|
||||
function ImageGrid({ images, onImageClick }: { images: string[]; onImageClick: (index: number) => void }) {
|
||||
function ImageGrid({ images, onImageClick, inView }: { images: string[]; onImageClick: (index: number) => void; inView: boolean }) {
|
||||
const count = images.length;
|
||||
if (count === 0) return null;
|
||||
|
||||
@@ -18,17 +18,27 @@ function ImageGrid({ images, onImageClick }: { images: string[]; onImageClick: (
|
||||
const extraCount = count - 4;
|
||||
const visible = images.slice(0, maxVisible);
|
||||
|
||||
// Each image sits in a fixed-aspect box, so the card height is deterministic
|
||||
// before anything loads — the virtualizer can't mis-measure it. The <img>
|
||||
// itself only mounts when the card is on screen (inView); off-screen rows
|
||||
// keep just the empty box, so scrolling stays light.
|
||||
const boxCls = "rounded-sm bg-bg-raised border border-border overflow-hidden cursor-zoom-in";
|
||||
const cellImg = (src: string, idx: number) =>
|
||||
inView ? (
|
||||
<img
|
||||
src={src}
|
||||
alt="Posted image"
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
onClick={(e) => { e.stopPropagation(); onImageClick(idx); }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (count === 1) {
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<img
|
||||
src={images[0]}
|
||||
alt="Posted image"
|
||||
loading="lazy"
|
||||
className="max-w-full max-h-80 rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
|
||||
onClick={(e) => { e.stopPropagation(); onImageClick(0); }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
<div className={`w-full aspect-[4/3] ${boxCls}`}>{cellImg(images[0], 0)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,15 +47,7 @@ function ImageGrid({ images, onImageClick }: { images: string[]; onImageClick: (
|
||||
return (
|
||||
<div className="mt-2 grid grid-cols-2 gap-1">
|
||||
{visible.map((src, idx) => (
|
||||
<img
|
||||
key={idx}
|
||||
src={src}
|
||||
alt="Posted image"
|
||||
loading="lazy"
|
||||
className="w-full aspect-[4/3] rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
|
||||
onClick={(e) => { e.stopPropagation(); onImageClick(idx); }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
<div key={idx} className={`aspect-[4/3] ${boxCls}`}>{cellImg(src, idx)}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -54,30 +56,9 @@ function ImageGrid({ images, onImageClick }: { images: string[]; onImageClick: (
|
||||
if (count === 3) {
|
||||
return (
|
||||
<div className="mt-2 grid grid-cols-2 grid-rows-2 gap-1">
|
||||
<img
|
||||
src={visible[0]}
|
||||
alt="Posted image"
|
||||
loading="lazy"
|
||||
className="w-full h-full rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in row-span-2 aspect-[3/4]"
|
||||
onClick={(e) => { e.stopPropagation(); onImageClick(0); }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
<img
|
||||
src={visible[1]}
|
||||
alt="Posted image"
|
||||
loading="lazy"
|
||||
className="w-full aspect-[4/3] rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
|
||||
onClick={(e) => { e.stopPropagation(); onImageClick(1); }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
<img
|
||||
src={visible[2]}
|
||||
alt="Posted image"
|
||||
loading="lazy"
|
||||
className="w-full aspect-[4/3] rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
|
||||
onClick={(e) => { e.stopPropagation(); onImageClick(2); }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
<div className={`row-span-2 ${boxCls}`}>{cellImg(visible[0], 0)}</div>
|
||||
<div className={`aspect-[4/3] ${boxCls}`}>{cellImg(visible[1], 1)}</div>
|
||||
<div className={`aspect-[4/3] ${boxCls}`}>{cellImg(visible[2], 2)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -86,18 +67,11 @@ function ImageGrid({ images, onImageClick }: { images: string[]; onImageClick: (
|
||||
return (
|
||||
<div className="mt-2 grid grid-cols-2 gap-1">
|
||||
{visible.map((src, idx) => (
|
||||
<div key={idx} className="relative">
|
||||
<img
|
||||
src={src}
|
||||
alt="Posted image"
|
||||
loading="lazy"
|
||||
className="w-full aspect-[4/3] rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
|
||||
onClick={(e) => { e.stopPropagation(); onImageClick(idx); }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
<div key={idx} className={`relative aspect-[4/3] ${boxCls}`}>
|
||||
{cellImg(src, idx)}
|
||||
{idx === 3 && extraCount > 0 && (
|
||||
<div
|
||||
className="absolute inset-0 bg-bg/60 flex items-center justify-center rounded-sm cursor-zoom-in"
|
||||
className="absolute inset-0 bg-bg/60 flex items-center justify-center cursor-zoom-in"
|
||||
onClick={(e) => { e.stopPropagation(); onImageClick(idx); }}
|
||||
>
|
||||
<span className="text-text text-[14px] font-semibold">+{extraCount}</span>
|
||||
@@ -148,9 +122,11 @@ interface NoteContentProps {
|
||||
inline?: boolean;
|
||||
/** Render only media blocks (videos, embeds, quotes). Used outside the clickable area. */
|
||||
mediaOnly?: boolean;
|
||||
/** When false, image/video boxes render empty — keeps off-screen rows light. */
|
||||
mediaInView?: boolean;
|
||||
}
|
||||
|
||||
export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) {
|
||||
export function NoteContent({ content, inline, mediaOnly, mediaInView = true }: NoteContentProps) {
|
||||
const { openHashtag } = useUIStore();
|
||||
const segments = parseContent(content);
|
||||
const images: string[] = segments.filter((s) => s.type === "image").map((s) => s.value);
|
||||
@@ -173,8 +149,10 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Media blocks only (rendered OUTSIDE the clickable wrapper, gated by inView) ---
|
||||
// Images are included here so they only load when the note is near the viewport.
|
||||
// --- Media blocks only (rendered OUTSIDE the clickable wrapper) ---
|
||||
// Split out so a click on media doesn't navigate to the thread. Images keep
|
||||
// loading="lazy" so off-screen media isn't fetched; the boxes have a fixed
|
||||
// aspect ratio so the card height is stable for the virtualizer.
|
||||
if (mediaOnly) {
|
||||
const hasMedia = images.length > 0 || videos.length > 0 || audios.length > 0 || youtubes.length > 0
|
||||
|| vimeos.length > 0 || spotifys.length > 0 || tidals.length > 0 || fountains.length > 0 || quoteIds.length > 0;
|
||||
@@ -182,7 +160,7 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) {
|
||||
|
||||
return (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ImageGrid images={images} onImageClick={setLightboxIndex} />
|
||||
<ImageGrid images={images} onImageClick={setLightboxIndex} inView={mediaInView} />
|
||||
{lightboxIndex !== null && (
|
||||
<ImageLightbox
|
||||
images={images}
|
||||
@@ -191,7 +169,7 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) {
|
||||
onNavigate={setLightboxIndex}
|
||||
/>
|
||||
)}
|
||||
<VideoBlock sources={videos} />
|
||||
<VideoBlock sources={videos} inView={mediaInView} />
|
||||
<AudioBlock sources={audios} />
|
||||
{youtubes.map((seg, i) => <YouTubeCard key={`yt-${i}`} seg={seg} />)}
|
||||
{vimeos.map((seg, i) => <VimeoCard key={`vim-${i}`} seg={seg} />)}
|
||||
@@ -210,7 +188,7 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) {
|
||||
{renderTextSegments(segments, openHashtag)}
|
||||
</div>
|
||||
|
||||
<ImageGrid images={images} onImageClick={setLightboxIndex} />
|
||||
<ImageGrid images={images} onImageClick={setLightboxIndex} inView={mediaInView} />
|
||||
|
||||
{lightboxIndex !== null && (
|
||||
<ImageLightbox
|
||||
@@ -221,7 +199,7 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<VideoBlock sources={videos} />
|
||||
<VideoBlock sources={videos} inView={mediaInView} />
|
||||
<AudioBlock sources={audios} />
|
||||
{youtubes.map((seg, i) => <YouTubeCard key={`yt-${i}`} seg={seg} />)}
|
||||
{vimeos.map((seg, i) => <VimeoCard key={`vim-${i}`} seg={seg} />)}
|
||||
|
||||
Reference in New Issue
Block a user