Files
buzz/desktop/src/features/messages/ui/MessageTimeline.tsx
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 b0426006b5 fix(desktop): stop competing scroll writers collapsing the load-older anchor
Loading older messages under virtualization let three writers fight over
scrollTop on overlapping frames, so the anchored row jittered or collapsed
to the top (~33% of prepends) and the library's reconcile spun the full 5s
MAX_RECONCILE_MS valve. Establish a single owner of scroll position across
the whole fetch+restore window:

- useLoadOlderOnScroll restores by scrollTop ONLY (drop scrollToIndex), via
  one getOffsetForIndex(anchorIndex + prepended, "start")[0] + intra-row gap
  write. getOffsetForIndex is a pure measurement-cache read, so no library
  scrollState is set and the reconcile loop has nothing to fight.
- The viewport ResizeObserver in useTimelineScrollManager no longer runs a
  competing restore during a fetch: it skips while isFetchingOlder is true
  (the spinner's clientHeight 720->590 mount-shift fires before the lock is
  set) and otherwise defers to lockedScrollTopRef when the load-older restore
  holds it. MessageTimeline threads isFetchingOlder into the manager.

The defect was invisible to unit tests (jsdom getBoundingClientRect -> 0) and
to static traces; the new load-older E2E drives a real prepend on six fresh
page loads and asserts the anchor holds every run, the scroller genuinely
grew, and the reconcile terminates. emitMockHistory now honors the relay
filter's until/limit so the mock relay paginates like a real one, which the
E2E needs to exercise a genuine older page.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
2026-06-18 01:42:07 -04:00

