mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Smooth channel loading: single-surface timeline state machine (#1099)
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -35,6 +35,10 @@ import {
|
||||
} from "@/features/messages/lib/formatTimelineMessages";
|
||||
import { getThreadReference } from "@/features/messages/lib/threading";
|
||||
import { imetaMediaFromTags } from "@/features/messages/lib/imetaMediaMarkdown";
|
||||
import {
|
||||
resolveTimelineLoadingLatch,
|
||||
selectTimelineLoadingState,
|
||||
} from "@/features/messages/lib/timelineLoadingState";
|
||||
import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages";
|
||||
import { useLoadMissingAncestors } from "@/features/messages/useLoadMissingAncestors";
|
||||
import { useChannelTyping } from "@/features/messages/useChannelTyping";
|
||||
@@ -474,13 +478,27 @@ export function ChannelScreen({
|
||||
setThreadReplyTargetId,
|
||||
setThreadScrollTargetId,
|
||||
});
|
||||
const hasTimelineData = messagesQuery.data !== undefined;
|
||||
const isTimelineLoading =
|
||||
// `data !== undefined` is not "loaded": the cache is seeded early by stale
|
||||
// placeholders and the live subscription. Wait for the history fetch to settle.
|
||||
const timelineLoadingNow =
|
||||
activeChannel !== null &&
|
||||
activeChannel.channelType !== "forum" &&
|
||||
!hasTimelineData &&
|
||||
messagesQuery.isPending;
|
||||
const shouldShowInitialChannelLoading = isTimelineLoading;
|
||||
selectTimelineLoadingState({
|
||||
isPending: messagesQuery.isPending,
|
||||
isFetching: messagesQuery.isFetching,
|
||||
isPlaceholderData: messagesQuery.isPlaceholderData,
|
||||
dataLength: messagesQuery.data?.length ?? null,
|
||||
});
|
||||
// Latch loaded per channel so a later background refetch can't flip back to
|
||||
// the skeleton — that re-flip is the "skeleton bouncing up and down" on entry.
|
||||
const settledChannelIdRef = React.useRef<string | null>(null);
|
||||
const { settledChannelId, isLoading: isTimelineLoading } =
|
||||
resolveTimelineLoadingLatch(
|
||||
settledChannelIdRef.current,
|
||||
activeChannelId,
|
||||
timelineLoadingNow,
|
||||
);
|
||||
settledChannelIdRef.current = settledChannelId;
|
||||
// Panel identity (thread/profile/agent session) lives in the URL search
|
||||
// params, so channel changes and back/forward traversals carry it per
|
||||
// history entry — only the local ephemeral targets need resetting here.
|
||||
@@ -607,9 +625,7 @@ export function ChannelScreen({
|
||||
ref={channelContentRef}
|
||||
>
|
||||
{activeChannel ? (
|
||||
shouldShowInitialChannelLoading ? (
|
||||
<ViewLoadingFallback includeHeader kind="channel" />
|
||||
) : activeChannel.channelType === "forum" ? (
|
||||
activeChannel.channelType === "forum" ? (
|
||||
<>
|
||||
{channelHeader}
|
||||
<React.Suspense fallback={<ViewLoadingFallback kind="forum" />}>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { selectTimelineLoadingState } from "./timelineLoadingState.ts";
|
||||
|
||||
const settled = {
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
isPlaceholderData: false,
|
||||
dataLength: null,
|
||||
};
|
||||
|
||||
test("pending first fetch with no cache is loading", () => {
|
||||
assert.equal(
|
||||
selectTimelineLoadingState({ ...settled, isPending: true }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("stale placeholder while refetching is loading", () => {
|
||||
// Revisited within gcTime: placeholderData hands back a cached array while the
|
||||
// authoritative fetch runs. Must keep the skeleton up, not flash the intro.
|
||||
assert.equal(
|
||||
selectTimelineLoadingState({
|
||||
...settled,
|
||||
isFetching: true,
|
||||
isPlaceholderData: true,
|
||||
dataLength: 0,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("subscription-seeded empty cache while fetching is loading", () => {
|
||||
// The live subscription's setQueryData seeds [] before history settles, so
|
||||
// data is defined but empty and a fetch is still in flight.
|
||||
assert.equal(
|
||||
selectTimelineLoadingState({
|
||||
...settled,
|
||||
isFetching: true,
|
||||
isPlaceholderData: false,
|
||||
dataLength: 0,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("settled with rows is not loading", () => {
|
||||
assert.equal(
|
||||
selectTimelineLoadingState({ ...settled, dataLength: 5 }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("settled and genuinely empty is not loading (real empty channel)", () => {
|
||||
assert.equal(
|
||||
selectTimelineLoadingState({ ...settled, dataLength: 0 }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("background refetch of a populated channel is not loading", () => {
|
||||
// staleTime expiry can trigger a background refetch; with rows already present
|
||||
// we are loaded and must not re-show the skeleton.
|
||||
assert.equal(
|
||||
selectTimelineLoadingState({
|
||||
...settled,
|
||||
isFetching: true,
|
||||
dataLength: 12,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
import { resolveTimelineLoadingLatch } from "./timelineLoadingState.ts";
|
||||
|
||||
test("latch: loading on first entry to a channel", () => {
|
||||
const r = resolveTimelineLoadingLatch(null, "chan-a", true);
|
||||
assert.equal(r.isLoading, true);
|
||||
assert.equal(r.settledChannelId, null);
|
||||
});
|
||||
|
||||
test("latch: settles when loadingNow turns false, recording the channel", () => {
|
||||
const r = resolveTimelineLoadingLatch(null, "chan-a", false);
|
||||
assert.equal(r.isLoading, false);
|
||||
assert.equal(r.settledChannelId, "chan-a");
|
||||
});
|
||||
|
||||
test("latch: background refetch blip stays loaded once settled", () => {
|
||||
// settled for chan-a, then loadingNow blips true (isFetching) — must NOT
|
||||
// re-show the skeleton (the bounce Wes reported).
|
||||
const r = resolveTimelineLoadingLatch("chan-a", "chan-a", true);
|
||||
assert.equal(r.isLoading, false);
|
||||
assert.equal(r.settledChannelId, "chan-a");
|
||||
});
|
||||
|
||||
test("latch: switching channels resets and loads the new one", () => {
|
||||
const r = resolveTimelineLoadingLatch("chan-a", "chan-b", true);
|
||||
assert.equal(r.isLoading, true);
|
||||
assert.equal(r.settledChannelId, "chan-a"); // not yet settled for b
|
||||
});
|
||||
|
||||
test("latch: no active channel passes loadingNow through untouched", () => {
|
||||
assert.equal(
|
||||
resolveTimelineLoadingLatch("chan-a", null, true).isLoading,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
resolveTimelineLoadingLatch("chan-a", null, false).isLoading,
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Pure decision for "is the channel timeline still doing its initial load."
|
||||
*
|
||||
* Extracted so the windows below are covered by the lib `*.test.mjs` suite.
|
||||
* The trap: `data !== undefined` looks like "loaded" but the per-channel query
|
||||
* cache is seeded early — by a stale `placeholderData` on revisit, and by the
|
||||
* live subscription's `setQueryData` — before the authoritative history fetch
|
||||
* settles. Treating that as loaded flashes the channel intro/empty state over a
|
||||
* list that is about to stream in.
|
||||
*/
|
||||
export type TimelineQueryStatus = {
|
||||
isPending: boolean;
|
||||
isFetching: boolean;
|
||||
isPlaceholderData: boolean;
|
||||
dataLength: number | null;
|
||||
};
|
||||
|
||||
export function selectTimelineLoadingState(
|
||||
status: TimelineQueryStatus,
|
||||
): boolean {
|
||||
if (status.isPending) {
|
||||
return true;
|
||||
}
|
||||
// A fetch is in flight; keep loading while what we'd show is a placeholder or
|
||||
// still empty. Once real rows are present we are loaded, even mid-refetch.
|
||||
return (
|
||||
status.isFetching &&
|
||||
(status.isPlaceholderData || (status.dataLength ?? 0) === 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Monotonic loading latch keyed by channel. Once a channel has settled (loaded),
|
||||
* `loadingNow` blipping true again (a background refetch) must not re-show the
|
||||
* skeleton — that re-flip is the visible skeleton bounce on entry. A different
|
||||
* channel id resets the latch so the new channel loads fresh.
|
||||
*/
|
||||
export function resolveTimelineLoadingLatch(
|
||||
settledChannelId: string | null,
|
||||
activeChannelId: string | null,
|
||||
loadingNow: boolean,
|
||||
): { settledChannelId: string | null; isLoading: boolean } {
|
||||
if (activeChannelId === null) {
|
||||
return { settledChannelId, isLoading: loadingNow };
|
||||
}
|
||||
if (settledChannelId === activeChannelId) {
|
||||
// Already settled for this channel — stay loaded through refetch blips.
|
||||
return { settledChannelId, isLoading: false };
|
||||
}
|
||||
if (!loadingNow) {
|
||||
// First settle for this channel; latch it.
|
||||
return { settledChannelId: activeChannelId, isLoading: false };
|
||||
}
|
||||
return { settledChannelId, isLoading: true };
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
resolveDeepLinkTarget,
|
||||
selectDeferredListRenderState,
|
||||
selectLatestMessageKey,
|
||||
selectTimelineBodySurface,
|
||||
selectTimelineIntroSurface,
|
||||
} from "./timelineSnapshot.ts";
|
||||
|
||||
// Local-midnight unix-second timestamps so isSameDay (local time) is stable
|
||||
@@ -287,3 +289,96 @@ test("deferred-render: keys the empty decision off the live count, not deferred"
|
||||
assert.equal(selectDeferredListRenderState(0, 0), "empty");
|
||||
assert.equal(selectDeferredListRenderState(0, 1), "pending");
|
||||
});
|
||||
|
||||
test("timeline-body-surface: loading and deferred-pending both paint the single static skeleton", () => {
|
||||
assert.equal(
|
||||
selectTimelineBodySurface({
|
||||
deferredCount: 0,
|
||||
isLoading: true,
|
||||
liveCount: 0,
|
||||
}),
|
||||
"skeleton",
|
||||
);
|
||||
assert.equal(
|
||||
selectTimelineBodySurface({
|
||||
deferredCount: 0,
|
||||
isLoading: false,
|
||||
liveCount: 3,
|
||||
}),
|
||||
"skeleton",
|
||||
);
|
||||
});
|
||||
|
||||
test("timeline-body-surface: deferred rows paint the message list", () => {
|
||||
assert.equal(
|
||||
selectTimelineBodySurface({
|
||||
deferredCount: 2,
|
||||
isLoading: false,
|
||||
liveCount: 2,
|
||||
}),
|
||||
"list",
|
||||
);
|
||||
});
|
||||
|
||||
test("timeline-body-surface: empty only when live and deferred rows are empty", () => {
|
||||
assert.equal(
|
||||
selectTimelineBodySurface({
|
||||
deferredCount: 0,
|
||||
isLoading: false,
|
||||
liveCount: 0,
|
||||
}),
|
||||
"empty",
|
||||
);
|
||||
});
|
||||
|
||||
test("timeline-intro-surface: skeleton suppresses intro while loading", () => {
|
||||
assert.equal(
|
||||
selectTimelineIntroSurface({
|
||||
hasChannelIntro: true,
|
||||
hasDirectMessageIntro: false,
|
||||
isSkeletonVisible: true,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("timeline-intro-surface: intro may coexist with the message list", () => {
|
||||
assert.equal(
|
||||
selectTimelineBodySurface({
|
||||
deferredCount: 2,
|
||||
isLoading: false,
|
||||
liveCount: 2,
|
||||
}),
|
||||
"list",
|
||||
);
|
||||
assert.equal(
|
||||
selectTimelineIntroSurface({
|
||||
hasChannelIntro: true,
|
||||
hasDirectMessageIntro: false,
|
||||
isSkeletonVisible: false,
|
||||
}),
|
||||
"channel-intro",
|
||||
);
|
||||
});
|
||||
|
||||
test("timeline-intro-surface: direct-message intro wins over channel intro", () => {
|
||||
assert.equal(
|
||||
selectTimelineIntroSurface({
|
||||
hasChannelIntro: true,
|
||||
hasDirectMessageIntro: true,
|
||||
isSkeletonVisible: false,
|
||||
}),
|
||||
"direct-message-intro",
|
||||
);
|
||||
});
|
||||
|
||||
test("timeline-intro-surface: no intro without an intro model", () => {
|
||||
assert.equal(
|
||||
selectTimelineIntroSurface({
|
||||
hasChannelIntro: false,
|
||||
hasDirectMessageIntro: false,
|
||||
isSkeletonVisible: false,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -148,3 +148,51 @@ export function selectDeferredListRenderState(
|
||||
}
|
||||
return "pending";
|
||||
}
|
||||
|
||||
export type TimelineBodySurface = "skeleton" | "empty" | "list";
|
||||
|
||||
export function selectTimelineBodySurface({
|
||||
deferredCount,
|
||||
isLoading,
|
||||
liveCount,
|
||||
}: {
|
||||
deferredCount: number;
|
||||
isLoading: boolean;
|
||||
liveCount: number;
|
||||
}): TimelineBodySurface {
|
||||
if (isLoading) {
|
||||
return "skeleton";
|
||||
}
|
||||
|
||||
const renderState = selectDeferredListRenderState(deferredCount, liveCount);
|
||||
if (renderState === "pending") {
|
||||
return "skeleton";
|
||||
}
|
||||
return renderState;
|
||||
}
|
||||
|
||||
export type TimelineIntroSurface =
|
||||
| "direct-message-intro"
|
||||
| "channel-intro"
|
||||
| null;
|
||||
|
||||
export function selectTimelineIntroSurface({
|
||||
hasChannelIntro,
|
||||
hasDirectMessageIntro,
|
||||
isSkeletonVisible,
|
||||
}: {
|
||||
hasChannelIntro: boolean;
|
||||
hasDirectMessageIntro: boolean;
|
||||
isSkeletonVisible: boolean;
|
||||
}): TimelineIntroSurface {
|
||||
if (isSkeletonVisible) {
|
||||
return null;
|
||||
}
|
||||
if (hasDirectMessageIntro) {
|
||||
return "direct-message-intro";
|
||||
}
|
||||
if (hasChannelIntro) {
|
||||
return "channel-intro";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -428,14 +428,7 @@ export function MessageThreadPanel({
|
||||
<div className="px-3 pb-3 pt-1" data-testid="message-thread-replies">
|
||||
{repliesRenderState === "list" ? (
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-2.5",
|
||||
// While a deferred render is in flight the painted reply list
|
||||
// lags the latest `threadReplies`. Dim it slightly so the
|
||||
// streaming-in reads as intentional instead of frozen — mirrors
|
||||
// the main timeline.
|
||||
isRepliesPending && "opacity-60 transition-opacity",
|
||||
)}
|
||||
className="space-y-2.5"
|
||||
data-render-pending={isRepliesPending ? "true" : undefined}
|
||||
>
|
||||
{deferredThreadReplies.map((entry, index) => {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import * as React from "react";
|
||||
import { ArrowDown, ArrowUp, Hash } from "lucide-react";
|
||||
|
||||
import {
|
||||
selectTimelineBodySurface,
|
||||
selectTimelineIntroSurface,
|
||||
} from "@/features/messages/lib/timelineSnapshot";
|
||||
import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
@@ -9,7 +13,6 @@ import { cn } from "@/shared/lib/cn";
|
||||
import { channelChrome } from "@/shared/layout/chromeLayout";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
import { SkeletonReveal } from "@/shared/ui/skeleton";
|
||||
import { TooltipProvider } from "@/shared/ui/tooltip";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton";
|
||||
@@ -167,6 +170,28 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
? `message-timeline:${channelId ?? "none"}:target:${targetMessageId}`
|
||||
: `message-timeline:${channelId ?? "none"}`;
|
||||
|
||||
const timelineBodySurface = selectTimelineBodySurface({
|
||||
deferredCount: deferredMessages.length,
|
||||
isLoading,
|
||||
liveCount: messages.length,
|
||||
});
|
||||
const showTimelineSkeleton = timelineBodySurface === "skeleton";
|
||||
const timelineIntroSurface = selectTimelineIntroSurface({
|
||||
hasChannelIntro: channelIntro !== null && directMessageIntro === null,
|
||||
hasDirectMessageIntro: directMessageIntro !== null,
|
||||
isSkeletonVisible: showTimelineSkeleton,
|
||||
});
|
||||
const showDirectMessageIntro =
|
||||
timelineIntroSurface === "direct-message-intro";
|
||||
const showChannelIntro = timelineIntroSurface === "channel-intro";
|
||||
const activeDirectMessageIntro = showDirectMessageIntro
|
||||
? directMessageIntro
|
||||
: null;
|
||||
const activeChannelIntro = showChannelIntro ? channelIntro : null;
|
||||
const showIntro = showDirectMessageIntro || showChannelIntro;
|
||||
const showGenericEmpty = timelineBodySurface === "empty" && !showIntro;
|
||||
const showMessageList = timelineBodySurface === "list";
|
||||
|
||||
const {
|
||||
bottomAnchorRef,
|
||||
contentRef,
|
||||
@@ -179,7 +204,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
syncScrollState,
|
||||
} = useTimelineScrollManager({
|
||||
channelId,
|
||||
isLoading,
|
||||
isLoading: showTimelineSkeleton,
|
||||
messages: deferredMessages,
|
||||
onTargetReached,
|
||||
scrollContainerRef,
|
||||
@@ -210,7 +235,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
!isUnreadPillDismissed &&
|
||||
unreadCount > 0 &&
|
||||
firstUnreadMessageId !== null &&
|
||||
!isLoading;
|
||||
!showTimelineSkeleton;
|
||||
if (showUnreadPill) hasShownPillRef.current = true;
|
||||
const handleJumpToOldestUnread = React.useCallback(() => {
|
||||
setIsUnreadPillDismissed(true);
|
||||
@@ -223,6 +248,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
const prevSearchActiveRef = React.useRef<string | null>(null);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: scrollContainerRef is a stable React ref
|
||||
React.useEffect(() => {
|
||||
if (showTimelineSkeleton) return;
|
||||
if (
|
||||
!searchActiveMessageId ||
|
||||
searchActiveMessageId === prevSearchActiveRef.current
|
||||
@@ -241,31 +267,21 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
if (el) {
|
||||
el.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
}
|
||||
}, [searchActiveMessageId]);
|
||||
}, [searchActiveMessageId, showTimelineSkeleton]);
|
||||
|
||||
useLoadOlderOnScroll({
|
||||
fetchOlder,
|
||||
hasOlderMessages,
|
||||
isLoading,
|
||||
isLoading: showTimelineSkeleton,
|
||||
restoreScrollPosition,
|
||||
scrollContainerRef,
|
||||
sentinelRef: topSentinelRef,
|
||||
});
|
||||
|
||||
const showDirectMessageIntro = !isLoading && directMessageIntro !== null;
|
||||
const showChannelIntro =
|
||||
!isLoading && channelIntro !== null && directMessageIntro === null;
|
||||
const showIntro = showDirectMessageIntro || showChannelIntro;
|
||||
const showGenericEmpty =
|
||||
!isLoading &&
|
||||
deferredMessages.length === 0 &&
|
||||
directMessageIntro === null &&
|
||||
channelIntro === null;
|
||||
const showMessageList = !isLoading && deferredMessages.length > 0;
|
||||
const timelineSkeletonRows = useTimelineSkeletonRows({
|
||||
channelId,
|
||||
isLoading,
|
||||
messages,
|
||||
isLoading: showTimelineSkeleton,
|
||||
messages: showTimelineSkeleton ? EMPTY_MESSAGES : deferredMessages,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -317,41 +333,38 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SkeletonReveal
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-[18rem]",
|
||||
"flex min-h-[18rem] min-w-0 flex-col gap-2",
|
||||
(showIntro || showGenericEmpty) && "min-h-full",
|
||||
showMessageList && !showIntro && "mt-auto",
|
||||
)}
|
||||
contentClassName={cn(
|
||||
"flex min-w-0 flex-col gap-2",
|
||||
(showIntro || showGenericEmpty) && "min-h-full",
|
||||
)}
|
||||
loading={isLoading}
|
||||
skeleton={<TimelineSkeleton rows={timelineSkeletonRows} />}
|
||||
>
|
||||
{showDirectMessageIntro ? (
|
||||
{showTimelineSkeleton ? (
|
||||
<TimelineSkeleton rows={timelineSkeletonRows} />
|
||||
) : null}
|
||||
{activeDirectMessageIntro ? (
|
||||
<div
|
||||
className="mb-0.5 mt-auto flex w-full flex-col items-start px-3 py-2 text-left"
|
||||
data-testid="message-dm-intro"
|
||||
>
|
||||
<DirectMessageIntroAvatarStack
|
||||
participants={directMessageIntro.participants}
|
||||
participants={activeDirectMessageIntro.participants}
|
||||
/>
|
||||
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
|
||||
{directMessageIntro.displayName}
|
||||
{activeDirectMessageIntro.displayName}
|
||||
</p>
|
||||
<p className="mt-1 max-w-full truncate whitespace-nowrap text-sm leading-5 text-muted-foreground">
|
||||
This is the beginning of your direct message with{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{directMessageIntro.displayName}
|
||||
{activeDirectMessageIntro.displayName}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showChannelIntro ? (
|
||||
{activeChannelIntro ? (
|
||||
<div
|
||||
className="mb-0.5 mt-auto flex w-full max-w-2xl flex-col items-start px-3 py-2 text-left"
|
||||
data-testid="message-channel-intro"
|
||||
@@ -360,28 +373,28 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
className="flex h-[60px] w-[60px] items-center justify-center rounded-2xl border border-border/70 bg-muted/40 text-muted-foreground"
|
||||
data-testid="message-channel-intro-icon"
|
||||
>
|
||||
{channelIntro.icon ?? (
|
||||
{activeChannelIntro.icon ?? (
|
||||
<Hash aria-hidden className="h-7 w-7" />
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
|
||||
#{channelIntro.channelName}
|
||||
#{activeChannelIntro.channelName}
|
||||
</p>
|
||||
<p className="mt-1 max-w-full text-sm leading-5 text-muted-foreground">
|
||||
This is the beginning of the{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{channelIntro.channelKindLabel}
|
||||
{activeChannelIntro.channelKindLabel}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
{channelIntro.description ? (
|
||||
{activeChannelIntro.description ? (
|
||||
<p className="mt-2 max-w-xl text-sm leading-5 text-muted-foreground">
|
||||
{channelIntro.description}
|
||||
{activeChannelIntro.description}
|
||||
</p>
|
||||
) : null}
|
||||
{channelIntro.actions?.length ? (
|
||||
{activeChannelIntro.actions?.length ? (
|
||||
<div className="mt-4 flex max-w-full flex-nowrap gap-3 overflow-x-auto pb-1">
|
||||
{channelIntro.actions.map((action) => {
|
||||
{activeChannelIntro.actions.map((action) => {
|
||||
const hasDescription = Boolean(action.description);
|
||||
|
||||
return (
|
||||
@@ -460,14 +473,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
|
||||
{showMessageList ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2",
|
||||
!showIntro && "mt-auto",
|
||||
// While a deferred render is in flight the painted
|
||||
// list lags the latest `messages`. Dim it slightly so the
|
||||
// streaming-in feels intentional instead of frozen.
|
||||
isRenderPending && "opacity-60 transition-opacity",
|
||||
)}
|
||||
className={cn("flex flex-col gap-2", !showIntro && "mt-auto")}
|
||||
data-render-pending={isRenderPending ? "true" : undefined}
|
||||
>
|
||||
<TimelineMessageList
|
||||
@@ -499,7 +505,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</SkeletonReveal>
|
||||
</div>
|
||||
|
||||
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
|
||||
</div>
|
||||
|
||||
@@ -41,6 +41,11 @@ export function useTimelineScrollManager({
|
||||
const previousLastMessageKeyRef = React.useRef<string | undefined>(undefined);
|
||||
const previousMessageCountRef = React.useRef(0);
|
||||
const handledTargetMessageIdRef = React.useRef<string | null>(null);
|
||||
// Mirror isLoading into a ref so the ResizeObservers (which subscribe once)
|
||||
// can skip reacting while the skeleton is up — reacting to height churn under
|
||||
// a streaming-in list is what makes the timeline thrash on entry.
|
||||
const isLoadingRef = React.useRef(isLoading);
|
||||
isLoadingRef.current = isLoading;
|
||||
const [isAtBottom, setIsAtBottom] = React.useState(true);
|
||||
const [highlightedMessageId, setHighlightedMessageId] = React.useState<
|
||||
string | null
|
||||
@@ -241,6 +246,12 @@ export function useTimelineScrollManager({
|
||||
const nextTimelineHeight = entry.contentRect.height;
|
||||
previousTimelineHeightRef.current = nextTimelineHeight;
|
||||
|
||||
// Track height while loading, but don't scroll — the init layout-effect
|
||||
// owns the first scroll once content settles.
|
||||
if (isLoadingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
previousTimelineHeight === null ||
|
||||
Math.abs(nextTimelineHeight - previousTimelineHeight) < 1
|
||||
@@ -271,6 +282,9 @@ export function useTimelineScrollManager({
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (isLoadingRef.current) {
|
||||
return;
|
||||
}
|
||||
if (shouldStickToBottomRef.current) {
|
||||
scrollToBottom("auto");
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user