diff --git a/src/components/messageStream/MessageStream.tsx b/src/components/messageStream/MessageStream.tsx index 9a7163f..8d38fef 100644 --- a/src/components/messageStream/MessageStream.tsx +++ b/src/components/messageStream/MessageStream.tsx @@ -86,6 +86,11 @@ export function MessageStream({ scope }: MessageStreamProps) { const handledTargetRef = useRef(""); const targetScrollTimersRef = useRef([]); const targetScrollFrameRef = useRef(null); + // Timestamp (perf clock) when the first batch of real items became visible + // for this target. Used to delay the deep-link smooth scroll until the + // initial Reveal in-view animations have had a moment to play, so the user + // sees content before the page starts moving. + const firstContentAtRef = useRef(null); const clearTargetScrollTimers = () => { for (const timer of targetScrollTimersRef.current) { @@ -100,10 +105,19 @@ export function MessageStream({ scope }: MessageStreamProps) { useEffect(() => { handledTargetRef.current = ""; + firstContentAtRef.current = null; clearTargetScrollTimers(); setIsAligningQueryTarget(false); }, [queryTargetPostId, targetPostId]); + // Mark when first real content becomes visible (skeletons gone, items in). + // Captured per-target via the reset above so a later navigation re-measures. + useEffect(() => { + if (items.length > 0 && !isLoading && firstContentAtRef.current === null) { + firstContentAtRef.current = performance.now(); + } + }, [items.length, isLoading]); + // Banner / deep-link arrivals (`?post=`) should always begin the // smooth-scroll positioning from the top of the stream, so the user sees a // clear, satisfying journey to the target post instead of a tiny nudge when @@ -190,17 +204,31 @@ export function MessageStream({ scope }: MessageStreamProps) { // duration of the animation so the page never feels frozen. Quiet // re-alignments run inside the lock window to absorb late image-shift // above the target; nothing nudges the user after the lock releases. + // + // Hold the animation until the page has had ~300ms after the first + // content paint, so the in-view Reveal staggers on the top bubbles + // have time to fade in. Otherwise the smooth scroll starts before the + // user can see anything, and the journey reads as "blank flash". + const REVEAL_SETTLE_MS = 300; + const elapsed = + firstContentAtRef.current !== null + ? performance.now() - firstContentAtRef.current + : 0; + const settle = Math.max(0, REVEAL_SETTLE_MS - elapsed); + setIsAligningQueryTarget(true); - window.requestAnimationFrame(() => - scrollToTarget(prefersReducedMotion ? "auto" : "smooth"), - ); targetScrollTimersRef.current = [ - window.setTimeout(() => scrollToTarget("auto"), 450), - window.setTimeout(() => scrollToTarget("auto"), 750), + window.setTimeout(() => { + window.requestAnimationFrame(() => + scrollToTarget(prefersReducedMotion ? "auto" : "smooth"), + ); + }, settle), + window.setTimeout(() => scrollToTarget("auto"), settle + 450), + window.setTimeout(() => scrollToTarget("auto"), settle + 750), window.setTimeout(() => { scrollToTarget("auto"); setIsAligningQueryTarget(false); - }, 950), + }, settle + 950), ]; } else { window.requestAnimationFrame(() =>