mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(timeline): opacity-gate channel intro until pinned and at top
The header-first flash survived three CSS rewrites because no overflow-container CSS reorder fixes it: a standard scroll container rests at scrollTop 0 during the whole estimate->measure->settle window, so the in-flow intro is painted at the top before the bottom pin (a post-paint layout effect) drives it off-top. tho watches that intermediate frame. Keep the intro reserving its space (scrollMargin math unchanged) but gate its VISUAL reveal on hasInitialized && isAtTop: hidden on first paint, hidden while pinned off-top, revealed only when the user genuinely reaches the top (or a short channel where top is also bottom). New pure isAtTopMetrics/isAtTop helpers carry unit coverage (jsdom can't catch the layout bug; the threshold math is testable). Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
co-authored by
Taylor Ho
parent
67e39e4635
commit
807c761bca
@@ -3,7 +3,9 @@ import test from "node:test";
|
||||
|
||||
import {
|
||||
BOTTOM_THRESHOLD_PX,
|
||||
TOP_THRESHOLD_PX,
|
||||
buildDayGroupBoundaries,
|
||||
isAtTopMetrics,
|
||||
isNearBottomMetrics,
|
||||
resolveDeepLinkTarget,
|
||||
selectDeferredListRenderState,
|
||||
@@ -77,6 +79,20 @@ test("isNearBottomMetrics: false when scrolled up beyond the threshold", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// --- earned channel-intro header (at-top reveal gate) -------------------------
|
||||
|
||||
test("isAtTopMetrics: true when within threshold of the top", () => {
|
||||
assert.equal(isAtTopMetrics({ scrollTop: 4 }), true);
|
||||
});
|
||||
|
||||
test("isAtTopMetrics: true exactly at the threshold boundary", () => {
|
||||
assert.equal(isAtTopMetrics({ scrollTop: TOP_THRESHOLD_PX }), true);
|
||||
});
|
||||
|
||||
test("isAtTopMetrics: false when scrolled down beyond the threshold", () => {
|
||||
assert.equal(isAtTopMetrics({ scrollTop: TOP_THRESHOLD_PX + 1 }), false);
|
||||
});
|
||||
|
||||
test("selectLatestMessageKey: prefers renderKey, falls back to id, undefined when empty", () => {
|
||||
assert.equal(selectLatestMessageKey([]), undefined);
|
||||
assert.equal(
|
||||
|
||||
@@ -43,6 +43,32 @@ export function isNearBottom(container: HTMLDivElement): boolean {
|
||||
});
|
||||
}
|
||||
|
||||
/** Distance (px) from the top within which the timeline counts as "at top". */
|
||||
export const TOP_THRESHOLD_PX = 8;
|
||||
|
||||
/**
|
||||
* Is the timeline scrolled close enough to the top to count as "at top"?
|
||||
*
|
||||
* This gates the channel-intro header's VISUAL reveal. The intro is the
|
||||
* terminal header of a bottom-anchored list — it must surface only once the
|
||||
* user has genuinely arrived at the true top, never get painted up front while
|
||||
* the list is still streaming in from the bottom (a standard overflow container
|
||||
* rests at scrollTop 0 during the estimate→measure→settle window, so "scrollTop
|
||||
* is 0" alone is NOT a trustworthy at-top signal until the first-load bottom pin
|
||||
* has landed). Pure over geometry so the threshold math is unit-testable without
|
||||
* a DOM — the surrounding flexbox layout is not, jsdom does no layout.
|
||||
*/
|
||||
export function isAtTopMetrics(
|
||||
metrics: Pick<ScrollMetrics, "scrollTop">,
|
||||
): boolean {
|
||||
return metrics.scrollTop <= TOP_THRESHOLD_PX;
|
||||
}
|
||||
|
||||
/** Reads live scroll geometry off a container and applies the top-threshold rule. */
|
||||
export function isAtTop(container: HTMLDivElement): boolean {
|
||||
return isAtTopMetrics({ scrollTop: container.scrollTop });
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity of the last message in a snapshot, used to detect "a new latest
|
||||
* message arrived" for autoscroll. Prefers `renderKey` (stable across optimistic
|
||||
|
||||
@@ -252,6 +252,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
|
||||
const {
|
||||
highlightedMessageId,
|
||||
introRevealed,
|
||||
isAtBottom,
|
||||
newMessageCount,
|
||||
scrollToBottom,
|
||||
@@ -419,7 +420,15 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
|
||||
{showChannelIntro ? (
|
||||
<div
|
||||
className="mb-0.5 mt-auto flex w-full max-w-2xl flex-col items-start px-3 py-2 text-left"
|
||||
aria-hidden={!introRevealed}
|
||||
className={cn(
|
||||
"mb-0.5 mt-auto flex w-full max-w-2xl flex-col items-start px-3 py-2 text-left transition-opacity",
|
||||
// Reserve the intro's space (feeds scrollMargin) but only
|
||||
// REVEAL it once the bottom pin has landed AND we're at the
|
||||
// genuine top — never painted up front while the list
|
||||
// streams in from the bottom.
|
||||
introRevealed ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
data-testid="message-channel-intro"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type VirtualTimelineRow,
|
||||
} from "@/features/messages/lib/buildVirtualTimelineRows";
|
||||
import {
|
||||
isAtTop,
|
||||
isNearBottom,
|
||||
resolveDeepLinkTarget,
|
||||
selectLatestMessageKey,
|
||||
@@ -78,6 +79,14 @@ export function useVirtualTimelineScroll({
|
||||
const [highlightedMessageId, setHighlightedMessageId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
// Drives the channel-intro header's VISUAL reveal. The intro is the terminal
|
||||
// header of a bottom-anchored list: it reserves its space (scrollMargin), but
|
||||
// must only become VISIBLE once the first-load bottom pin has landed AND the
|
||||
// user has genuinely arrived at the true top — never painted up front while
|
||||
// the list streams in from the bottom. A standard overflow container rests at
|
||||
// scrollTop 0 during the estimate→measure→settle window, so we gate on
|
||||
// `hasInitialized && isAtTop`, not "scrollTop is 0" alone.
|
||||
const [introRevealed, setIntroRevealed] = React.useState(false);
|
||||
|
||||
const lastRowIndex = rows.length - 1;
|
||||
|
||||
@@ -110,8 +119,19 @@ export function useVirtualTimelineScroll({
|
||||
setIsAtBottom(true);
|
||||
setNewMessageCount(0);
|
||||
setHighlightedMessageId(null);
|
||||
setIntroRevealed(false);
|
||||
}, [channelId]);
|
||||
|
||||
// Recompute whether the channel intro should be visible: only once the
|
||||
// first-load pin has landed (`hasInitialized`) AND the container is genuinely
|
||||
// at the top. Cheap geometry read, only flips state on a real change.
|
||||
const syncIntroRevealed = React.useCallback(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
const revealed =
|
||||
hasInitializedRef.current && container !== null && isAtTop(container);
|
||||
setIntroRevealed((current) => (current === revealed ? current : revealed));
|
||||
}, [scrollContainerRef]);
|
||||
|
||||
// Track bottom-pinned state off the native scroll event. The virtualizer owns
|
||||
// the scrollTop; we only read it to decide whether to keep auto-following.
|
||||
const syncScrollState = React.useCallback(() => {
|
||||
@@ -125,7 +145,8 @@ export function useVirtualTimelineScroll({
|
||||
if (atBottom) {
|
||||
setNewMessageCount(0);
|
||||
}
|
||||
}, [scrollContainerRef]);
|
||||
syncIntroRevealed();
|
||||
}, [scrollContainerRef, syncIntroRevealed]);
|
||||
|
||||
const latestMessage =
|
||||
messages.length > 0 ? messages[messages.length - 1] : undefined;
|
||||
@@ -152,6 +173,10 @@ export function useVirtualTimelineScroll({
|
||||
hasInitializedRef.current = true;
|
||||
previousLastMessageKeyRef.current = latestMessageKey;
|
||||
previousMessageCountRef.current = messages.length;
|
||||
// The first-load pin just landed: recompute reveal so a short channel
|
||||
// (everything fits → top is genuinely also the bottom) surfaces its intro,
|
||||
// while a long channel pinned off-top stays hidden.
|
||||
syncIntroRevealed();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -191,6 +216,7 @@ export function useVirtualTimelineScroll({
|
||||
messages.length,
|
||||
scrollMarginReady,
|
||||
scrollToBottom,
|
||||
syncIntroRevealed,
|
||||
targetMessageId,
|
||||
]);
|
||||
|
||||
@@ -219,7 +245,18 @@ export function useVirtualTimelineScroll({
|
||||
}
|
||||
lastPinnedTotalSizeRef.current = totalSize;
|
||||
virtualizer.scrollToIndex(lastRowIndex, { align: "end" });
|
||||
}, [totalSize, isLoading, targetMessageId, lastRowIndex, virtualizer]);
|
||||
// Re-anchor changed the scroll position: recompute reveal so the intro
|
||||
// tracks the new resting place (stays hidden off-top, surfaces only if the
|
||||
// grown content still leaves us genuinely at the top).
|
||||
syncIntroRevealed();
|
||||
}, [
|
||||
totalSize,
|
||||
isLoading,
|
||||
targetMessageId,
|
||||
lastRowIndex,
|
||||
syncIntroRevealed,
|
||||
virtualizer,
|
||||
]);
|
||||
|
||||
// Deep-link jump-to-message. Drives the virtualizer to mount and center the
|
||||
// target row, replacing the bespoke querySelector + scrollIntoView path that
|
||||
@@ -303,6 +340,7 @@ export function useVirtualTimelineScroll({
|
||||
|
||||
return {
|
||||
highlightedMessageId,
|
||||
introRevealed,
|
||||
isAtBottom,
|
||||
newMessageCount,
|
||||
scrollToBottom,
|
||||
|
||||
Reference in New Issue
Block a user