diff --git a/src/components/feed/Feed.tsx b/src/components/feed/Feed.tsx
index d1b686a..847be9d 100644
--- a/src/components/feed/Feed.tsx
+++ b/src/components/feed/Feed.tsx
@@ -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
diff --git a/src/components/feed/NoteContent.tsx b/src/components/feed/NoteContent.tsx
index 0497f2e..0dc7505 100644
--- a/src/components/feed/NoteContent.tsx
+++ b/src/components/feed/NoteContent.tsx
@@ -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 (
{ 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}
>
-
- {profile?.picture && (
-

{ (e.target as HTMLImageElement).style.display = "none"; }} />
- )}
-
{name}
-
-
{preview}
+ {event && (
+ <>
+
+ {profile?.picture && (
+

{ (e.target as HTMLImageElement).style.display = "none"; }} />
+ )}
+
{name}
+
+
{preview}
+ >
+ )}
);
}
diff --git a/src/lib/feedEstimate.ts b/src/lib/feedEstimate.ts
new file mode 100644
index 0000000..f1fc686
--- /dev/null
+++ b/src/lib/feedEstimate.ts
@@ -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();
+
+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;
+}