mirror of
https://github.com/block/buzz.git
synced 2026-11-03 07:51:22 +00:00
fix(desktop): settle virtual timeline seams
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
parent
be9cf8ee17
commit
2fb1da561a
@@ -194,8 +194,19 @@ const MessageTimelineBase = React.forwardRef<
|
||||
const topSentinelRef = React.useRef<HTMLDivElement>(null);
|
||||
const [virtualizerScrollParent, setVirtualizerScrollParent] =
|
||||
React.useState<HTMLDivElement | null>(null);
|
||||
const [virtualizerRenderVersion, bumpVirtualizerRenderVersion] =
|
||||
React.useReducer((version: number) => version + 1, 0);
|
||||
const [timelineVirtualizerApi, setTimelineVirtualizerApi] =
|
||||
React.useState<TimelineVirtualizerApi | null>(null);
|
||||
const useTimelineVirtualizer = true;
|
||||
const activeScrollContainerRef = React.useMemo(
|
||||
() => ({
|
||||
get current() {
|
||||
return virtualizerScrollParent ?? scrollContainerRef.current;
|
||||
},
|
||||
}),
|
||||
[scrollContainerRef, virtualizerScrollParent],
|
||||
);
|
||||
|
||||
// Gate the heavy timeline render (each row runs a synchronous
|
||||
// react-markdown parse) behind React concurrency. `useDeferredValue` lets the
|
||||
@@ -238,7 +249,9 @@ const MessageTimelineBase = React.forwardRef<
|
||||
React.useLayoutEffect(() => {
|
||||
// Re-read after `scrollContainerDomKey` swaps the keyed scroll DOM node.
|
||||
void scrollContainerDomKey;
|
||||
setVirtualizerScrollParent(scrollContainerRef.current);
|
||||
if (!useTimelineVirtualizer) {
|
||||
setVirtualizerScrollParent(scrollContainerRef.current);
|
||||
}
|
||||
setTimelineVirtualizerApi(null);
|
||||
}, [scrollContainerRef, scrollContainerDomKey]);
|
||||
|
||||
@@ -264,11 +277,12 @@ const MessageTimelineBase = React.forwardRef<
|
||||
isLoading: showTimelineSkeleton,
|
||||
messages: deferredMessages,
|
||||
onTargetReached,
|
||||
scrollContainerRef,
|
||||
scrollContainerRef: activeScrollContainerRef,
|
||||
targetMessageId,
|
||||
virtualScrollToMessage: timelineVirtualizerApi?.scrollToMessage,
|
||||
virtualScrollToBottom: timelineVirtualizerApi?.scrollToBottom,
|
||||
virtualizerOwnsPrependAnchoring: virtualizerScrollParent !== null,
|
||||
virtualizerOwnsPrependAnchoring: useTimelineVirtualizer,
|
||||
virtualizerRenderVersion,
|
||||
});
|
||||
|
||||
const timelineIntroSurface = selectTimelineIntroSurface({
|
||||
@@ -282,11 +296,18 @@ const MessageTimelineBase = React.forwardRef<
|
||||
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 activeDirectMessageIntro =
|
||||
showDirectMessageIntro &&
|
||||
(!useTimelineVirtualizer || timelineBodySurface !== "list")
|
||||
? directMessageIntro
|
||||
: null;
|
||||
const activeChannelIntro =
|
||||
showChannelIntro &&
|
||||
(!useTimelineVirtualizer || timelineBodySurface !== "list")
|
||||
? channelIntro
|
||||
: null;
|
||||
const showIntro =
|
||||
activeDirectMessageIntro !== null || activeChannelIntro !== null;
|
||||
const showGenericEmpty = timelineBodySurface === "empty" && !showIntro;
|
||||
const showMessageList = timelineBodySurface === "list";
|
||||
|
||||
@@ -365,14 +386,19 @@ const MessageTimelineBase = React.forwardRef<
|
||||
}
|
||||
}, [jumpToMessage, searchActiveMessageId, showTimelineSkeleton]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: deferredMessages is the intentional retry trigger — a search hit outside the initial window is spliced into messages asynchronously, and the DOM scroll should retry when that row commits.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: deferredMessages and virtualizerRenderVersion are intentional retry triggers — a search hit may be spliced into messages asynchronously, and in virtualized mode a phase-1 index jump only realizes the row; retry when the rendered range changes so the DOM-visible path can center and highlight it.
|
||||
React.useEffect(() => {
|
||||
const target = pendingSearchTargetRef.current;
|
||||
if (!target || showTimelineSkeleton) return;
|
||||
if (jumpToMessage(target, { behavior: "auto" })) {
|
||||
pendingSearchTargetRef.current = null;
|
||||
}
|
||||
}, [deferredMessages, jumpToMessage, showTimelineSkeleton]);
|
||||
}, [
|
||||
deferredMessages,
|
||||
jumpToMessage,
|
||||
showTimelineSkeleton,
|
||||
virtualizerRenderVersion,
|
||||
]);
|
||||
|
||||
const loadOlderViaVirtualizer = React.useCallback(() => {
|
||||
if (!fetchOlder || showTimelineSkeleton || !hasOlderMessages) return;
|
||||
@@ -380,10 +406,10 @@ const MessageTimelineBase = React.forwardRef<
|
||||
}, [fetchOlder, hasOlderMessages, showTimelineSkeleton]);
|
||||
|
||||
useLoadOlderOnScroll({
|
||||
fetchOlder: virtualizerScrollParent ? undefined : fetchOlder,
|
||||
fetchOlder: useTimelineVirtualizer ? undefined : fetchOlder,
|
||||
hasOlderMessages,
|
||||
isLoading: showTimelineSkeleton,
|
||||
scrollContainerRef,
|
||||
scrollContainerRef: activeScrollContainerRef,
|
||||
sentinelRef: topSentinelRef,
|
||||
});
|
||||
|
||||
@@ -393,6 +419,49 @@ const MessageTimelineBase = React.forwardRef<
|
||||
messages: showTimelineSkeleton ? EMPTY_MESSAGES : deferredMessages,
|
||||
});
|
||||
|
||||
const timelineList = showMessageList ? (
|
||||
<TimelineMessageList
|
||||
key={scrollContainerDomKey}
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
channelName={channelName}
|
||||
channelType={channelType}
|
||||
currentPubkey={currentPubkey}
|
||||
firstUnreadMessageId={firstUnreadMessageId}
|
||||
followThreadById={followThreadById}
|
||||
highlightedMessageId={highlightedMessageId}
|
||||
huddleMemberPubkeys={huddleMemberPubkeys}
|
||||
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
|
||||
isFollowingThreadById={isFollowingThreadById}
|
||||
isMessageUnreadById={isMessageUnreadById}
|
||||
messageFooters={messageFooters}
|
||||
mainEntries={deferredMessages === messages ? mainEntries : undefined}
|
||||
threadSummaries={threadSummaries}
|
||||
messages={deferredMessages}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onMarkRead={onMarkRead}
|
||||
onReply={onReply}
|
||||
isSendingVideoReviewComment={isSendingVideoReviewComment}
|
||||
onSendVideoReviewComment={onSendVideoReviewComment}
|
||||
onStartReached={loadOlderViaVirtualizer}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onVirtualizerApiChange={setTimelineVirtualizerApi}
|
||||
onVirtualizerRangeChanged={bumpVirtualizerRenderVersion}
|
||||
onVirtualizerScrollerChange={setVirtualizerScrollParent}
|
||||
onAtBottomStateChange={onVirtualizerAtBottomStateChange}
|
||||
personaLookup={personaLookup}
|
||||
profiles={profiles}
|
||||
searchActiveMessageId={searchActiveMessageId}
|
||||
searchMatchingMessageIds={searchMatchingMessageIds}
|
||||
searchQuery={searchQuery}
|
||||
useVirtualizer={useTimelineVirtualizer}
|
||||
threadUnreadCounts={threadUnreadCounts}
|
||||
unfollowThreadById={unfollowThreadById}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
@@ -429,221 +498,205 @@ const MessageTimelineBase = React.forwardRef<
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 overflow-y-auto overflow-x-hidden overscroll-contain px-2 pt-1",
|
||||
hasComposerOverlay ? "pb-24" : "pb-4",
|
||||
"absolute inset-0 overflow-hidden",
|
||||
!useTimelineVirtualizer &&
|
||||
cn(
|
||||
"overflow-y-auto overflow-x-hidden overscroll-contain px-2 pt-1",
|
||||
hasComposerOverlay ? "pb-24" : "pb-4",
|
||||
),
|
||||
)}
|
||||
data-buzz-conversation-scroll
|
||||
data-buzz-conversation-scroll={
|
||||
useTimelineVirtualizer ? undefined : "true"
|
||||
}
|
||||
data-scroll-restoration-id={scrollRestorationId}
|
||||
data-testid="message-timeline"
|
||||
data-testid={useTimelineVirtualizer ? undefined : "message-timeline"}
|
||||
key={scrollContainerDomKey}
|
||||
onScroll={onScroll}
|
||||
onScroll={useTimelineVirtualizer ? undefined : onScroll}
|
||||
ref={scrollContainerRef}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-col gap-2",
|
||||
channelChrome.contentPadding,
|
||||
(showIntro || showGenericEmpty || showMessageList) &&
|
||||
"min-h-full",
|
||||
)}
|
||||
ref={contentRef}
|
||||
>
|
||||
<div ref={topSentinelRef} aria-hidden className="h-px" />
|
||||
|
||||
{/* Fixed-height slot: an always-mounted height keeps the virtual
|
||||
spacer's offset stable across the load-older fetch toggle, so
|
||||
`scrollMargin` doesn't shift mid-fetch and yank the restore. The
|
||||
visible fetch spinner lives in the absolute overlay above, which
|
||||
does not occupy inline flow. */}
|
||||
<div aria-hidden className="h-8" />
|
||||
|
||||
{useTimelineVirtualizer && timelineList ? (
|
||||
<div
|
||||
className="h-full min-h-0 w-full"
|
||||
data-render-pending={isRenderPending ? "true" : undefined}
|
||||
>
|
||||
{timelineList}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[18rem] min-w-0 flex-col gap-2",
|
||||
(showIntro || showGenericEmpty) && "min-h-full",
|
||||
showMessageList && !showIntro && "mt-auto",
|
||||
"flex w-full flex-col gap-2",
|
||||
channelChrome.contentPadding,
|
||||
(showIntro || showGenericEmpty || showMessageList) &&
|
||||
"min-h-full",
|
||||
)}
|
||||
ref={contentRef}
|
||||
>
|
||||
{showTimelineSkeleton ? (
|
||||
<TimelineSkeleton rows={timelineSkeletonRows} />
|
||||
) : null}
|
||||
{activeDirectMessageIntro ? (
|
||||
<div
|
||||
className="mt-auto flex w-full flex-col items-start px-3 py-2 text-left"
|
||||
data-testid="message-dm-intro"
|
||||
>
|
||||
<DirectMessageIntroAvatarStack
|
||||
participants={activeDirectMessageIntro.participants}
|
||||
/>
|
||||
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
|
||||
{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">
|
||||
{activeDirectMessageIntro.displayName}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div ref={topSentinelRef} aria-hidden className="h-px" />
|
||||
|
||||
{activeChannelIntro ? (
|
||||
<div
|
||||
className="mt-auto flex w-full max-w-2xl flex-col items-start px-3 py-2 text-left"
|
||||
data-testid="message-channel-intro"
|
||||
>
|
||||
{/* Fixed-height slot: an always-mounted height keeps the virtual
|
||||
spacer's offset stable across the load-older fetch toggle, so
|
||||
`scrollMargin` doesn't shift mid-fetch and yank the restore. The
|
||||
visible fetch spinner lives in the absolute overlay above, which
|
||||
does not occupy inline flow. */}
|
||||
<div aria-hidden className="h-8" />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[18rem] min-w-0 flex-col gap-2",
|
||||
useTimelineVirtualizer && "min-h-0 flex-1",
|
||||
(showIntro || showGenericEmpty) && "min-h-full",
|
||||
showMessageList &&
|
||||
!showIntro &&
|
||||
!useTimelineVirtualizer &&
|
||||
"mt-auto",
|
||||
)}
|
||||
>
|
||||
{showTimelineSkeleton ? (
|
||||
<TimelineSkeleton rows={timelineSkeletonRows} />
|
||||
) : null}
|
||||
{activeDirectMessageIntro ? (
|
||||
<div
|
||||
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"
|
||||
className="mt-auto flex w-full flex-col items-start px-3 py-2 text-left"
|
||||
data-testid="message-dm-intro"
|
||||
>
|
||||
{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">
|
||||
#{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">
|
||||
{activeChannelIntro.channelKindLabel}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
{activeChannelIntro.description ? (
|
||||
<p className="mt-2 max-w-xl text-sm leading-5 text-muted-foreground">
|
||||
{activeChannelIntro.description}
|
||||
<DirectMessageIntroAvatarStack
|
||||
participants={activeDirectMessageIntro.participants}
|
||||
/>
|
||||
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
|
||||
{activeDirectMessageIntro.displayName}
|
||||
</p>
|
||||
) : null}
|
||||
{activeChannelIntro.actions?.length ? (
|
||||
<div className="mt-4 flex max-w-full flex-nowrap gap-3 overflow-x-auto pb-1">
|
||||
{activeChannelIntro.actions.map((action) => {
|
||||
const hasDescription = Boolean(action.description);
|
||||
<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">
|
||||
{activeDirectMessageIntro.displayName}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex shrink-0 border border-border/70 bg-background/70 text-left transition-colors hover:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
|
||||
hasDescription
|
||||
? "h-56 w-[13.75rem] flex-col rounded-2xl p-4"
|
||||
: "h-28 w-64 flex-col rounded-2xl p-4",
|
||||
)}
|
||||
data-testid={action.testId}
|
||||
key={action.label}
|
||||
onClick={action.onClick}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
{activeChannelIntro ? (
|
||||
<div
|
||||
className="mt-auto flex w-full max-w-2xl flex-col items-start px-3 py-2 text-left"
|
||||
data-testid="message-channel-intro"
|
||||
>
|
||||
<div
|
||||
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"
|
||||
>
|
||||
{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">
|
||||
#{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">
|
||||
{activeChannelIntro.channelKindLabel}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
{activeChannelIntro.description ? (
|
||||
<p className="mt-2 max-w-xl text-sm leading-5 text-muted-foreground">
|
||||
{activeChannelIntro.description}
|
||||
</p>
|
||||
) : null}
|
||||
{activeChannelIntro.actions?.length ? (
|
||||
<div className="mt-4 flex max-w-full flex-nowrap gap-3 overflow-x-auto pb-1">
|
||||
{activeChannelIntro.actions.map((action) => {
|
||||
const hasDescription = Boolean(action.description);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-full bg-muted/70 text-muted-foreground",
|
||||
"flex shrink-0 border border-border/70 bg-background/70 text-left transition-colors hover:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
|
||||
hasDescription
|
||||
? "h-12 w-12 [&_svg]:h-6 [&_svg]:w-6"
|
||||
: "h-10 w-10 [&_svg]:h-4 [&_svg]:w-4",
|
||||
? "h-56 w-[13.75rem] flex-col rounded-2xl p-4"
|
||||
: "h-28 w-64 flex-col rounded-2xl p-4",
|
||||
)}
|
||||
data-testid={
|
||||
action.testId
|
||||
? `${action.testId}-icon`
|
||||
: undefined
|
||||
}
|
||||
data-testid={action.testId}
|
||||
key={action.label}
|
||||
onClick={action.onClick}
|
||||
type="button"
|
||||
>
|
||||
{action.icon}
|
||||
</span>
|
||||
<span className="mt-auto min-w-0">
|
||||
<span
|
||||
className="block whitespace-normal break-words text-base font-medium leading-6 text-foreground"
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-full bg-muted/70 text-muted-foreground",
|
||||
hasDescription
|
||||
? "h-12 w-12 [&_svg]:h-6 [&_svg]:w-6"
|
||||
: "h-10 w-10 [&_svg]:h-4 [&_svg]:w-4",
|
||||
)}
|
||||
data-testid={
|
||||
action.testId
|
||||
? `${action.testId}-title`
|
||||
? `${action.testId}-icon`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{action.label}
|
||||
{action.icon}
|
||||
</span>
|
||||
{action.description ? (
|
||||
<span className="mt-auto min-w-0">
|
||||
<span
|
||||
className="mt-1 block whitespace-normal break-words text-sm leading-5 text-muted-foreground"
|
||||
className="block whitespace-normal break-words text-base font-medium leading-6 text-foreground"
|
||||
data-testid={
|
||||
action.testId
|
||||
? `${action.testId}-description`
|
||||
? `${action.testId}-title`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{action.description}
|
||||
{action.label}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{action.description ? (
|
||||
<span
|
||||
className="mt-1 block whitespace-normal break-words text-sm leading-5 text-muted-foreground"
|
||||
data-testid={
|
||||
action.testId
|
||||
? `${action.testId}-description`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{action.description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showGenericEmpty ? (
|
||||
<div
|
||||
className="mt-auto rounded-2xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center shadow-xs"
|
||||
data-testid="message-empty"
|
||||
>
|
||||
<p className="text-base font-semibold tracking-tight">
|
||||
{emptyTitle}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{emptyDescription}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{showGenericEmpty ? (
|
||||
<div
|
||||
className="mt-auto rounded-2xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center shadow-xs"
|
||||
data-testid="message-empty"
|
||||
>
|
||||
<p className="text-base font-semibold tracking-tight">
|
||||
{emptyTitle}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{emptyDescription}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showMessageList ? (
|
||||
<div
|
||||
className={cn("flex flex-col gap-2", !showIntro && "mt-auto")}
|
||||
data-render-pending={isRenderPending ? "true" : undefined}
|
||||
>
|
||||
<TimelineMessageList
|
||||
key={scrollContainerDomKey}
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
channelName={channelName}
|
||||
channelType={channelType}
|
||||
currentPubkey={currentPubkey}
|
||||
firstUnreadMessageId={firstUnreadMessageId}
|
||||
followThreadById={followThreadById}
|
||||
highlightedMessageId={highlightedMessageId}
|
||||
huddleMemberPubkeys={huddleMemberPubkeys}
|
||||
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
|
||||
isFollowingThreadById={isFollowingThreadById}
|
||||
isMessageUnreadById={isMessageUnreadById}
|
||||
messageFooters={messageFooters}
|
||||
mainEntries={
|
||||
deferredMessages === messages ? mainEntries : undefined
|
||||
}
|
||||
threadSummaries={threadSummaries}
|
||||
messages={deferredMessages}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onMarkRead={onMarkRead}
|
||||
onReply={onReply}
|
||||
isSendingVideoReviewComment={isSendingVideoReviewComment}
|
||||
onSendVideoReviewComment={onSendVideoReviewComment}
|
||||
onStartReached={loadOlderViaVirtualizer}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onVirtualizerApiChange={setTimelineVirtualizerApi}
|
||||
onAtBottomStateChange={onVirtualizerAtBottomStateChange}
|
||||
personaLookup={personaLookup}
|
||||
profiles={profiles}
|
||||
searchActiveMessageId={searchActiveMessageId}
|
||||
searchMatchingMessageIds={searchMatchingMessageIds}
|
||||
searchQuery={searchQuery}
|
||||
scrollParent={virtualizerScrollParent}
|
||||
threadUnreadCounts={threadUnreadCounts}
|
||||
unfollowThreadById={unfollowThreadById}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{showMessageList ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2",
|
||||
!showIntro && !useTimelineVirtualizer && "mt-auto",
|
||||
useTimelineVirtualizer && "min-h-0 flex-1",
|
||||
)}
|
||||
data-render-pending={isRenderPending ? "true" : undefined}
|
||||
>
|
||||
{timelineList}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isAtBottom ? (
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import * as React from "react";
|
||||
import { GroupedVirtuoso } from "react-virtuoso";
|
||||
import type { Components, GroupedVirtuosoHandle } from "react-virtuoso";
|
||||
import type {
|
||||
Components,
|
||||
GroupedVirtuosoHandle,
|
||||
ListRange,
|
||||
ScrollerProps,
|
||||
} from "react-virtuoso";
|
||||
|
||||
import { formatDayHeading } from "@/features/messages/lib/dateFormatters";
|
||||
import { timelineRowReserveStyle } from "@/features/messages/lib/rowHeightEstimate";
|
||||
@@ -93,10 +98,12 @@ type TimelineMessageListProps = {
|
||||
/** Per-thread unread counts keyed by thread root id. */
|
||||
threadUnreadCounts?: ReadonlyMap<string, number>;
|
||||
/** Existing scroll container, reused by the virtualizer spike so MessageTimeline owns the scroll node. */
|
||||
scrollParent?: HTMLElement | null;
|
||||
useVirtualizer?: boolean;
|
||||
onStartReached?: () => void;
|
||||
onAtBottomStateChange?: (atBottom: boolean) => void;
|
||||
onVirtualizerApiChange?: (api: TimelineVirtualizerApi | null) => void;
|
||||
onVirtualizerRangeChanged?: () => void;
|
||||
onVirtualizerScrollerChange?: (element: HTMLDivElement | null) => void;
|
||||
};
|
||||
|
||||
export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
@@ -130,10 +137,12 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
searchQuery,
|
||||
threadUnreadCounts,
|
||||
unfollowThreadById,
|
||||
scrollParent = null,
|
||||
useVirtualizer = false,
|
||||
onStartReached,
|
||||
onAtBottomStateChange,
|
||||
onVirtualizerApiChange,
|
||||
onVirtualizerRangeChanged,
|
||||
onVirtualizerScrollerChange,
|
||||
}: TimelineMessageListProps) {
|
||||
const entries = React.useMemo(
|
||||
() =>
|
||||
@@ -275,15 +284,16 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
],
|
||||
);
|
||||
|
||||
if (scrollParent) {
|
||||
if (useVirtualizer) {
|
||||
return (
|
||||
<VirtualizedTimelineRows
|
||||
dayGroups={dayGroups}
|
||||
onAtBottomStateChange={onAtBottomStateChange}
|
||||
onStartReached={onStartReached}
|
||||
onVirtualizerApiChange={onVirtualizerApiChange}
|
||||
onVirtualizerRangeChanged={onVirtualizerRangeChanged}
|
||||
onVirtualizerScrollerChange={onVirtualizerScrollerChange}
|
||||
renderItem={renderItem}
|
||||
scrollParent={scrollParent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -326,8 +336,9 @@ type VirtualizedTimelineRowsProps = {
|
||||
onAtBottomStateChange?: (atBottom: boolean) => void;
|
||||
onStartReached?: () => void;
|
||||
onVirtualizerApiChange?: (api: TimelineVirtualizerApi | null) => void;
|
||||
onVirtualizerRangeChanged?: () => void;
|
||||
onVirtualizerScrollerChange?: (element: HTMLDivElement | null) => void;
|
||||
renderItem: (item: TimelineNonDayItem) => React.ReactNode;
|
||||
scrollParent: HTMLElement;
|
||||
};
|
||||
|
||||
function VirtualizedTimelineRows({
|
||||
@@ -335,8 +346,9 @@ function VirtualizedTimelineRows({
|
||||
onAtBottomStateChange,
|
||||
onStartReached,
|
||||
onVirtualizerApiChange,
|
||||
onVirtualizerRangeChanged,
|
||||
onVirtualizerScrollerChange,
|
||||
renderItem,
|
||||
scrollParent,
|
||||
}: VirtualizedTimelineRowsProps) {
|
||||
const virtuosoRef = React.useRef<GroupedVirtuosoHandle>(null);
|
||||
const firstItemIndexStateRef = React.useRef<StableFirstItemIndexState>({
|
||||
@@ -390,17 +402,30 @@ function VirtualizedTimelineRows({
|
||||
return () => onVirtualizerApiChange(null);
|
||||
}, [messageVirtualIndexById, onVirtualizerApiChange]);
|
||||
|
||||
const handleScrollerRef = React.useCallback(
|
||||
(ref: HTMLElement | Window | null) => {
|
||||
onVirtualizerScrollerChange?.(ref instanceof HTMLDivElement ? ref : null);
|
||||
},
|
||||
[onVirtualizerScrollerChange],
|
||||
);
|
||||
|
||||
const handleRangeChanged = React.useCallback(
|
||||
(_range: ListRange) => {
|
||||
onVirtualizerRangeChanged?.();
|
||||
},
|
||||
[onVirtualizerRangeChanged],
|
||||
);
|
||||
|
||||
return (
|
||||
<GroupedVirtuoso<TimelineNonDayItem>
|
||||
ref={virtuosoRef}
|
||||
className="min-h-full"
|
||||
className="h-full min-h-0 w-full"
|
||||
components={virtuosoComponents}
|
||||
computeItemKey={(index, item) =>
|
||||
item === undefined
|
||||
? `timeline-missing-${index}`
|
||||
: getTimelineItemKey(item)
|
||||
}
|
||||
customScrollParent={scrollParent}
|
||||
data={flattenedItems}
|
||||
defaultItemHeight={72}
|
||||
firstItemIndex={firstItemIndex}
|
||||
@@ -433,12 +458,32 @@ function VirtualizedTimelineRows({
|
||||
itemContent={(_index, _groupIndex, item) => (
|
||||
<TimelineRowShell item={item}>{renderItem(item)}</TimelineRowShell>
|
||||
)}
|
||||
rangeChanged={handleRangeChanged}
|
||||
scrollerRef={handleScrollerRef}
|
||||
startReached={onStartReached ? () => onStartReached() : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const virtuosoComponents: Components<TimelineNonDayItem> = {
|
||||
Scroller: React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ScrollerProps & { className?: string }
|
||||
>(function TimelineVirtuosoScroller({ className, ...props }, ref) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
"h-full min-h-0 overflow-y-auto overflow-x-hidden overscroll-contain px-2 pt-1 pb-24",
|
||||
className,
|
||||
)}
|
||||
data-buzz-conversation-scroll
|
||||
data-testid="message-timeline"
|
||||
data-virtuoso-scroller="true"
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
List: React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
|
||||
function TimelineVirtuosoList({ className, ...props }, ref) {
|
||||
return (
|
||||
|
||||
@@ -57,6 +57,8 @@ type UseAnchoredScrollOptions = {
|
||||
virtualScrollToBottom?: (behavior?: ScrollBehavior) => void;
|
||||
/** Spike mode: let the virtualizer preserve prepend position via its own firstItemIndex path. */
|
||||
virtualizerOwnsPrependAnchoring?: boolean;
|
||||
/** Bumps when a virtualized range changes, so pending target/search retries can re-check newly mounted DOM. */
|
||||
virtualizerRenderVersion?: number;
|
||||
};
|
||||
|
||||
type UseAnchoredScrollResult = {
|
||||
@@ -163,6 +165,7 @@ export function useAnchoredScroll({
|
||||
virtualScrollToMessage,
|
||||
virtualScrollToBottom,
|
||||
virtualizerOwnsPrependAnchoring = false,
|
||||
virtualizerRenderVersion = 0,
|
||||
}: UseAnchoredScrollOptions): UseAnchoredScrollResult {
|
||||
// Anchor lives in a ref because it must survive renders and is updated
|
||||
// both on scroll (commit-time read) and in the layout effect (post-render
|
||||
@@ -288,8 +291,11 @@ export function useAnchoredScroll({
|
||||
) {
|
||||
anchorRef.current = { kind: "message", messageId, topOffset: 0 };
|
||||
setIsAtBottom(false);
|
||||
if (options.highlight) highlightMessage(messageId);
|
||||
return true;
|
||||
// In virtualized mode, this is only the phase-1 realization request:
|
||||
// the target row is not in the DOM yet, so centering can still re-aim
|
||||
// once Virtuoso mounts/measures it. Start the highlight clock only on
|
||||
// the later DOM-visible path, otherwise it can fade before the row has
|
||||
// settled in view.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -579,6 +585,7 @@ export function useAnchoredScroll({
|
||||
scrollToMessageImperative,
|
||||
targetMessageId,
|
||||
virtualizerOwnsPrependAnchoring,
|
||||
virtualizerRenderVersion,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user