mirror of
https://github.com/hoornet/vega.git
synced 2026-06-11 07:23:31 -07:00
fix: feed flicker — fixed-height quote box + content-aware row estimate
Two remaining causes of upward-scroll flicker in the virtualized feed: - QuotePreview rendered nothing until fetchNoteById resolved, then popped in a box — a 70-200px late growth that desynced the virtualizer every time a quote note mounted. It now renders a fixed 80px (h-20) box, reserved whether or not the quoted note has loaded; a 2-line clamp keeps resolved content inside it. - estimateSize was a flat 140px, so every row snapped from 140 to its real height (96-1660px) on first measurement. New estimateNoteHeight predicts height from note content (text, media boxes, quote box) so the first-measure correction is small enough to be imperceptible. Remaining: a <=25px transient on some notes' first few views (a flex-wrap row reflowing as async content lands); self-heals as caches warm.
This commit is contained in:
@@ -8,6 +8,7 @@ import { useWoTStore } from "../../stores/wot";
|
||||
import { fetchFollowFeed, getNDK, ensureConnected } from "../../lib/nostr";
|
||||
import { diagWrapFetch, logDiag } from "../../lib/feedDiagnostics";
|
||||
import { detectScript, getEventLanguageTag, FILTER_SCRIPTS } from "../../lib/language";
|
||||
import { estimateNoteHeight } from "../../lib/feedEstimate";
|
||||
import { NoteCard } from "./NoteCard";
|
||||
import { ArticleCard } from "../article/ArticleCard";
|
||||
import { ComposeBox } from "./ComposeBox";
|
||||
@@ -147,7 +148,15 @@ export function Feed() {
|
||||
const virtualizer = useVirtualizer({
|
||||
count: filteredNotes.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 140,
|
||||
// Content-aware estimate: a flat estimate makes every row snap to its real
|
||||
// height on first measurement → upward-scroll flicker. estimateNoteHeight
|
||||
// predicts the height from the note's content so the snap is negligible.
|
||||
estimateSize: (index) => {
|
||||
const e = filteredNotes[index];
|
||||
if (!e) return 140;
|
||||
// content column = scroll width minus card padding (32) + avatar col (48)
|
||||
return estimateNoteHeight(e, (scrollRef.current?.clientWidth ?? 600) - 80);
|
||||
},
|
||||
overscan: 6,
|
||||
// Key measurements by note id, not list index. The feed list mutates order
|
||||
// constantly — new notes prepend (flushPendingNotes), the WoT filter removes
|
||||
|
||||
@@ -93,25 +93,32 @@ function QuotePreview({ eventId }: { eventId: string }) {
|
||||
fetchNoteById(eventId).then(setEvent);
|
||||
}, [eventId]);
|
||||
|
||||
if (!event) return null;
|
||||
|
||||
const rawName = profile?.displayName || profile?.name;
|
||||
const name = (typeof rawName === "string" ? rawName : null) || shortenPubkey(event.pubkey);
|
||||
const preview = event.content.slice(0, 160) + (event.content.length > 160 ? "…" : "");
|
||||
const name = (typeof rawName === "string" ? rawName : null) || (event ? shortenPubkey(event.pubkey) : "");
|
||||
const preview = event ? event.content.slice(0, 160) + (event.content.length > 160 ? "…" : "") : "";
|
||||
|
||||
// Fixed-height box, reserved whether or not the quoted note has resolved.
|
||||
// QuotePreview used to render null until fetchNoteById resolved, then pop in
|
||||
// a box — that late growth (~70-200px) desynced the virtualizer and was the
|
||||
// main cause of upward-scroll flicker. The box height never changes now; the
|
||||
// 2-line clamp keeps resolved content inside the reserved space.
|
||||
return (
|
||||
<div
|
||||
className="mt-2 border border-border bg-bg-raised px-3 py-2 cursor-pointer hover:bg-bg-hover transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); openThread(event, currentView as "feed" | "profile"); }}
|
||||
className={`mt-2 h-20 border border-border bg-bg-raised px-3 py-2 overflow-hidden transition-colors ${event ? "cursor-pointer hover:bg-bg-hover" : ""}`}
|
||||
onClick={event ? (e) => { e.stopPropagation(); openThread(event, currentView as "feed" | "profile"); } : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{profile?.picture && (
|
||||
<img src={profile.picture} alt={`${name}'s avatar`} className="w-4 h-4 rounded-sm object-cover shrink-0"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
)}
|
||||
<span className="text-text-muted text-[11px] font-medium truncate">{name}</span>
|
||||
</div>
|
||||
<p className="text-text-dim text-[11px] leading-relaxed whitespace-pre-wrap break-words">{preview}</p>
|
||||
{event && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{profile?.picture && (
|
||||
<img src={profile.picture} alt={`${name}'s avatar`} className="w-4 h-4 rounded-md object-cover shrink-0"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
)}
|
||||
<span className="text-text-muted text-[11px] font-medium truncate">{name}</span>
|
||||
</div>
|
||||
<p className="text-text-dim text-[11px] leading-relaxed break-words line-clamp-2">{preview}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { parseContent } from "./parsing";
|
||||
|
||||
/**
|
||||
* Estimated rendered height (px) of a feed row — used as the virtualizer's
|
||||
* `estimateSize`.
|
||||
*
|
||||
* A flat estimate makes every row snap from the guess to its real height the
|
||||
* first time it's measured; on upward scroll each snap nudges the rows below
|
||||
* it, producing a subtle per-row flicker. A content-aware estimate keeps that
|
||||
* correction small enough to be imperceptible.
|
||||
*
|
||||
* Exactness is not required — within ~10-15% of the real height is plenty.
|
||||
* Constants are matched to NoteCard / NoteContent layout.
|
||||
*/
|
||||
|
||||
const cache = new Map<string, number>();
|
||||
|
||||
const BASE = 96; // avatar/name/time row + action row + vertical padding
|
||||
const LINE = 21; // one line of 13px / leading-relaxed body text
|
||||
const QUOTE = 88; // QuotePreview h-20 box + mt-2
|
||||
const LINK_CARD = 70; // youtube / vimeo / spotify / tidal / fountain row + mt-2
|
||||
const AUDIO = 64; // AudioBlock row + mt-2
|
||||
const MEDIA_GAP = 8; // mt-2 above a media block
|
||||
const ARTICLE = 132; // ArticleCard is near-uniform height
|
||||
|
||||
function textHeight(text: string, charsPerLine: number): number {
|
||||
if (!text.trim()) return 0;
|
||||
let lines = 0;
|
||||
for (const para of text.split("\n")) {
|
||||
lines += Math.max(1, Math.ceil(para.length / charsPerLine));
|
||||
}
|
||||
return lines * LINE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mediaWidth width (px) available to the note's content column —
|
||||
* scroll-container width minus card padding and the avatar column.
|
||||
*/
|
||||
export function estimateNoteHeight(event: NDKEvent, mediaWidth: number): number {
|
||||
if (event.kind === 30023) return ARTICLE;
|
||||
|
||||
const id = event.id;
|
||||
if (id && cache.has(id)) return cache.get(id)!;
|
||||
|
||||
const charsPerLine = Math.max(20, Math.floor(mediaWidth / 7));
|
||||
const segments = parseContent(event.content);
|
||||
|
||||
let h = BASE;
|
||||
let images = 0;
|
||||
let videos = 0;
|
||||
let inlineText = "";
|
||||
|
||||
for (const s of segments) {
|
||||
switch (s.type) {
|
||||
case "image": images++; break;
|
||||
case "video": videos++; break;
|
||||
case "audio": h += AUDIO; break;
|
||||
case "youtube":
|
||||
case "vimeo":
|
||||
case "spotify":
|
||||
case "tidal":
|
||||
case "fountain": h += LINK_CARD; break;
|
||||
case "quote": h += QUOTE; break;
|
||||
default: inlineText += (s.value ?? "") + " ";
|
||||
}
|
||||
}
|
||||
|
||||
h += textHeight(inlineText, charsPerLine);
|
||||
|
||||
// Image grid — boxes use aspect-[4/3]; see ImageGrid in NoteContent.
|
||||
if (images === 1) {
|
||||
h += mediaWidth * 0.75 + MEDIA_GAP;
|
||||
} else if (images === 2) {
|
||||
h += ((mediaWidth - 4) / 2) * 0.75 + MEDIA_GAP; // one row of two
|
||||
} else if (images >= 3) {
|
||||
h += (mediaWidth - 4) * 0.75 + 4 + MEDIA_GAP; // two rows
|
||||
}
|
||||
|
||||
// Videos — aspect-video (16:9).
|
||||
h += videos * ((mediaWidth * 9) / 16 + MEDIA_GAP);
|
||||
|
||||
const result = Math.round(h);
|
||||
if (id) cache.set(id, result);
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user