mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
feat(desktop): virtualize timeline + thread pane onto single scroll owner
The two chat-message surfaces (MessageTimeline, MessageThreadPanel) ran on a hand-rolled scroll manager with two ResizeObservers and a double-rAF scrollTop band-aid that raced during prepend, causing the channel-jump bug. Replace it with @tanstack/react-virtual as the single scroll owner. virtual-core@3.17.0 ships the three hard behaviors natively, so they are configuration not hand-built code: anchorTo:"end" holds the viewport on load-older prepend, followOnAppend pins the bottom during a burst, and the internal reconcile loop settles deep-link scrollToIndex once the target row measures. The hook owns only what the library does not: the short-channel bottom-align pad, the at-bottom/new-message pill state, and the deep-link highlight. Channel/thread switch resets via key-remount (fresh virtualizer = zero stale measurement cache) rather than porting the old reset logic. useTimelineScrollManager + useLoadOlderOnScroll deleted, zero callers. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
parent
7ab6b4051c
commit
8a4ef01cff
@@ -638,6 +638,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
<MessageTimeline
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={activeChannel?.id}
|
||||
key={activeChannel?.id}
|
||||
channelIntro={channelIntro}
|
||||
directMessageIntro={directMessageIntro}
|
||||
scrollContainerRef={timelineScrollRef}
|
||||
@@ -790,6 +791,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
agentPubkeys={agentPubkeys}
|
||||
channel={activeChannel}
|
||||
channelId={activeChannel?.id ?? null}
|
||||
key={threadHeadMessage.id}
|
||||
channelName={activeChannel?.name ?? "channel"}
|
||||
currentPubkey={currentPubkey}
|
||||
disabled={isComposerDisabled}
|
||||
|
||||
@@ -16,8 +16,8 @@ export type MessageLinkInput = {
|
||||
*
|
||||
* Currently emitted into the URL but not consumed by the click handler
|
||||
* or deep-link listener — both route via `goChannel(channelId,
|
||||
* { messageId })` and let `useTimelineScrollManager` resolve the target.
|
||||
* Reserved for future "open in thread view" routing.
|
||||
* { messageId })` and let the timeline's `useChatScrollVirtualizer`
|
||||
* resolve the target. Reserved for future "open in thread view" routing.
|
||||
*/
|
||||
threadRootId?: string | null;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import test from "node:test";
|
||||
import {
|
||||
BOTTOM_THRESHOLD_PX,
|
||||
buildDayGroupBoundaries,
|
||||
buildThreadReplyVirtualItems,
|
||||
buildTimelineVirtualItems,
|
||||
isNearBottomMetrics,
|
||||
resolveDeepLinkTarget,
|
||||
@@ -394,3 +395,71 @@ test("virtual-items: item count equals entries plus one day item per calendar da
|
||||
["day", "message", "message", "day", "unread", "message"],
|
||||
);
|
||||
});
|
||||
|
||||
// --- thread reply flat-item model (thread-pane virtualization) --------------
|
||||
//
|
||||
// buildThreadReplyVirtualItems flattens reply entries + firstUnreadReplyId into
|
||||
// the ordered, keyed row list the thread pane's virtualizer measures. The
|
||||
// thread pane has no day grouping and renders the head OUTSIDE the list, so the
|
||||
// only kinds are an unread boundary and a reply (summary rides on the reply).
|
||||
|
||||
test("thread-items: empty entries produce no items", () => {
|
||||
assert.deepEqual(buildThreadReplyVirtualItems([]), []);
|
||||
});
|
||||
|
||||
test("thread-items: each reply becomes one item, no day dividers", () => {
|
||||
const items = buildThreadReplyVirtualItems([
|
||||
entry({ id: "a", createdAt: dayAt(2026, 6, 14) }),
|
||||
entry({ id: "b", createdAt: dayAt(2026, 6, 15) }),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
items.map((item) => item.kind),
|
||||
["reply", "reply"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
items.map((item) => item.key),
|
||||
["a", "b"],
|
||||
);
|
||||
});
|
||||
|
||||
test("thread-items: unread divider precedes the first unread reply when not index 0", () => {
|
||||
const items = buildThreadReplyVirtualItems(
|
||||
[entry({ id: "a" }), entry({ id: "b" }), entry({ id: "c" })],
|
||||
"b",
|
||||
);
|
||||
assert.deepEqual(
|
||||
items.map((item) => item.kind),
|
||||
["reply", "unread", "reply", "reply"],
|
||||
);
|
||||
assert.equal(items[1].key, "unread-b");
|
||||
});
|
||||
|
||||
test("thread-items: unread divider suppressed when the first unread reply is index 0", () => {
|
||||
const items = buildThreadReplyVirtualItems(
|
||||
[entry({ id: "a" }), entry({ id: "b" })],
|
||||
"a",
|
||||
);
|
||||
assert.deepEqual(
|
||||
items.map((item) => item.kind),
|
||||
["reply", "reply"],
|
||||
);
|
||||
});
|
||||
|
||||
test("thread-items: a reply carries its inline summary, never a separate item", () => {
|
||||
const summary = {
|
||||
threadHeadId: "a",
|
||||
replyCount: 2,
|
||||
lastReplyAt: null,
|
||||
participants: [],
|
||||
};
|
||||
const items = buildThreadReplyVirtualItems([entry({ id: "a" }, summary)]);
|
||||
assert.equal(items.length, 1);
|
||||
assert.equal(items[0].summary, summary);
|
||||
});
|
||||
|
||||
test("thread-items: reply key prefers renderKey for optimistic-send identity", () => {
|
||||
const items = buildThreadReplyVirtualItems([
|
||||
entry({ id: "server-id", renderKey: "local-key" }),
|
||||
]);
|
||||
assert.equal(items[0].key, "local-key");
|
||||
});
|
||||
|
||||
@@ -160,7 +160,7 @@ export function selectDeferredListRenderState(
|
||||
* the virtualizer measures and positions, so every divider that the legacy
|
||||
* `TimelineMessageList` rendered as a sibling node becomes its own item here.
|
||||
*
|
||||
* - "day" → a day-boundary heading; renders the sticky `DayDivider`
|
||||
* - "day" → a day-boundary heading; renders the inline `DayDivider`
|
||||
* - "unread" → the "New" read/unread boundary above the first unread message
|
||||
* - "system" → a system message (join/leave); renders `SystemMessageRow`
|
||||
* - "message" → a normal message; carries the thread `summary` it renders
|
||||
@@ -227,3 +227,51 @@ export function buildTimelineVirtualItems(
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row in the flattened, virtualizer-ready thread reply list. The thread
|
||||
* pane has a simpler shape than the main timeline — no day grouping, no system
|
||||
* rows, and the head message renders OUTSIDE the virtualized region — so the
|
||||
* only item kinds are an unread boundary and a reply (which carries the inline
|
||||
* thread `summary` it renders beneath the row, never a separate item, to keep
|
||||
* one measured unit per reply).
|
||||
*
|
||||
* `key` is byte-identical to the legacy reply-list keys
|
||||
* (`message.renderKey ?? message.id`) so optimistic-send identity stays stable
|
||||
* across the migration.
|
||||
*/
|
||||
export type ThreadReplyVirtualItem =
|
||||
| { kind: "unread"; key: string }
|
||||
| {
|
||||
kind: "reply";
|
||||
key: string;
|
||||
message: TimelineMessage;
|
||||
summary: TimelineThreadSummary | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Flattens thread reply entries into the ordered virtual-item list the thread
|
||||
* pane's virtualizer consumes. Mirrors the legacy reply-list render walk: an
|
||||
* unread divider precedes the first unread reply (suppressed at index 0, where
|
||||
* there is nothing above it to divide), and every entry becomes a `reply` item.
|
||||
* Pure over the snapshot so the divider rule stays lib-tested without a DOM.
|
||||
*/
|
||||
export function buildThreadReplyVirtualItems(
|
||||
entries: readonly MainTimelineEntry[],
|
||||
firstUnreadReplyId: string | null | undefined = null,
|
||||
): ThreadReplyVirtualItem[] {
|
||||
const items: ThreadReplyVirtualItem[] = [];
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const { message, summary } = entries[i];
|
||||
const replyKey = message.renderKey ?? message.id;
|
||||
|
||||
if (shouldRenderUnreadDivider(i, message.id, firstUnreadReplyId ?? null)) {
|
||||
items.push({ kind: "unread", key: `unread-${replyKey}` });
|
||||
}
|
||||
|
||||
items.push({ kind: "reply", key: replyKey, message, summary });
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ export function DayDivider({ label }: { label: string }) {
|
||||
return (
|
||||
<section
|
||||
aria-label={label}
|
||||
className="sticky top-[92px] z-[5] flex justify-center py-1"
|
||||
className="flex justify-center py-1"
|
||||
data-testid="message-timeline-day-divider"
|
||||
data-day-label={label}
|
||||
>
|
||||
|
||||
@@ -31,8 +31,12 @@ import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
|
||||
import { TypingIndicatorRow } from "./TypingIndicatorRow";
|
||||
import { UnreadDivider } from "./UnreadDivider";
|
||||
import { useComposerHeightPadding } from "./useComposerHeightPadding";
|
||||
import { useTimelineScrollManager } from "./useTimelineScrollManager";
|
||||
import { selectDeferredListRenderState } from "@/features/messages/lib/timelineSnapshot";
|
||||
import { useChatScrollVirtualizer } from "./useChatScrollVirtualizer";
|
||||
import {
|
||||
buildThreadReplyVirtualItems,
|
||||
selectDeferredListRenderState,
|
||||
selectLatestMessageKey,
|
||||
} from "@/features/messages/lib/timelineSnapshot";
|
||||
|
||||
type MessageThreadPanelProps = {
|
||||
agentPubkeys?: ReadonlySet<string>;
|
||||
@@ -95,6 +99,10 @@ type MessageThreadPanelProps = {
|
||||
* `EMPTY_MESSAGES` in MessageTimeline. */
|
||||
const EMPTY_THREAD_REPLIES: MainTimelineEntry[] = [];
|
||||
|
||||
/** Vertical gap (px) the virtualizer inserts between reply rows — the spacing
|
||||
* the legacy reply list expressed as `space-y-2.5`. */
|
||||
const THREAD_REPLY_GAP_PX = 10;
|
||||
|
||||
type MessageThreadPanelSkeletonProps = {
|
||||
isSinglePanelView?: boolean;
|
||||
layout?: "standalone" | "split";
|
||||
@@ -317,8 +325,6 @@ export function MessageThreadPanel({
|
||||
isSinglePanelView,
|
||||
);
|
||||
|
||||
const threadHeadId = threadHead?.id ?? null;
|
||||
|
||||
const composerReplyTarget =
|
||||
replyTargetMessage && threadHead && replyTargetMessage.id !== threadHead.id
|
||||
? {
|
||||
@@ -333,13 +339,10 @@ export function MessageThreadPanel({
|
||||
// the main thread and the OS would show the busy cursor. Gate the reply render
|
||||
// behind `useDeferredValue`. `initialValue: []` keeps even the FIRST render on
|
||||
// thread-open light; the heavy list streams in on a deferred, interruptible
|
||||
// commit. We deliberately drive BOTH the scroll manager and the rendered list
|
||||
// off the SAME deferred value — sticky-bottom / deep-link logic reads the DOM
|
||||
// (`scrollIntoView`), so it must stay consistent with what's actually painted.
|
||||
// You can't scroll to a reply that hasn't committed yet. The thread pane gets
|
||||
// this no-tearing guarantee for free by routing through the same
|
||||
// `useTimelineScrollManager` (and its `timelineSnapshot` helpers) as the main
|
||||
// timeline.
|
||||
// commit. We deliberately drive BOTH the virtualizer and the rendered list
|
||||
// off the SAME deferred value — the deep-link index resolves against this
|
||||
// snapshot, so a jump never targets a reply the DOM has not committed yet
|
||||
// (the no-tearing guard). You can't scroll to a reply that hasn't rendered.
|
||||
const deferredThreadReplies = React.useDeferredValue(
|
||||
threadReplies,
|
||||
EMPTY_THREAD_REPLIES,
|
||||
@@ -354,32 +357,130 @@ export function MessageThreadPanel({
|
||||
threadReplies.length,
|
||||
);
|
||||
|
||||
const threadMessages = React.useMemo(
|
||||
() => deferredThreadReplies.map((entry) => entry.message),
|
||||
// Flat virtual-item list for the reply region (head renders outside it). The
|
||||
// deep-link index map below is built off this SAME list — the no-tearing
|
||||
// guard — so a `scrollTargetId` jump only fires once the target reply exists
|
||||
// in the committed snapshot.
|
||||
const replyItems = React.useMemo(
|
||||
() =>
|
||||
buildThreadReplyVirtualItems(deferredThreadReplies, firstUnreadReplyId),
|
||||
[deferredThreadReplies, firstUnreadReplyId],
|
||||
);
|
||||
const indexByMessageId = React.useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (let i = 0; i < replyItems.length; i++) {
|
||||
const item = replyItems[i];
|
||||
if (item.kind === "reply") {
|
||||
map.set(item.message.id, i);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [replyItems]);
|
||||
const latestReplyKey = React.useMemo(
|
||||
() => selectLatestMessageKey(deferredThreadReplies.map((e) => e.message)),
|
||||
[deferredThreadReplies],
|
||||
);
|
||||
const getReplyItemKey = React.useCallback(
|
||||
(index: number) => replyItems[index].key,
|
||||
[replyItems],
|
||||
);
|
||||
|
||||
// Bottom-stick + deep-link only — a thread never grows at the head, so
|
||||
// `anchorTo:"end"` makes prepend structurally absent (no flag needed). No
|
||||
// short-channel `topPad`: replies render top-down under the head row, which
|
||||
// is the legacy thread-pane layout.
|
||||
const {
|
||||
bottomAnchorRef,
|
||||
contentRef,
|
||||
virtualizer,
|
||||
isAtBottom,
|
||||
newMessageCount,
|
||||
highlightedMessageId,
|
||||
scrollToBottom,
|
||||
syncScrollState,
|
||||
} = useTimelineScrollManager({
|
||||
channelId: threadHeadId,
|
||||
// Wait for deferred replies to commit before scroll-init (else rows mount un-scrolled).
|
||||
isLoading: repliesRenderState === "pending",
|
||||
messages: threadMessages,
|
||||
scrollToItem,
|
||||
} = useChatScrollVirtualizer({
|
||||
count: replyItems.length,
|
||||
scrollRef: threadBodyRef,
|
||||
getItemKey: getReplyItemKey,
|
||||
gap: THREAD_REPLY_GAP_PX,
|
||||
latestMessageKey: latestReplyKey,
|
||||
onTargetReached: onScrollTargetResolved,
|
||||
scrollContainerRef: threadBodyRef,
|
||||
targetMessageId: scrollTargetId,
|
||||
});
|
||||
|
||||
// Deep-link to `scrollTargetId` once it resolves against the rendered
|
||||
// snapshot. The library's reconcile loop settles the offset once the target
|
||||
// reply measures — the same W1 deep-link settle the main timeline uses, with
|
||||
// prepend off.
|
||||
const lastJumpedTargetRef = React.useRef<string | null>(null);
|
||||
React.useEffect(() => {
|
||||
if (!scrollTargetId) {
|
||||
lastJumpedTargetRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (scrollTargetId === lastJumpedTargetRef.current) {
|
||||
return;
|
||||
}
|
||||
const index = indexByMessageId.get(scrollTargetId);
|
||||
if (index === undefined) {
|
||||
return;
|
||||
}
|
||||
lastJumpedTargetRef.current = scrollTargetId;
|
||||
scrollToItem(index, scrollTargetId);
|
||||
}, [scrollTargetId, indexByMessageId, scrollToItem]);
|
||||
|
||||
if (!threadHead) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderReplyItem = (
|
||||
item: (typeof replyItems)[number],
|
||||
): React.ReactNode => {
|
||||
if (item.kind === "unread") {
|
||||
return <UnreadDivider />;
|
||||
}
|
||||
const { message, summary } = item;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1",
|
||||
summary &&
|
||||
"group/message mx-1 rounded-2xl px-0 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
|
||||
message.id === highlightedMessageId &&
|
||||
"before:absolute before:-inset-y-1 before:inset-x-0 before:animate-[route-target-highlight-fade_2s_ease-out_forwards] before:bg-primary/10 before:content-[''] motion-reduce:before:animate-none",
|
||||
)}
|
||||
>
|
||||
<MessageRow
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
hoverBackground={!summary}
|
||||
layoutVariant="thread-reply"
|
||||
message={message}
|
||||
onDelete={
|
||||
onDelete && canManageMessage(message, currentPubkey)
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
onEdit && canManageMessage(message, currentPubkey)
|
||||
? onEdit
|
||||
: undefined
|
||||
}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onReply={onSelectReplyTarget}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
/>
|
||||
{summary ? (
|
||||
<MessageThreadSummaryRow
|
||||
depth={message.depth}
|
||||
message={message}
|
||||
onOpenThread={onExpandReplies}
|
||||
summary={summary}
|
||||
unreadCount={threadReplyUnreadCounts?.get(message.id)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const threadScrollRegion = (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -388,124 +489,88 @@ export function MessageThreadPanel({
|
||||
!isSplitLayout && !isFloatingOverlay && "pt-[4.75rem]",
|
||||
)}
|
||||
data-testid="message-thread-body"
|
||||
onScroll={syncScrollState}
|
||||
ref={threadBodyRef}
|
||||
>
|
||||
<div ref={contentRef}>
|
||||
<div className="px-3 pb-1 pt-0" data-testid="message-thread-head">
|
||||
<div className="rounded-2xl">
|
||||
<MessageRow
|
||||
actionBarPlacement="inside"
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
isFollowingThread={isFollowingThread}
|
||||
layoutVariant="thread-reply"
|
||||
message={threadHead}
|
||||
onDelete={
|
||||
onDelete && canManageMessage(threadHead, currentPubkey)
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
onEdit && canManageMessage(threadHead, currentPubkey)
|
||||
? onEdit
|
||||
: undefined
|
||||
}
|
||||
onFollowThread={
|
||||
onFollowThread ? (_msg) => onFollowThread() : undefined
|
||||
}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onUnfollowThread={
|
||||
onUnfollowThread ? (_msg) => onUnfollowThread() : undefined
|
||||
}
|
||||
profiles={profiles}
|
||||
videoReviewContext={threadHeadVideoReviewContext}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-3 pb-1 pt-0" data-testid="message-thread-head">
|
||||
<div className="rounded-2xl">
|
||||
<MessageRow
|
||||
actionBarPlacement="inside"
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
isFollowingThread={isFollowingThread}
|
||||
layoutVariant="thread-reply"
|
||||
message={threadHead}
|
||||
onDelete={
|
||||
onDelete && canManageMessage(threadHead, currentPubkey)
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
onEdit && canManageMessage(threadHead, currentPubkey)
|
||||
? onEdit
|
||||
: undefined
|
||||
}
|
||||
onFollowThread={
|
||||
onFollowThread ? (_msg) => onFollowThread() : undefined
|
||||
}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onUnfollowThread={
|
||||
onUnfollowThread ? (_msg) => onUnfollowThread() : undefined
|
||||
}
|
||||
profiles={profiles}
|
||||
videoReviewContext={threadHeadVideoReviewContext}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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",
|
||||
)}
|
||||
data-render-pending={isRepliesPending ? "true" : undefined}
|
||||
>
|
||||
{deferredThreadReplies.map((entry, index) => {
|
||||
const showUnreadDivider =
|
||||
index > 0 && entry.message.id === firstUnreadReplyId;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1",
|
||||
entry.summary &&
|
||||
"group/message mx-1 rounded-2xl px-0 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
|
||||
)}
|
||||
key={entry.message.renderKey ?? entry.message.id}
|
||||
>
|
||||
{showUnreadDivider ? <UnreadDivider /> : null}
|
||||
<MessageRow
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
hoverBackground={!entry.summary}
|
||||
layoutVariant="thread-reply"
|
||||
message={entry.message}
|
||||
onDelete={
|
||||
onDelete &&
|
||||
canManageMessage(entry.message, currentPubkey)
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
onEdit && canManageMessage(entry.message, currentPubkey)
|
||||
? onEdit
|
||||
: undefined
|
||||
}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onReply={onSelectReplyTarget}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
/>
|
||||
{entry.summary ? (
|
||||
<MessageThreadSummaryRow
|
||||
depth={entry.message.depth}
|
||||
message={entry.message}
|
||||
onOpenThread={onExpandReplies}
|
||||
summary={entry.summary}
|
||||
unreadCount={threadReplyUnreadCounts?.get(
|
||||
entry.message.id,
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : repliesRenderState === "empty" ? (
|
||||
// Only show the empty state when the thread is GENUINELY empty.
|
||||
// Keying off `deferredThreadReplies` would flash "No replies" for a
|
||||
// frame while a non-empty list streams in on the deferred commit.
|
||||
<div className="rounded-2xl border border-dashed border-border/70 bg-card/40 px-4 py-6 text-center">
|
||||
<p className="text-sm font-medium text-foreground/80">
|
||||
No replies in this branch yet
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Reply in the thread to continue this branch.
|
||||
</p>
|
||||
</div>
|
||||
) : // "pending": deferred list is empty but the live list has content —
|
||||
// rows are streaming in on the deferred commit. Paint nothing rather
|
||||
// than flashing the empty state.
|
||||
null}
|
||||
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
|
||||
</div>
|
||||
<div className="px-3 pb-3 pt-1" data-testid="message-thread-replies">
|
||||
{repliesRenderState === "list" ? (
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
// 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",
|
||||
)}
|
||||
data-render-pending={isRepliesPending ? "true" : undefined}
|
||||
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => (
|
||||
<div
|
||||
data-index={virtualRow.index}
|
||||
key={virtualRow.key}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{renderReplyItem(replyItems[virtualRow.index])}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : repliesRenderState === "empty" ? (
|
||||
// Only show the empty state when the thread is GENUINELY empty.
|
||||
// Keying off `deferredThreadReplies` would flash "No replies" for a
|
||||
// frame while a non-empty list streams in on the deferred commit.
|
||||
<div className="rounded-2xl border border-dashed border-border/70 bg-card/40 px-4 py-6 text-center">
|
||||
<p className="text-sm font-medium text-foreground/80">
|
||||
No replies in this branch yet
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Reply in the thread to continue this branch.
|
||||
</p>
|
||||
</div>
|
||||
) : // "pending": deferred list is empty but the live list has content —
|
||||
// rows are streaming in on the deferred commit. Paint nothing rather
|
||||
// than flashing the empty state.
|
||||
null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,11 @@ import * as React from "react";
|
||||
import { ArrowDown, ArrowUp, Hash } from "lucide-react";
|
||||
|
||||
import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay";
|
||||
import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
|
||||
import {
|
||||
buildTimelineVirtualItems,
|
||||
selectLatestMessageKey,
|
||||
} from "@/features/messages/lib/timelineSnapshot";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { ChannelType } from "@/shared/api/types";
|
||||
@@ -14,8 +19,7 @@ import { TooltipProvider } from "@/shared/ui/tooltip";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton";
|
||||
import { TimelineMessageList } from "./TimelineMessageList";
|
||||
import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll";
|
||||
import { useTimelineScrollManager } from "./useTimelineScrollManager";
|
||||
import { useChatScrollVirtualizer } from "./useChatScrollVirtualizer";
|
||||
|
||||
type MessageTimelineProps = {
|
||||
agentPubkeys?: ReadonlySet<string>;
|
||||
@@ -101,6 +105,10 @@ type ChannelIntro = {
|
||||
* message list. Must be module-level so its identity never changes. */
|
||||
const EMPTY_MESSAGES: TimelineMessage[] = [];
|
||||
|
||||
/** Vertical gap (px) the virtualizer inserts between rows — the spacing the
|
||||
* legacy list expressed as the `gap-2.5` between its top-level row wrappers. */
|
||||
const TIMELINE_ROW_GAP_PX = 10;
|
||||
|
||||
type DirectMessageIntroParticipant = {
|
||||
avatarUrl: string | null;
|
||||
displayName: string;
|
||||
@@ -167,25 +175,79 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
? `message-timeline:${channelId ?? "none"}:target:${targetMessageId}`
|
||||
: `message-timeline:${channelId ?? "none"}`;
|
||||
|
||||
// Flatten the deferred snapshot into the ordered virtual-item list the
|
||||
// virtualizer measures. Deep-link/search index lookups below resolve against
|
||||
// this SAME snapshot — a jump must target a row the DOM has committed, never
|
||||
// a fresher message that hasn't rendered yet (the no-tearing guard).
|
||||
const entries = React.useMemo(
|
||||
() => buildMainTimelineEntries(deferredMessages),
|
||||
[deferredMessages],
|
||||
);
|
||||
const items = React.useMemo(
|
||||
() => buildTimelineVirtualItems(entries, firstUnreadMessageId),
|
||||
[entries, firstUnreadMessageId],
|
||||
);
|
||||
// Map message id -> its flat-item index for deep-link jumps. Built off the
|
||||
// same `items` so the index the virtualizer scrolls to and the row it renders
|
||||
// are always the same snapshot.
|
||||
const indexByMessageId = React.useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind === "message" || item.kind === "system") {
|
||||
map.set(item.message.id, i);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [items]);
|
||||
|
||||
const latestMessageKey = React.useMemo(
|
||||
() => selectLatestMessageKey(deferredMessages),
|
||||
[deferredMessages],
|
||||
);
|
||||
const getItemKey = React.useCallback(
|
||||
(index: number) => items[index].key,
|
||||
[items],
|
||||
);
|
||||
|
||||
const {
|
||||
bottomAnchorRef,
|
||||
contentRef,
|
||||
highlightedMessageId,
|
||||
virtualizer,
|
||||
topPad,
|
||||
isAtBottom,
|
||||
newMessageCount,
|
||||
restoreScrollPosition,
|
||||
highlightedMessageId,
|
||||
scrollToBottom,
|
||||
scrollToMessage,
|
||||
syncScrollState,
|
||||
} = useTimelineScrollManager({
|
||||
channelId,
|
||||
isLoading,
|
||||
messages: deferredMessages,
|
||||
scrollToItem,
|
||||
} = useChatScrollVirtualizer({
|
||||
count: items.length,
|
||||
scrollRef: scrollContainerRef,
|
||||
getItemKey,
|
||||
gap: TIMELINE_ROW_GAP_PX,
|
||||
latestMessageKey,
|
||||
onTargetReached,
|
||||
scrollContainerRef,
|
||||
targetMessageId,
|
||||
});
|
||||
|
||||
// Deep-link to `targetMessageId` once it resolves against the rendered
|
||||
// snapshot. `resolveDeepLinkTarget` reads the same `deferredMessages` the
|
||||
// rows came from, so the jump never fires against an uncommitted row; the
|
||||
// library's reconcile loop settles the offset once the target measures.
|
||||
const lastJumpedTargetRef = React.useRef<string | null>(null);
|
||||
React.useEffect(() => {
|
||||
if (!targetMessageId) {
|
||||
lastJumpedTargetRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (targetMessageId === lastJumpedTargetRef.current) {
|
||||
return;
|
||||
}
|
||||
const index = indexByMessageId.get(targetMessageId);
|
||||
if (index === undefined) {
|
||||
return;
|
||||
}
|
||||
lastJumpedTargetRef.current = targetMessageId;
|
||||
scrollToItem(index, targetMessageId);
|
||||
}, [targetMessageId, indexByMessageId, scrollToItem]);
|
||||
|
||||
// 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
|
||||
@@ -214,14 +276,19 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
if (showUnreadPill) hasShownPillRef.current = true;
|
||||
const handleJumpToOldestUnread = React.useCallback(() => {
|
||||
setIsUnreadPillDismissed(true);
|
||||
if (firstUnreadMessageId) {
|
||||
scrollToMessage(firstUnreadMessageId);
|
||||
if (!firstUnreadMessageId) {
|
||||
return;
|
||||
}
|
||||
}, [firstUnreadMessageId, scrollToMessage]);
|
||||
const index = indexByMessageId.get(firstUnreadMessageId);
|
||||
if (index !== undefined) {
|
||||
scrollToItem(index, firstUnreadMessageId);
|
||||
}
|
||||
}, [firstUnreadMessageId, indexByMessageId, scrollToItem]);
|
||||
|
||||
// Scroll to the active search match when it changes.
|
||||
// Scroll to the active find-in-channel match when it changes. Resolving the
|
||||
// index off `indexByMessageId` (not `querySelector`) is required under
|
||||
// virtualization — an off-screen match has no DOM node to scroll into view.
|
||||
const prevSearchActiveRef = React.useRef<string | null>(null);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: scrollContainerRef is a stable React ref
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
!searchActiveMessageId ||
|
||||
@@ -231,31 +298,50 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
return;
|
||||
}
|
||||
prevSearchActiveRef.current = searchActiveMessageId;
|
||||
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const el = container.querySelector<HTMLElement>(
|
||||
`[data-message-id="${searchActiveMessageId}"]`,
|
||||
);
|
||||
if (el) {
|
||||
el.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
const index = indexByMessageId.get(searchActiveMessageId);
|
||||
if (index !== undefined) {
|
||||
scrollToItem(index, searchActiveMessageId);
|
||||
}
|
||||
}, [searchActiveMessageId]);
|
||||
}, [searchActiveMessageId, indexByMessageId, scrollToItem]);
|
||||
|
||||
useLoadOlderOnScroll({
|
||||
// Load older messages when the user scrolls near the top. The virtualizer's
|
||||
// `anchorTo:"end"` holds the viewport across the prepend — unlike the legacy
|
||||
// manager, this trigger does NOT touch scrollTop, killing the
|
||||
// measure-and-restore race. The sentinel is the topmost rendered row.
|
||||
React.useEffect(() => {
|
||||
const sentinel = topSentinelRef.current;
|
||||
const container = scrollContainerRef.current;
|
||||
if (
|
||||
!sentinel ||
|
||||
!container ||
|
||||
!fetchOlder ||
|
||||
isLoading ||
|
||||
isFetchingOlder ||
|
||||
!hasOlderMessages
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
void fetchOlder();
|
||||
}
|
||||
},
|
||||
{ root: container, rootMargin: "200px 0px 0px 0px" },
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [
|
||||
fetchOlder,
|
||||
hasOlderMessages,
|
||||
isLoading,
|
||||
restoreScrollPosition,
|
||||
isFetchingOlder,
|
||||
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 &&
|
||||
@@ -298,16 +384,10 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
)}
|
||||
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}
|
||||
className={cn("flex w-full flex-col", channelChrome.contentPadding)}
|
||||
>
|
||||
<div ref={topSentinelRef} aria-hidden className="h-px" />
|
||||
|
||||
@@ -317,191 +397,190 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SkeletonReveal
|
||||
className={cn(
|
||||
"min-h-[18rem]",
|
||||
(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 ? (
|
||||
<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}
|
||||
/>
|
||||
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
|
||||
{directMessageIntro.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}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showChannelIntro ? (
|
||||
<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"
|
||||
>
|
||||
{/* Intro/empty/skeleton are NOT virtual rows — they render as plain
|
||||
siblings of the virtualized spacer, gated so exactly one of them
|
||||
or the list shows. `min-h-full` keeps the empty/intro states
|
||||
filling the viewport (bottom-aligned via `mt-auto`); the message
|
||||
list bottom-aligns through the virtualizer's `topPad` instead. */}
|
||||
{showMessageList ? null : (
|
||||
<SkeletonReveal
|
||||
className={cn("min-h-[18rem] flex flex-col", "min-h-full")}
|
||||
contentClassName="flex min-w-0 flex-col min-h-full"
|
||||
loading={isLoading}
|
||||
skeleton={<TimelineSkeleton rows={timelineSkeletonRows} />}
|
||||
>
|
||||
{showDirectMessageIntro ? (
|
||||
<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="mb-0.5 mt-auto flex w-full flex-col items-start px-3 py-2 text-left"
|
||||
data-testid="message-dm-intro"
|
||||
>
|
||||
{channelIntro.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}
|
||||
</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}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
{channelIntro.description ? (
|
||||
<p className="mt-2 max-w-xl text-sm leading-5 text-muted-foreground">
|
||||
{channelIntro.description}
|
||||
<DirectMessageIntroAvatarStack
|
||||
participants={directMessageIntro.participants}
|
||||
/>
|
||||
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
|
||||
{directMessageIntro.displayName}
|
||||
</p>
|
||||
) : null}
|
||||
{channelIntro.actions?.length ? (
|
||||
<div className="mt-4 flex max-w-full flex-nowrap gap-3 overflow-x-auto pb-1">
|
||||
{channelIntro.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">
|
||||
{directMessageIntro.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-xl p-4",
|
||||
)}
|
||||
data-testid={action.testId}
|
||||
key={action.label}
|
||||
onClick={action.onClick}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
{showChannelIntro ? (
|
||||
<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"
|
||||
>
|
||||
{channelIntro.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}
|
||||
</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}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
{channelIntro.description ? (
|
||||
<p className="mt-2 max-w-xl text-sm leading-5 text-muted-foreground">
|
||||
{channelIntro.description}
|
||||
</p>
|
||||
) : null}
|
||||
{channelIntro.actions?.length ? (
|
||||
<div className="mt-4 flex max-w-full flex-nowrap gap-3 overflow-x-auto pb-1">
|
||||
{channelIntro.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-xl 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-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}
|
||||
{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}
|
||||
</SkeletonReveal>
|
||||
)}
|
||||
|
||||
{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",
|
||||
)}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</SkeletonReveal>
|
||||
|
||||
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
|
||||
{showMessageList ? (
|
||||
<div
|
||||
className={cn(
|
||||
// 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",
|
||||
)}
|
||||
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}
|
||||
items={items}
|
||||
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}
|
||||
topPad={topPad}
|
||||
unfollowThreadById={unfollowThreadById}
|
||||
virtualizer={virtualizer}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { formatDayHeading } from "@/features/messages/lib/dateFormatters";
|
||||
import {
|
||||
buildMainTimelineEntries,
|
||||
shouldRenderUnreadDivider,
|
||||
} from "@/features/messages/lib/threadPanel";
|
||||
import type { TimelineVirtualItem } from "@/features/messages/lib/timelineSnapshot";
|
||||
import {
|
||||
buildVideoReviewCommentsByRootId,
|
||||
buildVideoReviewContextForMessage,
|
||||
} from "@/features/messages/lib/videoReviewContext";
|
||||
import { buildDayGroupBoundaries } from "@/features/messages/lib/timelineSnapshot";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { ChannelType } from "@/shared/api/types";
|
||||
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import type { ChatVirtualizer } from "./useChatScrollVirtualizer";
|
||||
import { DayDivider } from "./DayDivider";
|
||||
import { MessageRow } from "./MessageRow";
|
||||
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
|
||||
@@ -63,6 +59,20 @@ type TimelineMessageListProps = {
|
||||
searchQuery?: string;
|
||||
/** Per-thread unread counts keyed by thread root id. */
|
||||
threadUnreadCounts?: ReadonlyMap<string, number>;
|
||||
/**
|
||||
* The virtualizer that owns this surface's scroll. Built by
|
||||
* `useChatScrollVirtualizer` at the timeline level so the single scroll owner
|
||||
* also owns measurement; this component is a pure renderer of its rows.
|
||||
*/
|
||||
virtualizer: ChatVirtualizer;
|
||||
/** The flat virtual-item list the virtualizer's `count` mirrors. */
|
||||
items: TimelineVirtualItem[];
|
||||
/**
|
||||
* Top padding (px) that bottom-aligns a channel shorter than the viewport.
|
||||
* Always `0` once content fills the viewport. Applied to the row spacer so it
|
||||
* pushes the absolute rows down without entering the measured total.
|
||||
*/
|
||||
topPad: number;
|
||||
};
|
||||
|
||||
export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
@@ -71,7 +81,6 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
channelName,
|
||||
channelType,
|
||||
currentPubkey,
|
||||
firstUnreadMessageId = null,
|
||||
followThreadById,
|
||||
highlightedMessageId = null,
|
||||
isFollowingThreadById,
|
||||
@@ -90,11 +99,10 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
searchQuery,
|
||||
threadUnreadCounts,
|
||||
unfollowThreadById,
|
||||
virtualizer,
|
||||
items,
|
||||
topPad,
|
||||
}: TimelineMessageListProps) {
|
||||
const entries = React.useMemo(
|
||||
() => buildMainTimelineEntries(messages),
|
||||
[messages],
|
||||
);
|
||||
const reviewCommentsByRootId = React.useMemo(
|
||||
() => buildVideoReviewCommentsByRootId(messages),
|
||||
[messages],
|
||||
@@ -137,161 +145,167 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
profiles,
|
||||
reviewCommentsByRootId,
|
||||
]);
|
||||
const dayGroups: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
elements: React.ReactNode[];
|
||||
}> = [];
|
||||
let currentDayGroup: (typeof dayGroups)[number] | null = null;
|
||||
|
||||
// Day-divider decision delegated to a pure, lib-tested helper: a new group
|
||||
// starts at index 0 and whenever a message falls on a different calendar day
|
||||
// than the one before it. We index the boundary start positions so the render
|
||||
// loop below stays a straight walk while the grouping logic lives in `lib/`.
|
||||
const dayGroupStartIndices = new Set(
|
||||
buildDayGroupBoundaries(entries.map((entry) => entry.message)).map(
|
||||
(boundary) => boundary.startIndex,
|
||||
),
|
||||
);
|
||||
const renderItem = (item: TimelineVirtualItem): React.ReactNode => {
|
||||
switch (item.kind) {
|
||||
case "day":
|
||||
// The day separator scrolls inline with the rows (it is a normal
|
||||
// virtual item, not sticky — sticky cannot work on an absolute row).
|
||||
// The thin connector line that the legacy day-`<section>` drew behind
|
||||
// the centered pill is re-homed onto this row so the divider keeps its
|
||||
// rule-behind-label look without the group wrapper.
|
||||
return (
|
||||
<div className="relative before:absolute before:inset-x-0 before:top-[15px] before:h-px before:bg-border/35 before:content-['']">
|
||||
<DayDivider label={formatDayHeading(item.headingTimestamp)} />
|
||||
</div>
|
||||
);
|
||||
case "unread":
|
||||
return <UnreadDivider />;
|
||||
case "system": {
|
||||
const footer = messageFooters?.[item.message.id] ?? null;
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<SystemMessageRow
|
||||
message={item.message}
|
||||
currentPubkey={currentPubkey}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
/>
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "message": {
|
||||
const { message, summary } = item;
|
||||
const footer = messageFooters?.[message.id] ?? null;
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const { message, summary } = entries[i];
|
||||
const messageRenderKey = message.renderKey ?? message.id;
|
||||
if (summary && onReply) {
|
||||
const isHighlighted = message.id === highlightedMessageId;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group/message relative mx-1 flex flex-col gap-0 rounded-2xl px-0 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
|
||||
isHighlighted &&
|
||||
"-mx-4 px-4 before:absolute before:-inset-y-1.5 before:inset-x-0 before:animate-[route-target-highlight-fade_2s_ease-out_forwards] before:bg-primary/10 before:content-[''] motion-reduce:before:animate-none sm:-mx-6 sm:px-6",
|
||||
)}
|
||||
>
|
||||
<MessageRow
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
highlighted={false}
|
||||
hoverBackground={false}
|
||||
isFollowingThread={
|
||||
isFollowingThreadById
|
||||
? isFollowingThreadById(message.id)
|
||||
: undefined
|
||||
}
|
||||
message={message}
|
||||
onDelete={
|
||||
onDelete && currentPubkey && message.pubkey === currentPubkey
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
onEdit && currentPubkey && message.pubkey === currentPubkey
|
||||
? onEdit
|
||||
: undefined
|
||||
}
|
||||
onFollowThread={
|
||||
followThreadById
|
||||
? () => followThreadById(message.id)
|
||||
: undefined
|
||||
}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onReply={onReply}
|
||||
onUnfollowThread={
|
||||
unfollowThreadById
|
||||
? () => unfollowThreadById(message.id)
|
||||
: undefined
|
||||
}
|
||||
profiles={profiles}
|
||||
showDepthGuides={false}
|
||||
videoReviewContext={videoReviewContextById.get(message.id)}
|
||||
/>
|
||||
<MessageThreadSummaryRow
|
||||
depth={message.depth}
|
||||
message={message}
|
||||
onOpenThread={onReply}
|
||||
showDepthGuides={false}
|
||||
summary={summary}
|
||||
unreadCount={threadUnreadCounts?.get(message.id)}
|
||||
/>
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (dayGroupStartIndices.has(i)) {
|
||||
currentDayGroup = {
|
||||
key: `day-${message.createdAt}`,
|
||||
label: formatDayHeading(message.createdAt),
|
||||
elements: [],
|
||||
};
|
||||
dayGroups.push(currentDayGroup);
|
||||
const isSearchMatch =
|
||||
searchMatchingMessageIds?.has(message.id) ?? false;
|
||||
const isSearchActive = message.id === searchActiveMessageId;
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<MessageRow
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
highlighted={
|
||||
message.id === highlightedMessageId || isSearchActive
|
||||
}
|
||||
message={message}
|
||||
onDelete={
|
||||
onDelete && currentPubkey && message.pubkey === currentPubkey
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
onEdit && currentPubkey && message.pubkey === currentPubkey
|
||||
? onEdit
|
||||
: undefined
|
||||
}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onReply={onReply}
|
||||
profiles={profiles}
|
||||
searchQuery={isSearchMatch ? searchQuery : undefined}
|
||||
showDepthGuides={false}
|
||||
videoReviewContext={videoReviewContextById.get(message.id)}
|
||||
/>
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// The unread "New" divider only marks a read/unread boundary when there is
|
||||
// a message above the first unread. When the first unread is the first
|
||||
// rendered top-level entry (fresh/never-read channel), there is nothing
|
||||
// above to separate from, so it is suppressed.
|
||||
if (shouldRenderUnreadDivider(i, message.id, firstUnreadMessageId)) {
|
||||
currentDayGroup?.elements.push(
|
||||
<UnreadDivider key={`unread-${messageRenderKey}`} />,
|
||||
);
|
||||
}
|
||||
|
||||
if (message.kind === KIND_SYSTEM_MESSAGE) {
|
||||
const footer = messageFooters?.[message.id] ?? null;
|
||||
currentDayGroup?.elements.push(
|
||||
<div key={messageRenderKey} className="flex flex-col gap-1">
|
||||
<SystemMessageRow
|
||||
message={message}
|
||||
currentPubkey={currentPubkey}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
/>
|
||||
{footer}
|
||||
</div>,
|
||||
);
|
||||
} else if (summary && onReply) {
|
||||
const footer = messageFooters?.[message.id] ?? null;
|
||||
const isHighlighted = message.id === highlightedMessageId;
|
||||
currentDayGroup?.elements.push(
|
||||
<div
|
||||
key={messageRenderKey}
|
||||
className={cn(
|
||||
"group/message relative mx-1 flex flex-col gap-0 rounded-2xl px-0 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
|
||||
isHighlighted &&
|
||||
"-mx-4 px-4 before:absolute before:-inset-y-1.5 before:inset-x-0 before:animate-[route-target-highlight-fade_2s_ease-out_forwards] before:bg-primary/10 before:content-[''] motion-reduce:before:animate-none sm:-mx-6 sm:px-6",
|
||||
)}
|
||||
>
|
||||
<MessageRow
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
highlighted={false}
|
||||
hoverBackground={false}
|
||||
isFollowingThread={
|
||||
isFollowingThreadById
|
||||
? isFollowingThreadById(message.id)
|
||||
: undefined
|
||||
}
|
||||
message={message}
|
||||
onDelete={
|
||||
onDelete && currentPubkey && message.pubkey === currentPubkey
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
onEdit && currentPubkey && message.pubkey === currentPubkey
|
||||
? onEdit
|
||||
: undefined
|
||||
}
|
||||
onFollowThread={
|
||||
followThreadById ? () => followThreadById(message.id) : undefined
|
||||
}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onReply={onReply}
|
||||
onUnfollowThread={
|
||||
unfollowThreadById
|
||||
? () => unfollowThreadById(message.id)
|
||||
: undefined
|
||||
}
|
||||
profiles={profiles}
|
||||
showDepthGuides={false}
|
||||
videoReviewContext={videoReviewContextById.get(message.id)}
|
||||
/>
|
||||
<MessageThreadSummaryRow
|
||||
depth={message.depth}
|
||||
message={message}
|
||||
onOpenThread={onReply}
|
||||
showDepthGuides={false}
|
||||
summary={summary}
|
||||
unreadCount={threadUnreadCounts?.get(message.id)}
|
||||
/>
|
||||
{footer}
|
||||
</div>,
|
||||
);
|
||||
} else {
|
||||
const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false;
|
||||
const isSearchActive = message.id === searchActiveMessageId;
|
||||
const footer = messageFooters?.[message.id] ?? null;
|
||||
|
||||
currentDayGroup?.elements.push(
|
||||
<div key={messageRenderKey} className="flex flex-col gap-1">
|
||||
<MessageRow
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
highlighted={message.id === highlightedMessageId || isSearchActive}
|
||||
message={message}
|
||||
onDelete={
|
||||
onDelete && currentPubkey && message.pubkey === currentPubkey
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
onEdit && currentPubkey && message.pubkey === currentPubkey
|
||||
? onEdit
|
||||
: undefined
|
||||
}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onReply={onReply}
|
||||
profiles={profiles}
|
||||
searchQuery={isSearchMatch ? searchQuery : undefined}
|
||||
showDepthGuides={false}
|
||||
videoReviewContext={videoReviewContextById.get(message.id)}
|
||||
/>
|
||||
{footer}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return dayGroups.map((group) => (
|
||||
<section
|
||||
className="relative flex flex-col gap-2.5 before:absolute before:inset-x-0 before:top-[15px] before:h-px before:bg-border/35 before:content-['']"
|
||||
key={group.key}
|
||||
// The spacer is `box-sizing: border-box` (Tailwind global), so `topPad` is
|
||||
// folded into `height` AND set as `paddingTop`: the height keeps the full
|
||||
// scroll range while the padding pushes the absolute rows (positioned against
|
||||
// the padding box, `top:0` = inner edge) down to bottom-align a short channel.
|
||||
// `translateY` then uses the virtualizer's raw `start` — no double offset.
|
||||
return (
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize() + topPad}px`,
|
||||
paddingTop: topPad > 0 ? `${topPad}px` : undefined,
|
||||
}}
|
||||
>
|
||||
<DayDivider label={group.label} />
|
||||
{group.elements}
|
||||
</section>
|
||||
));
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => (
|
||||
<div
|
||||
data-index={virtualRow.index}
|
||||
key={virtualRow.key}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{renderItem(items[virtualRow.index])}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { type Virtualizer, useVirtualizer } from "@tanstack/react-virtual";
|
||||
import * as React from "react";
|
||||
|
||||
import { BOTTOM_THRESHOLD_PX } from "@/features/messages/lib/timelineSnapshot";
|
||||
|
||||
export type ChatVirtualizer = Virtualizer<HTMLElement, Element>;
|
||||
|
||||
/** How long (ms) a deep-linked row stays highlighted before the glow fades. */
|
||||
const HIGHLIGHT_DURATION_MS = 2_000;
|
||||
|
||||
/**
|
||||
* Single scroll owner for the chat surfaces (main timeline + thread pane).
|
||||
*
|
||||
@@ -30,6 +35,12 @@ export type ChatVirtualizer = Virtualizer<HTMLElement, Element>;
|
||||
* the LIVE `getTotalSize()` on every measurement pass (via `onChange`) so
|
||||
* it collapses to 0 the instant content exceeds the viewport — see the
|
||||
* ordering note below.
|
||||
* - **The "at bottom" / new-message-count UI state** the scroll-to-latest
|
||||
* pill reads. The library knows the geometry (`isAtEnd`); this hook lifts
|
||||
* it to React state and counts messages that arrive while scrolled up.
|
||||
* - **Deep-link highlight + completion callback.** `scrollToItem` drives the
|
||||
* library jump, lights the target row for a beat, and fires
|
||||
* `onTargetReached` once.
|
||||
*/
|
||||
|
||||
export type ChatScrollVirtualizerOptions = {
|
||||
@@ -41,13 +52,18 @@ export type ChatScrollVirtualizerOptions = {
|
||||
getItemKey: (index: number) => string;
|
||||
/** Estimated row height (px) before measurement. */
|
||||
estimateSize?: number;
|
||||
/** Vertical gap (px) the virtualizer inserts between rows. */
|
||||
gap?: number;
|
||||
/** Rows rendered outside the viewport on each side. */
|
||||
overscan?: number;
|
||||
/**
|
||||
* Bottom-stick mode. The main timeline and thread pane both want to follow
|
||||
* new messages, so both pass `"auto"`; pass `false` to disable following.
|
||||
* Key of the latest message in the rendered snapshot. When it changes while
|
||||
* the user is scrolled up, `newMessageCount` ticks up — drives the pill's
|
||||
* "N new messages" label. Pass `undefined` for an empty surface.
|
||||
*/
|
||||
followOnAppend?: boolean | "auto" | "smooth" | "instant";
|
||||
latestMessageKey?: string;
|
||||
/** Called once when a deep-link jump lands on its target. */
|
||||
onTargetReached?: (messageId: string) => void;
|
||||
};
|
||||
|
||||
export type ChatScrollVirtualizer = {
|
||||
@@ -58,6 +74,20 @@ export type ChatScrollVirtualizer = {
|
||||
* once content fills the viewport.
|
||||
*/
|
||||
topPad: number;
|
||||
/** True while the surface is scrolled to (or near) the bottom. */
|
||||
isAtBottom: boolean;
|
||||
/** Messages that arrived while scrolled up; reset on reaching the bottom. */
|
||||
newMessageCount: number;
|
||||
/** Message id currently highlighted by a deep-link jump, or null. */
|
||||
highlightedMessageId: string | null;
|
||||
/** Jump to the end (the pill action). */
|
||||
scrollToBottom: (behavior: ScrollBehavior) => void;
|
||||
/**
|
||||
* Jump to a flat-item index, highlight `messageId`, and fire
|
||||
* `onTargetReached` once the library settles. The caller resolves the index
|
||||
* against the SAME snapshot the rows render from (the no-tearing guard).
|
||||
*/
|
||||
scrollToItem: (index: number, messageId: string) => void;
|
||||
};
|
||||
|
||||
export function useChatScrollVirtualizer({
|
||||
@@ -65,8 +95,10 @@ export function useChatScrollVirtualizer({
|
||||
scrollRef,
|
||||
getItemKey,
|
||||
estimateSize = 80,
|
||||
gap,
|
||||
overscan = 6,
|
||||
followOnAppend = "auto",
|
||||
latestMessageKey,
|
||||
onTargetReached,
|
||||
}: ChatScrollVirtualizerOptions): ChatScrollVirtualizer {
|
||||
// Read the element lazily so the virtualizer binds once the ref attaches;
|
||||
// capturing `ref.current` at render time would freeze it at the first-render
|
||||
@@ -77,17 +109,22 @@ export function useChatScrollVirtualizer({
|
||||
);
|
||||
|
||||
const [topPad, setTopPad] = React.useState(0);
|
||||
const [isAtBottom, setIsAtBottom] = React.useState(true);
|
||||
const [newMessageCount, setNewMessageCount] = React.useState(0);
|
||||
const [highlightedMessageId, setHighlightedMessageId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
// Recompute the short-channel pad off the LIVE total whenever a row's
|
||||
// measured size changes. virtual-core fires `onChange` directly from
|
||||
// The bottom-state and pad recompute both read live geometry off the
|
||||
// virtualizer on each `onChange`. virtual-core fires `onChange` directly from
|
||||
// `resizeItem` on any size delta (not only on a visible-range change), which
|
||||
// is exactly when `getTotalSize()` moves — so a short channel whose rows
|
||||
// measure taller/shorter than the estimate re-pads with the settled total.
|
||||
// Running it here, before paint and before the library re-applies its
|
||||
// end-anchoring/follow in the same pass, orders "recompute pad -> re-pin
|
||||
// bottom" and avoids a one-frame sliver when a short channel grows past the
|
||||
// viewport.
|
||||
const recomputePad = React.useCallback(
|
||||
// measure taller/shorter than the estimate re-pads (and re-evaluates "at
|
||||
// bottom") with the settled total. Running here, before paint and before the
|
||||
// library re-applies its end-anchoring/follow in the same pass, orders
|
||||
// "recompute pad -> re-pin bottom" and avoids a one-frame sliver when a short
|
||||
// channel grows past the viewport.
|
||||
const onChange = React.useCallback(
|
||||
(instance: ChatVirtualizer) => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!scrollEl) {
|
||||
@@ -95,6 +132,14 @@ export function useChatScrollVirtualizer({
|
||||
}
|
||||
const pad = Math.max(0, scrollEl.clientHeight - instance.getTotalSize());
|
||||
setTopPad((prev) => (prev === pad ? prev : pad));
|
||||
|
||||
// The app's "at bottom" rule is looser than the library's 1px default;
|
||||
// reuse the timeline's 72px threshold so the pill matches the old feel.
|
||||
const atBottom = instance.isAtEnd(BOTTOM_THRESHOLD_PX);
|
||||
setIsAtBottom((prev) => (prev === atBottom ? prev : atBottom));
|
||||
if (atBottom) {
|
||||
setNewMessageCount((prev) => (prev === 0 ? prev : 0));
|
||||
}
|
||||
},
|
||||
[scrollRef],
|
||||
);
|
||||
@@ -105,26 +150,89 @@ export function useChatScrollVirtualizer({
|
||||
estimateSize: () => estimateSize,
|
||||
getItemKey,
|
||||
overscan,
|
||||
gap,
|
||||
// Hold the viewport on prepend and pin to the bottom anchor for follow —
|
||||
// the two library-native behaviors that replace the manual scroll manager.
|
||||
// Both surfaces follow new messages, so `followOnAppend` is always on; the
|
||||
// library only re-pins when the user is already at the end.
|
||||
anchorTo: "end",
|
||||
followOnAppend,
|
||||
onChange: recomputePad,
|
||||
followOnAppend: "auto",
|
||||
onChange,
|
||||
});
|
||||
|
||||
// The pad also depends on the viewport height, which `onChange` does not
|
||||
// track — a window/pane resize that changes `clientHeight` without resizing a
|
||||
// row would leave a short channel's pad stale. Observe the container so the
|
||||
// bottom-align holds across resizes too.
|
||||
// The pad/bottom-state also depend on the viewport height, which `onChange`
|
||||
// does not track — a window/pane resize that changes `clientHeight` without
|
||||
// resizing a row would leave them stale. Observe the container so the
|
||||
// bottom-align and pill state hold across resizes too.
|
||||
React.useEffect(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!scrollEl) {
|
||||
return;
|
||||
}
|
||||
const observer = new ResizeObserver(() => recomputePad(virtualizer));
|
||||
const observer = new ResizeObserver(() => onChange(virtualizer));
|
||||
observer.observe(scrollEl);
|
||||
return () => observer.disconnect();
|
||||
}, [scrollRef, recomputePad, virtualizer]);
|
||||
}, [scrollRef, onChange, virtualizer]);
|
||||
|
||||
return { virtualizer, topPad };
|
||||
// Count messages that arrive while the user is scrolled up. When at the
|
||||
// bottom `followOnAppend` keeps us pinned and `onChange` zeroes the count, so
|
||||
// the increment only fires for genuinely-missed messages. Skips the first
|
||||
// observed key so opening a channel doesn't show a phantom count.
|
||||
const previousLatestKeyRef = React.useRef<string | undefined>(undefined);
|
||||
React.useEffect(() => {
|
||||
const previous = previousLatestKeyRef.current;
|
||||
previousLatestKeyRef.current = latestMessageKey;
|
||||
if (
|
||||
previous === undefined ||
|
||||
latestMessageKey === undefined ||
|
||||
latestMessageKey === previous ||
|
||||
isAtBottom
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setNewMessageCount((prev) => prev + 1);
|
||||
}, [latestMessageKey, isAtBottom]);
|
||||
|
||||
const scrollToBottom = React.useCallback(
|
||||
(behavior: ScrollBehavior) => {
|
||||
virtualizer.scrollToIndex(count - 1, { align: "end", behavior });
|
||||
},
|
||||
[virtualizer, count],
|
||||
);
|
||||
|
||||
const highlightTimeoutRef = React.useRef<number | undefined>(undefined);
|
||||
const scrollToItem = React.useCallback(
|
||||
(index: number, messageId: string) => {
|
||||
// align:center + the library's reconcile loop re-targets the index until
|
||||
// the offset is stable once the (possibly never-measured) row measures —
|
||||
// the W1 deep-link settle, library-owned.
|
||||
virtualizer.scrollToIndex(index, { align: "center" });
|
||||
setHighlightedMessageId(messageId);
|
||||
setNewMessageCount(0);
|
||||
onTargetReached?.(messageId);
|
||||
|
||||
window.clearTimeout(highlightTimeoutRef.current);
|
||||
highlightTimeoutRef.current = window.setTimeout(() => {
|
||||
setHighlightedMessageId((current) =>
|
||||
current === messageId ? null : current,
|
||||
);
|
||||
}, HIGHLIGHT_DURATION_MS);
|
||||
},
|
||||
[virtualizer, onTargetReached],
|
||||
);
|
||||
|
||||
React.useEffect(
|
||||
() => () => window.clearTimeout(highlightTimeoutRef.current),
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
virtualizer,
|
||||
topPad,
|
||||
isAtBottom,
|
||||
newMessageCount,
|
||||
highlightedMessageId,
|
||||
scrollToBottom,
|
||||
scrollToItem,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
type UseLoadOlderOnScrollOptions = {
|
||||
fetchOlder?: () => Promise<void>;
|
||||
hasOlderMessages: boolean;
|
||||
isLoading: boolean;
|
||||
restoreScrollPosition: (scrollTop: number) => void;
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
sentinelRef: React.RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Triggers `fetchOlder` when a sentinel element near the top of the scroll
|
||||
* container enters the viewport, then restores the scroll position so the
|
||||
* visible content doesn't jump.
|
||||
*/
|
||||
export function useLoadOlderOnScroll({
|
||||
fetchOlder,
|
||||
hasOlderMessages,
|
||||
isLoading,
|
||||
restoreScrollPosition,
|
||||
scrollContainerRef,
|
||||
sentinelRef,
|
||||
}: UseLoadOlderOnScrollOptions) {
|
||||
const restoreScrollPositionRef = React.useRef(restoreScrollPosition);
|
||||
React.useEffect(() => {
|
||||
restoreScrollPositionRef.current = restoreScrollPosition;
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
const container = scrollContainerRef.current;
|
||||
if (
|
||||
!sentinel ||
|
||||
!container ||
|
||||
!fetchOlder ||
|
||||
isLoading ||
|
||||
!hasOlderMessages
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let currentObserver: IntersectionObserver | null = null;
|
||||
|
||||
const observe = () => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (!entry.isIntersecting || disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentObserver?.disconnect();
|
||||
|
||||
const previousHeight = container.scrollHeight;
|
||||
const previousScrollTop = container.scrollTop;
|
||||
void fetchOlder().then(() => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const newHeight = container.scrollHeight;
|
||||
const delta = newHeight - previousHeight;
|
||||
if (delta > 0) {
|
||||
restoreScrollPositionRef.current(previousScrollTop + delta);
|
||||
}
|
||||
observe();
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
{ root: container, rootMargin: "200px 0px 0px 0px" },
|
||||
);
|
||||
|
||||
currentObserver.observe(sentinel);
|
||||
};
|
||||
|
||||
observe();
|
||||
return () => {
|
||||
disposed = true;
|
||||
currentObserver?.disconnect();
|
||||
};
|
||||
}, [
|
||||
fetchOlder,
|
||||
hasOlderMessages,
|
||||
isLoading,
|
||||
scrollContainerRef,
|
||||
sentinelRef,
|
||||
]);
|
||||
}
|
||||
@@ -1,436 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
isNearBottom,
|
||||
resolveDeepLinkTarget,
|
||||
selectLatestMessageKey,
|
||||
} from "@/features/messages/lib/timelineSnapshot";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
|
||||
type UseTimelineScrollManagerOptions = {
|
||||
channelId?: string | null;
|
||||
isLoading: boolean;
|
||||
messages: TimelineMessage[];
|
||||
onTargetReached?: (messageId: string) => void;
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
targetMessageId?: string | null;
|
||||
};
|
||||
|
||||
type PinToBottomOptions = {
|
||||
clearNewMessageCount?: boolean;
|
||||
};
|
||||
|
||||
export function useTimelineScrollManager({
|
||||
channelId,
|
||||
isLoading,
|
||||
messages,
|
||||
onTargetReached,
|
||||
scrollContainerRef,
|
||||
targetMessageId,
|
||||
}: UseTimelineScrollManagerOptions) {
|
||||
const timelineRef = scrollContainerRef;
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const bottomAnchorRef = React.useRef<HTMLDivElement>(null);
|
||||
const hasInitializedRef = React.useRef(false);
|
||||
const shouldStickToBottomRef = React.useRef(true);
|
||||
const isAtBottomRef = React.useRef(true);
|
||||
const isProgrammaticBottomScrollRef = React.useRef(false);
|
||||
const previousTimelineHeightRef = React.useRef<number | null>(null);
|
||||
const previousScrollTopRef = React.useRef(0);
|
||||
const lockedScrollTopRef = React.useRef<number | null>(null);
|
||||
const previousLastMessageKeyRef = React.useRef<string | undefined>(undefined);
|
||||
const previousMessageCountRef = React.useRef(0);
|
||||
const handledTargetMessageIdRef = React.useRef<string | null>(null);
|
||||
const [isAtBottom, setIsAtBottom] = React.useState(true);
|
||||
const [highlightedMessageId, setHighlightedMessageId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [newMessageCount, setNewMessageCount] = React.useState(0);
|
||||
|
||||
const resetScrollTracking = React.useCallback(() => {
|
||||
hasInitializedRef.current = false;
|
||||
shouldStickToBottomRef.current = true;
|
||||
isAtBottomRef.current = true;
|
||||
isProgrammaticBottomScrollRef.current = false;
|
||||
previousTimelineHeightRef.current = null;
|
||||
previousScrollTopRef.current = 0;
|
||||
lockedScrollTopRef.current = null;
|
||||
previousLastMessageKeyRef.current = undefined;
|
||||
previousMessageCountRef.current = 0;
|
||||
handledTargetMessageIdRef.current = null;
|
||||
setIsAtBottom(true);
|
||||
setHighlightedMessageId(null);
|
||||
setNewMessageCount(0);
|
||||
}, []);
|
||||
|
||||
const pinToBottom = React.useCallback(
|
||||
({ clearNewMessageCount = false }: PinToBottomOptions = {}) => {
|
||||
shouldStickToBottomRef.current = true;
|
||||
isAtBottomRef.current = true;
|
||||
setIsAtBottom((current) => (current ? current : true));
|
||||
|
||||
if (clearNewMessageCount) {
|
||||
setNewMessageCount(0);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const setObservedBottomState = React.useCallback((atBottom: boolean) => {
|
||||
shouldStickToBottomRef.current = atBottom;
|
||||
isAtBottomRef.current = atBottom;
|
||||
setIsAtBottom((current) => (current === atBottom ? current : atBottom));
|
||||
|
||||
if (atBottom) {
|
||||
setNewMessageCount(0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const unpinFromBottom = React.useCallback((scrollTop: number) => {
|
||||
shouldStickToBottomRef.current = false;
|
||||
isAtBottomRef.current = false;
|
||||
isProgrammaticBottomScrollRef.current = false;
|
||||
previousScrollTopRef.current = scrollTop;
|
||||
setIsAtBottom(false);
|
||||
}, []);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is intentionally the sole trigger — we reset all scroll state when the channel changes
|
||||
React.useLayoutEffect(() => {
|
||||
resetScrollTracking();
|
||||
}, [channelId, resetScrollTracking]);
|
||||
|
||||
const latestMessage =
|
||||
messages.length > 0 ? messages[messages.length - 1] : undefined;
|
||||
const latestMessageKey = selectLatestMessageKey(messages);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref passed from the parent — its identity never changes
|
||||
const syncScrollState = React.useCallback(() => {
|
||||
const timeline = timelineRef.current;
|
||||
if (!timeline) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollTop = lockedScrollTopRef.current ?? timeline.scrollTop;
|
||||
const atBottom = isNearBottom(timeline);
|
||||
const movedAwayFromBottom = scrollTop + 1 < previousScrollTopRef.current;
|
||||
|
||||
if (isProgrammaticBottomScrollRef.current) {
|
||||
previousScrollTopRef.current = scrollTop;
|
||||
|
||||
if (movedAwayFromBottom) {
|
||||
isProgrammaticBottomScrollRef.current = false;
|
||||
} else if (!atBottom) {
|
||||
pinToBottom();
|
||||
return;
|
||||
} else {
|
||||
isProgrammaticBottomScrollRef.current = false;
|
||||
pinToBottom({ clearNewMessageCount: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldStickToBottomRef.current && !atBottom && !movedAwayFromBottom) {
|
||||
previousScrollTopRef.current = scrollTop;
|
||||
pinToBottom({ clearNewMessageCount: true });
|
||||
return;
|
||||
}
|
||||
|
||||
previousScrollTopRef.current = scrollTop;
|
||||
setObservedBottomState(atBottom);
|
||||
}, [pinToBottom, setObservedBottomState]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes
|
||||
const restoreScrollPosition = React.useCallback(
|
||||
(scrollTop: number) => {
|
||||
const timeline = timelineRef.current;
|
||||
|
||||
if (!timeline) {
|
||||
return;
|
||||
}
|
||||
|
||||
isProgrammaticBottomScrollRef.current = false;
|
||||
lockedScrollTopRef.current = scrollTop;
|
||||
|
||||
const restore = (remainingFrames: number) => {
|
||||
timeline.scrollTop = scrollTop;
|
||||
|
||||
if (remainingFrames > 0) {
|
||||
requestAnimationFrame(() => {
|
||||
restore(remainingFrames - 1);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
lockedScrollTopRef.current = null;
|
||||
previousScrollTopRef.current = timeline.scrollTop;
|
||||
syncScrollState();
|
||||
};
|
||||
|
||||
restore(2);
|
||||
},
|
||||
[syncScrollState],
|
||||
);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes
|
||||
const scrollToBottom = React.useCallback(
|
||||
(behavior: ScrollBehavior) => {
|
||||
const timeline = timelineRef.current;
|
||||
|
||||
if (!timeline) {
|
||||
return;
|
||||
}
|
||||
|
||||
isProgrammaticBottomScrollRef.current = true;
|
||||
|
||||
const alignToBottom = (nextBehavior: ScrollBehavior) => {
|
||||
bottomAnchorRef.current?.scrollIntoView({
|
||||
block: "end",
|
||||
behavior: nextBehavior,
|
||||
});
|
||||
timeline.scrollTo({
|
||||
top: timeline.scrollHeight,
|
||||
behavior: nextBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
alignToBottom(behavior);
|
||||
lockedScrollTopRef.current = null;
|
||||
previousScrollTopRef.current = timeline.scrollTop;
|
||||
pinToBottom({ clearNewMessageCount: true });
|
||||
|
||||
if (behavior === "smooth") {
|
||||
requestAnimationFrame(() => {
|
||||
previousScrollTopRef.current = timeline.scrollTop;
|
||||
syncScrollState();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const settleAlignment = (remainingFrames: number) => {
|
||||
requestAnimationFrame(() => {
|
||||
alignToBottom("auto");
|
||||
previousScrollTopRef.current = timeline.scrollTop;
|
||||
|
||||
if (remainingFrames > 0) {
|
||||
settleAlignment(remainingFrames - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
syncScrollState();
|
||||
});
|
||||
};
|
||||
|
||||
settleAlignment(2);
|
||||
},
|
||||
[pinToBottom, syncScrollState],
|
||||
);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes
|
||||
React.useEffect(() => {
|
||||
const timeline = timelineRef.current;
|
||||
|
||||
if (!timeline || typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
previousTimelineHeightRef.current = timeline.clientHeight;
|
||||
previousScrollTopRef.current = timeline.scrollTop;
|
||||
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
const previousTimelineHeight = previousTimelineHeightRef.current;
|
||||
const nextTimelineHeight = entry.contentRect.height;
|
||||
previousTimelineHeightRef.current = nextTimelineHeight;
|
||||
|
||||
if (
|
||||
previousTimelineHeight === null ||
|
||||
Math.abs(nextTimelineHeight - previousTimelineHeight) < 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldStickToBottomRef.current || isAtBottomRef.current) {
|
||||
scrollToBottom("auto");
|
||||
return;
|
||||
}
|
||||
|
||||
restoreScrollPosition(previousScrollTopRef.current);
|
||||
});
|
||||
|
||||
observer.observe(timeline);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [restoreScrollPosition, scrollToBottom]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const content = contentRef.current;
|
||||
|
||||
if (!content || typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (shouldStickToBottomRef.current) {
|
||||
scrollToBottom("auto");
|
||||
return;
|
||||
}
|
||||
|
||||
syncScrollState();
|
||||
});
|
||||
|
||||
observer.observe(content);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [scrollToBottom, syncScrollState]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!hasInitializedRef.current) {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetMessageId) {
|
||||
const timeline = timelineRef.current;
|
||||
unpinFromBottom(timeline?.scrollTop ?? 0);
|
||||
} else {
|
||||
scrollToBottom("auto");
|
||||
}
|
||||
hasInitializedRef.current = true;
|
||||
previousLastMessageKeyRef.current = latestMessageKey;
|
||||
previousMessageCountRef.current = messages.length;
|
||||
return;
|
||||
}
|
||||
|
||||
const previousLastMessageKey = previousLastMessageKeyRef.current;
|
||||
const previousMessageCount = previousMessageCountRef.current;
|
||||
const hasNewLatestMessage =
|
||||
latestMessage !== undefined &&
|
||||
latestMessageKey !== previousLastMessageKey;
|
||||
|
||||
if (!hasNewLatestMessage) {
|
||||
previousLastMessageKeyRef.current = latestMessageKey;
|
||||
previousMessageCountRef.current = messages.length;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!targetMessageId &&
|
||||
(shouldStickToBottomRef.current ||
|
||||
isAtBottomRef.current ||
|
||||
latestMessage.accent)
|
||||
) {
|
||||
scrollToBottom(latestMessage.accent ? "smooth" : "auto");
|
||||
} else {
|
||||
setNewMessageCount((current) => {
|
||||
const addedMessages = Math.max(
|
||||
1,
|
||||
messages.length - previousMessageCount,
|
||||
);
|
||||
return current + addedMessages;
|
||||
});
|
||||
}
|
||||
|
||||
previousLastMessageKeyRef.current = latestMessageKey;
|
||||
previousMessageCountRef.current = messages.length;
|
||||
}, [
|
||||
isLoading,
|
||||
latestMessage,
|
||||
latestMessageKey,
|
||||
messages.length,
|
||||
scrollToBottom,
|
||||
targetMessageId,
|
||||
timelineRef,
|
||||
unpinFromBottom,
|
||||
]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes
|
||||
const scrollToMessage = React.useCallback(
|
||||
(messageId: string) => {
|
||||
const timeline = timelineRef.current;
|
||||
if (!timeline) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetElement = timeline.querySelector<HTMLElement>(
|
||||
`[data-message-id="${messageId}"]`,
|
||||
);
|
||||
if (!targetElement) {
|
||||
return false;
|
||||
}
|
||||
|
||||
unpinFromBottom(timeline.scrollTop);
|
||||
setHighlightedMessageId(messageId);
|
||||
setNewMessageCount(0);
|
||||
|
||||
const alignToTarget = (remainingFrames: number) => {
|
||||
targetElement.scrollIntoView({
|
||||
block: "center",
|
||||
behavior: "auto",
|
||||
});
|
||||
previousScrollTopRef.current = timeline.scrollTop;
|
||||
|
||||
if (remainingFrames > 0) {
|
||||
requestAnimationFrame(() => {
|
||||
alignToTarget(remainingFrames - 1);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onTargetReached?.(messageId);
|
||||
};
|
||||
|
||||
alignToTarget(2);
|
||||
|
||||
window.setTimeout(() => {
|
||||
setHighlightedMessageId((current) =>
|
||||
current === messageId ? null : current,
|
||||
);
|
||||
}, 2_000);
|
||||
|
||||
return true;
|
||||
},
|
||||
[onTargetReached, unpinFromBottom],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!targetMessageId) {
|
||||
handledTargetMessageIdRef.current = null;
|
||||
setHighlightedMessageId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (handledTargetMessageIdRef.current === targetMessageId || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Deep-link decision delegated to a pure, lib-tested helper: only attempt the
|
||||
// jump once the target actually exists in THIS (deferred) snapshot. If it
|
||||
// doesn't, the row hasn't committed yet — bail and let the next snapshot that
|
||||
// includes it drive the jump. This reads the same `messages` snapshot the
|
||||
// list rendered, which closes the tearing race.
|
||||
if (!resolveDeepLinkTarget(messages, targetMessageId).resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!scrollToMessage(targetMessageId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
handledTargetMessageIdRef.current = targetMessageId;
|
||||
}, [isLoading, messages, scrollToMessage, targetMessageId]);
|
||||
|
||||
return {
|
||||
bottomAnchorRef,
|
||||
contentRef,
|
||||
highlightedMessageId,
|
||||
isAtBottom,
|
||||
newMessageCount,
|
||||
restoreScrollPosition,
|
||||
scrollToBottom,
|
||||
scrollToMessage,
|
||||
syncScrollState,
|
||||
};
|
||||
}
|
||||
@@ -2093,8 +2093,8 @@ function MarkdownInner({
|
||||
const onOpenMessageLink = React.useCallback(
|
||||
(link: ParsedMessageLink) => {
|
||||
// Always route through `goChannel` with `messageId` set: the channel
|
||||
// route already handles scroll-into-view + highlight via
|
||||
// `useTimelineScrollManager` + `getEventById` backfill, and works for
|
||||
// route already handles scroll-into-view + highlight via the timeline's
|
||||
// `useChatScrollVirtualizer` + `getEventById` backfill, and works for
|
||||
// both stream-message replies and forum threads. Detecting "the thread
|
||||
// root is a forum post" up front would require an event lookup we don't
|
||||
// currently have synchronously; the brief explicitly allows skipping
|
||||
|
||||
Reference in New Issue
Block a user