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 && ( - {`${name}'s { (e.target as HTMLImageElement).style.display = "none"; }} /> - )} - {name} -
-

{preview}

+ {event && ( + <> +
+ {profile?.picture && ( + {`${name}'s { (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; +}