644 lines
25 KiB
TypeScript

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";
import type { ChannelType } from "@/shared/api/types";
import type { TimelineItemsResult } from "@/features/messages/lib/timelineItems";
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 { TooltipProvider } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import type { ListVirtualizer } from "@/shared/ui/VirtualizedList";
import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton";
import { TimelineMessageList } from "./TimelineMessageList";
import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll";
import { useTimelineScrollManager } from "./useTimelineScrollManager";
export type MessageTimelineHandle = {
scrollToBottomOnNextUpdate: () => void;
};
type MessageTimelineProps = {
agentPubkeys?: ReadonlySet<string>;
channelId?: string | null;
channelIntro?: ChannelIntro | null;
channelName?: string;
channelType?: ChannelType | null;
messages: TimelineMessage[];
directMessageIntro?: {
displayName: string;
participants: DirectMessageIntroParticipant[];
} | null;
isLoading?: boolean;
emptyTitle?: string;
emptyDescription?: string;
currentPubkey?: string;
fetchOlder?: () => Promise<void>;
hasOlderMessages?: boolean;
/** Optional external ref to the scroll container — used by the parent to
* observe scroll position or adjust padding dynamically. */
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
/** True when the timeline has the composer overlay below it. */
hasComposerOverlay?: boolean;
isFetchingOlder?: boolean;
messageFooters?: Record<string, React.ReactNode>;
/** Map from lowercase pubkey → persona display name for bot members. */
personaLookup?: Map<string, string>;
profiles?: UserProfileLookup;
followThreadById?: (rootId: string) => void;
isFollowingThreadById?: (rootId: string) => boolean;
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
onMarkUnread?: (message: TimelineMessage) => void;
onReply?: (message: TimelineMessage) => void;
isSendingVideoReviewComment?: boolean;
onSendVideoReviewComment?: (
message: TimelineMessage,
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
parentEventId?: string,
) => Promise<void>;
unfollowThreadById?: (rootId: string) => void;
onToggleReaction?: (
message: TimelineMessage,
emoji: string,
remove: boolean,
) => Promise<void>;
/** The message ID of the currently active find-in-channel match. */
searchActiveMessageId?: string | null;
/** Set of message IDs that match the current find-in-channel query. */
searchMatchingMessageIds?: Set<string>;
/** The current find-in-channel query string. */
searchQuery?: string;
targetMessageId?: string | null;
onTargetReached?: (messageId: string) => void;
/** Event id of the oldest unread top-level message at channel open, or null. */
firstUnreadMessageId?: string | null;
/** Count of unread top-level messages at channel open. */
unreadCount?: number;
/** Per-thread unread counts keyed by thread root id. */
threadUnreadCounts?: ReadonlyMap<string, number>;
};
type ChannelIntroAction = {
description?: string;
icon: React.ReactNode;
label: string;
onClick: () => void;
testId?: string;
};
type ChannelIntro = {
actions?: ChannelIntroAction[];
channelKindLabel: string;
channelName: string;
description?: string | null;
icon?: React.ReactNode;
};
/** Stable empty reference used as the `useDeferredValue` initial value so the
* first render on channel entry stays light instead of blocking on the full
* message list. Must be module-level so its identity never changes. */
const EMPTY_MESSAGES: TimelineMessage[] = [];
type DirectMessageIntroParticipant = {
avatarUrl: string | null;
displayName: string;
pubkey: string;
};
const MessageTimelineBase = React.forwardRef<
MessageTimelineHandle,
MessageTimelineProps
>(function MessageTimeline(
{
agentPubkeys,
channelId,
channelIntro = null,
directMessageIntro = null,
messages,
isLoading = false,
emptyTitle = "No messages yet",
emptyDescription = "Send the first message to start the thread.",
currentPubkey,
fetchOlder,
hasComposerOverlay = true,
hasOlderMessages = true,
isFetchingOlder = false,
followThreadById,
isFollowingThreadById,
messageFooters,
personaLookup,
profiles,
onDelete,
onEdit,
onMarkUnread,
onReply,
channelName,
channelType,
isSendingVideoReviewComment = false,
onSendVideoReviewComment,
onToggleReaction,
unfollowThreadById,
scrollContainerRef: externalScrollRef,
searchActiveMessageId = null,
searchMatchingMessageIds,
searchQuery,
targetMessageId = null,
onTargetReached,
firstUnreadMessageId = null,
unreadCount = 0,
threadUnreadCounts,
}: MessageTimelineProps,
ref,
) {
const internalScrollRef = React.useRef<HTMLDivElement>(null);
const scrollContainerRef = externalScrollRef ?? internalScrollRef;
const topSentinelRef = React.useRef<HTMLDivElement>(null);
// The virtualizer instance and the flattened item stream are owned by the
// child TimelineMessageList (which mounts the VirtualizedList) and reported
// up here so the scroll manager can resolve scroll targets by index. The
// virtualizer reaches us via a ref (its identity is stable across renders,
// but it arrives after first paint); the item stream + id->index map arrive
// as state so a rebuild re-runs the scroll manager's index-model paths.
const virtualizerRef = React.useRef<ListVirtualizer | null>(null);
const handleVirtualizer = React.useCallback((instance: ListVirtualizer) => {
virtualizerRef.current = instance;
}, []);
const getVirtualizer = React.useCallback(() => virtualizerRef.current, []);
const [timelineItems, setTimelineItems] =
React.useState<TimelineItemsResult | null>(null);
const handleItems = React.useCallback((result: TimelineItemsResult) => {
setTimelineItems(result);
}, []);
const virtualizerOption = React.useMemo(
() =>
timelineItems
? {
getVirtualizer,
indexByMessageId: timelineItems.indexByMessageId,
itemCount: timelineItems.items.length,
}
: null,
[getVirtualizer, timelineItems],
);
// Gate the heavy timeline render (each row runs a synchronous
// react-markdown parse) behind React concurrency. `useDeferredValue` lets the
// commit that rebuilds the message list yield to higher-priority work, so the
// main thread stops freezing and the OS no longer shows the busy cursor when
// entering a channel. We pass `initialValue: []` so even the FIRST render on
// channel entry stays light — the heavy list streams in on a deferred commit
// rather than blocking the initial paint. We deliberately drive BOTH the
// scroll manager and the rendered list off the same deferred value —
// scroll/autoscroll/deep-link logic reads the DOM (`scrollIntoView`,
// ResizeObserver on the content), so it must stay consistent with what's
// actually painted. You can't scroll to a row that hasn't committed yet.
const deferredMessages = React.useDeferredValue(messages, EMPTY_MESSAGES);
const isRenderPending = deferredMessages !== messages;
const scrollRestorationId = targetMessageId
? `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,
highlightedMessageId,
isAtBottom,
newMessageCount,
restoreScrollPosition,
scrollToBottom,
scrollToBottomOnNextUpdate,
scrollToMessage,
syncScrollState,
} = useTimelineScrollManager({
channelId,
isFetchingOlder,
isLoading: showTimelineSkeleton,
messages: deferredMessages,
onTargetReached,
scrollContainerRef,
targetMessageId,
virtualizer: virtualizerOption,
});
React.useImperativeHandle(
ref,
() => ({
scrollToBottomOnNextUpdate,
}),
[scrollToBottomOnNextUpdate],
);
// The unread pill is a transient, per-open affordance: dismiss it once the
// user acts on it (jumps to the oldest unread) or catches up by reaching the
// bottom of the timeline. Reset when the channel changes so a freshly opened
// channel shows its own pill.
const [isUnreadPillDismissed, setIsUnreadPillDismissed] =
React.useState(false);
// Track whether the pill has been shown at least once this channel visit.
// This prevents the dismiss effect from firing on mount (when isAtBottom
// initializes as true) before the pill ever renders.
const hasShownPillRef = React.useRef(false);
// biome-ignore lint/correctness/useExhaustiveDependencies: reset on channel switch only
React.useEffect(() => {
setIsUnreadPillDismissed(false);
hasShownPillRef.current = false;
}, [channelId]);
React.useEffect(() => {
if (isAtBottom && hasShownPillRef.current) {
setIsUnreadPillDismissed(true);
}
}, [isAtBottom]);
const showUnreadPill =
!isUnreadPillDismissed &&
unreadCount > 0 &&
firstUnreadMessageId !== null &&
!showTimelineSkeleton;
if (showUnreadPill) hasShownPillRef.current = true;
const handleJumpToOldestUnread = React.useCallback(() => {
setIsUnreadPillDismissed(true);
if (firstUnreadMessageId) {
scrollToMessage(firstUnreadMessageId);
}
}, [firstUnreadMessageId, scrollToMessage]);
// Scroll to the active search match when it changes. `scrollToMessage`
// resolves the target through the virtualizer's index model (the row may be
// windowed out of the DOM), falling back to a DOM query when not virtualized.
const prevSearchActiveRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (showTimelineSkeleton) return;
if (
!searchActiveMessageId ||
searchActiveMessageId === prevSearchActiveRef.current
) {
prevSearchActiveRef.current = searchActiveMessageId;
return;
}
prevSearchActiveRef.current = searchActiveMessageId;
scrollToMessage(searchActiveMessageId);
}, [scrollToMessage, searchActiveMessageId, showTimelineSkeleton]);
useLoadOlderOnScroll({
fetchOlder,
hasOlderMessages,
isLoading: showTimelineSkeleton,
restoreScrollPosition,
scrollContainerRef,
sentinelRef: topSentinelRef,
virtualizer: virtualizerOption,
});
const timelineSkeletonRows = useTimelineSkeletonRows({
channelId,
isLoading: showTimelineSkeleton,
messages: showTimelineSkeleton ? EMPTY_MESSAGES : deferredMessages,
});
return (
<TooltipProvider delayDuration={200}>
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{showUnreadPill ? (
<div
className={cn(
"pointer-events-none absolute inset-x-0 z-20 flex translate-y-3 justify-center px-4",
channelChrome.top,
)}
>
<Button
className="pointer-events-auto h-7 min-h-7 gap-1.5 rounded-full border-primary/40 bg-primary/10 px-2.5 text-2xs font-medium text-primary shadow-xs backdrop-blur-sm hover:bg-primary/20 [&_svg]:size-4"
data-testid="message-unread-pill"
onClick={handleJumpToOldestUnread}
size="sm"
type="button"
variant="outline"
>
<ArrowUp aria-hidden />
{`${unreadCount} new message${unreadCount === 1 ? "" : "s"}`}
</Button>
</div>
) : null}
<div
className={cn(
"absolute inset-0 overflow-y-auto overflow-x-hidden overscroll-contain px-2 pt-1 [overflow-anchor:none]",
hasComposerOverlay ? "pb-24" : "pb-4",
)}
data-scroll-restoration-id={scrollRestorationId}
data-testid="message-timeline"
onScroll={syncScrollState}
ref={scrollContainerRef}
>
<div
className={cn(
"flex w-full flex-col gap-2",
channelChrome.contentPadding,
(showIntro || showGenericEmpty) && "min-h-full",
)}
ref={contentRef}
>
<div ref={topSentinelRef} aria-hidden className="h-px" />
{isFetchingOlder ? (
<div className="flex justify-center py-2">
<Spinner className="h-4 w-4 border-2 text-muted-foreground" />
</div>
) : null}
<div
className={cn(
"flex min-h-[18rem] min-w-0 flex-col gap-2",
(showIntro || showGenericEmpty) && "min-h-full",
showMessageList && !showIntro && "mt-auto",
)}
>
{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={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}
{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"
>
<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 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-xl p-4",
)}
data-testid={action.testId}
key={action.label}
onClick={action.onClick}
type="button"
>
<span
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}-icon`
: undefined
}
>
{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"
data-testid={
action.testId
? `${action.testId}-title`
: undefined
}
>
{action.label}
</span>
{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-3xl 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
agentPubkeys={agentPubkeys}
channelId={channelId}
channelName={channelName}
channelType={channelType}
currentPubkey={currentPubkey}
firstUnreadMessageId={firstUnreadMessageId}
followThreadById={followThreadById}
highlightedMessageId={highlightedMessageId}
isFollowingThreadById={isFollowingThreadById}
messageFooters={messageFooters}
messages={deferredMessages}
onDelete={onDelete}
onEdit={onEdit}
onMarkUnread={onMarkUnread}
onReply={onReply}
isSendingVideoReviewComment={isSendingVideoReviewComment}
onSendVideoReviewComment={onSendVideoReviewComment}
onToggleReaction={onToggleReaction}
personaLookup={personaLookup}
profiles={profiles}
searchActiveMessageId={searchActiveMessageId}
searchMatchingMessageIds={searchMatchingMessageIds}
searchQuery={searchQuery}
threadUnreadCounts={threadUnreadCounts}
unfollowThreadById={unfollowThreadById}
scrollContainerRef={scrollContainerRef}
onItems={handleItems}
onVirtualizer={handleVirtualizer}
/>
</div>
) : null}
</div>
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
</div>
</div>
{!isAtBottom ? (
<div
className={cn(
"pointer-events-none absolute inset-x-0 z-20 flex justify-center px-4",
hasComposerOverlay ? "bottom-36" : "bottom-4",
)}
>
<Button
className="pointer-events-auto h-7 min-h-7 gap-1.5 rounded-full border-border/50 bg-background/85 px-2.5 text-2xs font-medium text-muted-foreground shadow-xs backdrop-blur-sm hover:bg-muted/70 hover:text-foreground [&_svg]:size-4"
data-testid="message-scroll-to-latest"
onClick={() => {
scrollToBottom("smooth");
}}
size="sm"
type="button"
variant="outline"
>
<ArrowDown aria-hidden />
{newMessageCount > 0
? `${newMessageCount} new message${newMessageCount === 1 ? "" : "s"}`
: "Jump to latest"}
</Button>
</div>
) : null}
</div>
</TooltipProvider>
);
});
export const MessageTimeline = React.memo(MessageTimelineBase);
function DirectMessageIntroAvatarStack({
participants,
}: {
participants: DirectMessageIntroParticipant[];
}) {
const { hiddenCount, visibleParticipants } =
getDmParticipantPreview(participants);
const stackItemCount = visibleParticipants.length + (hiddenCount > 0 ? 1 : 0);
return (
<div
aria-hidden="true"
className="flex shrink-0 items-center"
data-testid="message-dm-intro-avatar-stack"
>
{visibleParticipants.map((participant, index) => (
<div
className={index > 0 ? "-ml-5" : ""}
data-testid="message-dm-intro-avatar-stack-participant"
key={participant.pubkey}
style={{
zIndex: index + 1,
...(index < stackItemCount - 1 && {
mask: "radial-gradient(circle 34px at calc(100% + 10px) 50%, transparent 99%, #fff 100%)",
WebkitMask:
"radial-gradient(circle 34px at calc(100% + 10px) 50%, transparent 99%, #fff 100%)",
}),
}}
>
<UserAvatar
avatarUrl={participant.avatarUrl}
className="h-[60px] w-[60px] text-base"
displayName={participant.displayName}
size="md"
/>
</div>
))}
{hiddenCount > 0 ? (
<div
className={visibleParticipants.length > 0 ? "-ml-5" : ""}
data-testid="message-dm-intro-avatar-stack-more"
style={{ zIndex: stackItemCount }}
>
<span className="flex h-[60px] w-[60px] items-center justify-center rounded-full bg-secondary font-semibold text-secondary-foreground shadow-xs">
<span className="text-lg leading-none">+{hiddenCount}</span>
</span>
</div>
) : null}
</div>
);
}