mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Spike Virtuoso timeline one-writer integration
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
parent
e0f76b0e9c
commit
be9cf8ee17
@@ -70,6 +70,7 @@
|
||||
"react-diff-view": "^3.3.2",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-virtuoso": "^4.18.10",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^4.0.2",
|
||||
|
||||
@@ -21,6 +21,7 @@ import { UnreadPill, unreadCountLabel } from "@/shared/ui/UnreadPill";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton";
|
||||
import { TimelineMessageList } from "./TimelineMessageList";
|
||||
import type { TimelineVirtualizerApi } from "./TimelineMessageList";
|
||||
import { useAnchoredScroll } from "./useAnchoredScroll";
|
||||
import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll";
|
||||
|
||||
@@ -191,6 +192,10 @@ const MessageTimelineBase = React.forwardRef<
|
||||
const scrollContainerRef = externalScrollRef ?? internalScrollRef;
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const topSentinelRef = React.useRef<HTMLDivElement>(null);
|
||||
const [virtualizerScrollParent, setVirtualizerScrollParent] =
|
||||
React.useState<HTMLDivElement | null>(null);
|
||||
const [timelineVirtualizerApi, setTimelineVirtualizerApi] =
|
||||
React.useState<TimelineVirtualizerApi | null>(null);
|
||||
|
||||
// Gate the heavy timeline render (each row runs a synchronous
|
||||
// react-markdown parse) behind React concurrency. `useDeferredValue` lets the
|
||||
@@ -230,6 +235,13 @@ const MessageTimelineBase = React.forwardRef<
|
||||
// painted at a stale offset until the user's next scroll event forces layout.
|
||||
const scrollContainerDomKey = channelId ?? "none";
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
// Re-read after `scrollContainerDomKey` swaps the keyed scroll DOM node.
|
||||
void scrollContainerDomKey;
|
||||
setVirtualizerScrollParent(scrollContainerRef.current);
|
||||
setTimelineVirtualizerApi(null);
|
||||
}, [scrollContainerRef, scrollContainerDomKey]);
|
||||
|
||||
const timelineBodySurface = selectTimelineBodySurface({
|
||||
deferredCount: deferredMessages.length,
|
||||
isLoading: isLoading || isDeferredSnapshotStale,
|
||||
@@ -245,6 +257,7 @@ const MessageTimelineBase = React.forwardRef<
|
||||
scrollToBottom,
|
||||
scrollToBottomOnNextUpdate,
|
||||
scrollToMessage,
|
||||
onVirtualizerAtBottomStateChange,
|
||||
} = useAnchoredScroll({
|
||||
channelId,
|
||||
contentRef,
|
||||
@@ -253,6 +266,9 @@ const MessageTimelineBase = React.forwardRef<
|
||||
onTargetReached,
|
||||
scrollContainerRef,
|
||||
targetMessageId,
|
||||
virtualScrollToMessage: timelineVirtualizerApi?.scrollToMessage,
|
||||
virtualScrollToBottom: timelineVirtualizerApi?.scrollToBottom,
|
||||
virtualizerOwnsPrependAnchoring: virtualizerScrollParent !== null,
|
||||
});
|
||||
|
||||
const timelineIntroSurface = selectTimelineIntroSurface({
|
||||
@@ -358,8 +374,13 @@ const MessageTimelineBase = React.forwardRef<
|
||||
}
|
||||
}, [deferredMessages, jumpToMessage, showTimelineSkeleton]);
|
||||
|
||||
const loadOlderViaVirtualizer = React.useCallback(() => {
|
||||
if (!fetchOlder || showTimelineSkeleton || !hasOlderMessages) return;
|
||||
void fetchOlder();
|
||||
}, [fetchOlder, hasOlderMessages, showTimelineSkeleton]);
|
||||
|
||||
useLoadOlderOnScroll({
|
||||
fetchOlder,
|
||||
fetchOlder: virtualizerScrollParent ? undefined : fetchOlder,
|
||||
hasOlderMessages,
|
||||
isLoading: showTimelineSkeleton,
|
||||
scrollContainerRef,
|
||||
@@ -606,12 +627,16 @@ const MessageTimelineBase = React.forwardRef<
|
||||
onReply={onReply}
|
||||
isSendingVideoReviewComment={isSendingVideoReviewComment}
|
||||
onSendVideoReviewComment={onSendVideoReviewComment}
|
||||
onStartReached={loadOlderViaVirtualizer}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onVirtualizerApiChange={setTimelineVirtualizerApi}
|
||||
onAtBottomStateChange={onVirtualizerAtBottomStateChange}
|
||||
personaLookup={personaLookup}
|
||||
profiles={profiles}
|
||||
searchActiveMessageId={searchActiveMessageId}
|
||||
searchMatchingMessageIds={searchMatchingMessageIds}
|
||||
searchQuery={searchQuery}
|
||||
scrollParent={virtualizerScrollParent}
|
||||
threadUnreadCounts={threadUnreadCounts}
|
||||
unfollowThreadById={unfollowThreadById}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { GroupedVirtuoso } from "react-virtuoso";
|
||||
import type { Components, GroupedVirtuosoHandle } from "react-virtuoso";
|
||||
|
||||
import { formatDayHeading } from "@/features/messages/lib/dateFormatters";
|
||||
import { timelineRowReserveStyle } from "@/features/messages/lib/rowHeightEstimate";
|
||||
@@ -6,6 +8,7 @@ import {
|
||||
buildTimelineDayGroups,
|
||||
buildTimelineItems,
|
||||
getTimelineItemKey,
|
||||
type TimelineDayGroup,
|
||||
type TimelineNonDayItem,
|
||||
} from "@/features/messages/lib/timelineItems";
|
||||
import { THREAD_REPLY_ROW_MARGIN_INLINE_REM } from "@/features/messages/lib/threadTreeLayout";
|
||||
@@ -28,6 +31,14 @@ import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
|
||||
import { SystemMessageRow } from "./SystemMessageRow";
|
||||
import { UnreadDivider } from "./UnreadDivider";
|
||||
|
||||
export type TimelineVirtualizerApi = {
|
||||
scrollToBottom: (behavior?: ScrollBehavior) => void;
|
||||
scrollToMessage: (
|
||||
messageId: string,
|
||||
options?: { behavior?: ScrollBehavior },
|
||||
) => boolean;
|
||||
};
|
||||
|
||||
type TimelineMessageListProps = {
|
||||
agentPubkeys?: ReadonlySet<string>;
|
||||
channelId?: string | null;
|
||||
@@ -81,6 +92,11 @@ type TimelineMessageListProps = {
|
||||
searchQuery?: string;
|
||||
/** Per-thread unread counts keyed by thread root id. */
|
||||
threadUnreadCounts?: ReadonlyMap<string, number>;
|
||||
/** Existing scroll container, reused by the virtualizer spike so MessageTimeline owns the scroll node. */
|
||||
scrollParent?: HTMLElement | null;
|
||||
onStartReached?: () => void;
|
||||
onAtBottomStateChange?: (atBottom: boolean) => void;
|
||||
onVirtualizerApiChange?: (api: TimelineVirtualizerApi | null) => void;
|
||||
};
|
||||
|
||||
export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
@@ -114,6 +130,10 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
searchQuery,
|
||||
threadUnreadCounts,
|
||||
unfollowThreadById,
|
||||
scrollParent = null,
|
||||
onStartReached,
|
||||
onAtBottomStateChange,
|
||||
onVirtualizerApiChange,
|
||||
}: TimelineMessageListProps) {
|
||||
const entries = React.useMemo(
|
||||
() =>
|
||||
@@ -255,6 +275,19 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
],
|
||||
);
|
||||
|
||||
if (scrollParent) {
|
||||
return (
|
||||
<VirtualizedTimelineRows
|
||||
dayGroups={dayGroups}
|
||||
onAtBottomStateChange={onAtBottomStateChange}
|
||||
onStartReached={onStartReached}
|
||||
onVirtualizerApiChange={onVirtualizerApiChange}
|
||||
renderItem={renderItem}
|
||||
scrollParent={scrollParent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{dayGroups.map((group) => (
|
||||
@@ -276,13 +309,9 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
<DayDivider label={formatDayHeading(group.headingTimestamp)} />
|
||||
)}
|
||||
{group.items.map((item) => (
|
||||
<div
|
||||
className="timeline-row-cv"
|
||||
key={getTimelineItemKey(item)}
|
||||
style={timelineRowReserveStyle(item)}
|
||||
>
|
||||
<TimelineRowShell item={item} key={getTimelineItemKey(item)}>
|
||||
{renderItem(item)}
|
||||
</div>
|
||||
</TimelineRowShell>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
@@ -290,6 +319,178 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
);
|
||||
});
|
||||
|
||||
const FIRST_ITEM_INDEX_BASE = 1_000_000;
|
||||
|
||||
type VirtualizedTimelineRowsProps = {
|
||||
dayGroups: TimelineDayGroup[];
|
||||
onAtBottomStateChange?: (atBottom: boolean) => void;
|
||||
onStartReached?: () => void;
|
||||
onVirtualizerApiChange?: (api: TimelineVirtualizerApi | null) => void;
|
||||
renderItem: (item: TimelineNonDayItem) => React.ReactNode;
|
||||
scrollParent: HTMLElement;
|
||||
};
|
||||
|
||||
function VirtualizedTimelineRows({
|
||||
dayGroups,
|
||||
onAtBottomStateChange,
|
||||
onStartReached,
|
||||
onVirtualizerApiChange,
|
||||
renderItem,
|
||||
scrollParent,
|
||||
}: VirtualizedTimelineRowsProps) {
|
||||
const virtuosoRef = React.useRef<GroupedVirtuosoHandle>(null);
|
||||
const firstItemIndexStateRef = React.useRef<StableFirstItemIndexState>({
|
||||
firstItemIndex: FIRST_ITEM_INDEX_BASE,
|
||||
keys: [],
|
||||
});
|
||||
const timelineModel = React.useMemo(() => {
|
||||
const items = dayGroups.flatMap((group) => group.items);
|
||||
return {
|
||||
firstItemIndex: getStableFirstItemIndex(
|
||||
firstItemIndexStateRef.current,
|
||||
items,
|
||||
),
|
||||
flattenedItems: items,
|
||||
groupCounts: dayGroups.map((group) => group.items.length),
|
||||
};
|
||||
}, [dayGroups]);
|
||||
const { firstItemIndex, flattenedItems, groupCounts } = timelineModel;
|
||||
const messageVirtualIndexById = React.useMemo(() => {
|
||||
const byId = new Map<string, number>();
|
||||
flattenedItems.forEach((item, index) => {
|
||||
if (item.kind === "message" || item.kind === "system") {
|
||||
byId.set(item.entry.message.id, index);
|
||||
}
|
||||
});
|
||||
return byId;
|
||||
}, [flattenedItems]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!onVirtualizerApiChange) return;
|
||||
const api: TimelineVirtualizerApi = {
|
||||
scrollToBottom(behavior = "auto") {
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
align: "end",
|
||||
behavior: behavior === "smooth" ? "smooth" : "auto",
|
||||
index: "LAST",
|
||||
});
|
||||
},
|
||||
scrollToMessage(messageId, options = {}) {
|
||||
const index = messageVirtualIndexById.get(messageId);
|
||||
if (index === undefined) return false;
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
align: "center",
|
||||
behavior: options.behavior === "smooth" ? "smooth" : "auto",
|
||||
index,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
};
|
||||
onVirtualizerApiChange(api);
|
||||
return () => onVirtualizerApiChange(null);
|
||||
}, [messageVirtualIndexById, onVirtualizerApiChange]);
|
||||
|
||||
return (
|
||||
<GroupedVirtuoso<TimelineNonDayItem>
|
||||
ref={virtuosoRef}
|
||||
className="min-h-full"
|
||||
components={virtuosoComponents}
|
||||
computeItemKey={(index, item) =>
|
||||
item === undefined
|
||||
? `timeline-missing-${index}`
|
||||
: getTimelineItemKey(item)
|
||||
}
|
||||
customScrollParent={scrollParent}
|
||||
data={flattenedItems}
|
||||
defaultItemHeight={72}
|
||||
firstItemIndex={firstItemIndex}
|
||||
followOutput="auto"
|
||||
atBottomStateChange={onAtBottomStateChange}
|
||||
initialTopMostItemIndex={{ align: "end", index: "LAST" }}
|
||||
groupContent={(groupIndex) => {
|
||||
const group = dayGroups[groupIndex];
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col",
|
||||
group.headingTimestamp !== null &&
|
||||
"before:absolute before:inset-x-0 before:top-4 before:h-px before:bg-border/35 before:content-['']",
|
||||
)}
|
||||
data-day-label={
|
||||
group.headingTimestamp === null
|
||||
? undefined
|
||||
: formatDayHeading(group.headingTimestamp)
|
||||
}
|
||||
data-testid="message-timeline-day-group"
|
||||
>
|
||||
{group.headingTimestamp === null ? null : (
|
||||
<DayDivider label={formatDayHeading(group.headingTimestamp)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
groupCounts={groupCounts}
|
||||
itemContent={(_index, _groupIndex, item) => (
|
||||
<TimelineRowShell item={item}>{renderItem(item)}</TimelineRowShell>
|
||||
)}
|
||||
startReached={onStartReached ? () => onStartReached() : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const virtuosoComponents: Components<TimelineNonDayItem> = {
|
||||
List: React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
|
||||
function TimelineVirtuosoList({ className, ...props }, ref) {
|
||||
return (
|
||||
<div {...props} className={cn("flex flex-col", className)} ref={ref} />
|
||||
);
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
function TimelineRowShell({
|
||||
children,
|
||||
item,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
item: TimelineNonDayItem;
|
||||
}) {
|
||||
return (
|
||||
<div className="timeline-row-cv" style={timelineRowReserveStyle(item)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type StableFirstItemIndexState = {
|
||||
firstItemIndex: number;
|
||||
keys: string[];
|
||||
};
|
||||
|
||||
function getStableFirstItemIndex(
|
||||
state: StableFirstItemIndexState,
|
||||
items: TimelineNonDayItem[],
|
||||
): number {
|
||||
const keys = items.map(getTimelineItemKey);
|
||||
let firstItemIndex = state.firstItemIndex;
|
||||
|
||||
if (state.keys.length === 0 || keys.length === 0) {
|
||||
firstItemIndex = FIRST_ITEM_INDEX_BASE;
|
||||
} else {
|
||||
const previousFirstKey = state.keys[0];
|
||||
const previousFirstIndex = keys.indexOf(previousFirstKey);
|
||||
if (previousFirstIndex > 0) {
|
||||
firstItemIndex -= previousFirstIndex;
|
||||
} else if (previousFirstIndex === -1) {
|
||||
firstItemIndex = FIRST_ITEM_INDEX_BASE;
|
||||
}
|
||||
}
|
||||
|
||||
state.firstItemIndex = firstItemIndex;
|
||||
state.keys = keys;
|
||||
return firstItemIndex;
|
||||
}
|
||||
|
||||
function SystemRow({
|
||||
currentPubkey,
|
||||
entry,
|
||||
|
||||
@@ -49,6 +49,14 @@ type UseAnchoredScrollOptions = {
|
||||
/** When set, scroll to and highlight this message on mount and on change. */
|
||||
targetMessageId?: string | null;
|
||||
onTargetReached?: (messageId: string) => void;
|
||||
virtualScrollToMessage?: (
|
||||
messageId: string,
|
||||
options?: { behavior?: ScrollBehavior },
|
||||
) => boolean;
|
||||
/** Imperative virtualizer-owned bottom jump, used only when virtualizer mode is active. */
|
||||
virtualScrollToBottom?: (behavior?: ScrollBehavior) => void;
|
||||
/** Spike mode: let the virtualizer preserve prepend position via its own firstItemIndex path. */
|
||||
virtualizerOwnsPrependAnchoring?: boolean;
|
||||
};
|
||||
|
||||
type UseAnchoredScrollResult = {
|
||||
@@ -72,6 +80,8 @@ type UseAnchoredScrollResult = {
|
||||
messageId: string,
|
||||
options?: { highlight?: boolean; behavior?: ScrollBehavior },
|
||||
) => boolean;
|
||||
/** Syncs the hook's bottom affordances from a virtualizer-owned scroller. */
|
||||
onVirtualizerAtBottomStateChange: (atBottom: boolean) => void;
|
||||
};
|
||||
|
||||
function isAtBottomNow(
|
||||
@@ -150,6 +160,9 @@ export function useAnchoredScroll({
|
||||
|
||||
targetMessageId = null,
|
||||
onTargetReached,
|
||||
virtualScrollToMessage,
|
||||
virtualScrollToBottom,
|
||||
virtualizerOwnsPrependAnchoring = false,
|
||||
}: UseAnchoredScrollOptions): UseAnchoredScrollResult {
|
||||
// Anchor lives in a ref because it must survive renders and is updated
|
||||
// both on scroll (commit-time read) and in the layout effect (post-render
|
||||
@@ -222,11 +235,19 @@ export function useAnchoredScroll({
|
||||
// every imperative bottom jump so `onScroll` holds the at-bottom anchor
|
||||
// until it can snap to the true floor.
|
||||
settlingRef.current = true;
|
||||
container.scrollTo({ top: container.scrollHeight, behavior });
|
||||
if (virtualizerOwnsPrependAnchoring && virtualScrollToBottom) {
|
||||
virtualScrollToBottom(behavior);
|
||||
} else {
|
||||
container.scrollTo({ top: container.scrollHeight, behavior });
|
||||
}
|
||||
setIsAtBottom(true);
|
||||
setNewMessageCount(0);
|
||||
},
|
||||
[scrollContainerRef],
|
||||
[
|
||||
scrollContainerRef,
|
||||
virtualScrollToBottom,
|
||||
virtualizerOwnsPrependAnchoring,
|
||||
],
|
||||
);
|
||||
|
||||
// Arm a one-shot: the next append snaps to bottom regardless of where the
|
||||
@@ -236,6 +257,19 @@ export function useAnchoredScroll({
|
||||
forceBottomOnNextAppendRef.current = true;
|
||||
}, []);
|
||||
|
||||
const highlightMessage = React.useCallback((messageId: string) => {
|
||||
if (highlightTimeoutRef.current !== null) {
|
||||
window.clearTimeout(highlightTimeoutRef.current);
|
||||
}
|
||||
setHighlightedMessageId(messageId);
|
||||
highlightTimeoutRef.current = window.setTimeout(() => {
|
||||
setHighlightedMessageId((current) =>
|
||||
current === messageId ? null : current,
|
||||
);
|
||||
highlightTimeoutRef.current = null;
|
||||
}, 2_000);
|
||||
}, []);
|
||||
|
||||
const scrollToMessageImperative = React.useCallback(
|
||||
(
|
||||
messageId: string,
|
||||
@@ -246,7 +280,19 @@ export function useAnchoredScroll({
|
||||
const el = container.querySelector<HTMLElement>(
|
||||
`[data-message-id="${messageId}"]`,
|
||||
);
|
||||
if (!el) return false;
|
||||
if (!el) {
|
||||
if (
|
||||
virtualScrollToMessage?.(messageId, {
|
||||
behavior: options.behavior ?? "auto",
|
||||
})
|
||||
) {
|
||||
anchorRef.current = { kind: "message", messageId, topOffset: 0 };
|
||||
setIsAtBottom(false);
|
||||
if (options.highlight) highlightMessage(messageId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
@@ -279,21 +325,10 @@ export function useAnchoredScroll({
|
||||
};
|
||||
setIsAtBottom(maxScrollTop - targetScrollTop <= AT_BOTTOM_THRESHOLD_PX);
|
||||
|
||||
if (options.highlight) {
|
||||
if (highlightTimeoutRef.current !== null) {
|
||||
window.clearTimeout(highlightTimeoutRef.current);
|
||||
}
|
||||
setHighlightedMessageId(messageId);
|
||||
highlightTimeoutRef.current = window.setTimeout(() => {
|
||||
setHighlightedMessageId((current) =>
|
||||
current === messageId ? null : current,
|
||||
);
|
||||
highlightTimeoutRef.current = null;
|
||||
}, 2_000);
|
||||
}
|
||||
if (options.highlight) highlightMessage(messageId);
|
||||
return true;
|
||||
},
|
||||
[scrollContainerRef],
|
||||
[highlightMessage, scrollContainerRef, virtualScrollToMessage],
|
||||
);
|
||||
|
||||
// Scroll handler: recompute anchor + bottom state from the current
|
||||
@@ -311,6 +346,9 @@ export function useAnchoredScroll({
|
||||
if (settleProgrammaticBottomPin(container)) {
|
||||
settlingRef.current = false;
|
||||
} else {
|
||||
if (virtualizerOwnsPrependAnchoring) {
|
||||
settlingRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -320,7 +358,7 @@ export function useAnchoredScroll({
|
||||
if (atBottom) {
|
||||
setNewMessageCount(0);
|
||||
}
|
||||
}, [scrollContainerRef]);
|
||||
}, [scrollContainerRef, virtualizerOwnsPrependAnchoring]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Anchor restoration: after every render, stick to the bottom if the user is
|
||||
@@ -396,7 +434,11 @@ export function useAnchoredScroll({
|
||||
forceBottomOnNextAppendRef.current = false;
|
||||
anchorRef.current = { kind: "at-bottom" };
|
||||
settlingRef.current = true;
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
|
||||
if (virtualizerOwnsPrependAnchoring && virtualScrollToBottom) {
|
||||
virtualScrollToBottom("auto");
|
||||
} else {
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
|
||||
}
|
||||
setIsAtBottom(true);
|
||||
setNewMessageCount(0);
|
||||
prevLastMessageIdRef.current = lastMessage?.id;
|
||||
@@ -407,10 +449,13 @@ export function useAnchoredScroll({
|
||||
}
|
||||
|
||||
if (anchor.kind === "at-bottom") {
|
||||
// Stick to bottom across the append.
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
|
||||
// Stick to bottom across the append. In virtualized mode, Virtuoso's
|
||||
// followOutput owns this write; do not slam the shared scroll parent.
|
||||
if (!virtualizerOwnsPrependAnchoring) {
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
|
||||
}
|
||||
if (newLatestArrived) setNewMessageCount(0);
|
||||
} else if (messagesArrived > 0) {
|
||||
} else if (messagesArrived > 0 && !virtualizerOwnsPrependAnchoring) {
|
||||
// Anchored mid-history. An older-history prepend grows the content above
|
||||
// the reading row; the browser's native scroll anchoring does NOT correct
|
||||
// this at the top edge (no anchor node above the viewport when scrollTop
|
||||
@@ -449,6 +494,8 @@ export function useAnchoredScroll({
|
||||
scrollToBottomImperative,
|
||||
scrollToMessageImperative,
|
||||
targetMessageId,
|
||||
virtualScrollToBottom,
|
||||
virtualizerOwnsPrependAnchoring,
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -466,13 +513,21 @@ export function useAnchoredScroll({
|
||||
const observer = new ResizeObserver(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
if (anchorRef.current.kind === "at-bottom") {
|
||||
if (
|
||||
anchorRef.current.kind === "at-bottom" &&
|
||||
!virtualizerOwnsPrependAnchoring
|
||||
) {
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
|
||||
}
|
||||
});
|
||||
observer.observe(content);
|
||||
return () => observer.disconnect();
|
||||
}, [channelId, contentRef, scrollContainerRef]);
|
||||
}, [
|
||||
channelId,
|
||||
contentRef,
|
||||
scrollContainerRef,
|
||||
virtualizerOwnsPrependAnchoring,
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Target message handling (deep link, jump-to-reply, etc.). Distinct from
|
||||
@@ -500,6 +555,13 @@ export function useAnchoredScroll({
|
||||
const el = container.querySelector<HTMLElement>(
|
||||
`[data-message-id="${targetMessageId}"]`,
|
||||
);
|
||||
if (!el && virtualizerOwnsPrependAnchoring) {
|
||||
if (scrollToMessageImperative(targetMessageId, { highlight: true })) {
|
||||
handledTargetIdRef.current = targetMessageId;
|
||||
onTargetReached?.(targetMessageId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!el) {
|
||||
// Row not in the DOM yet. A cold deep-link target is fetched by id and
|
||||
// spliced into `messages` a render or two later; this effect re-runs on
|
||||
@@ -516,6 +578,7 @@ export function useAnchoredScroll({
|
||||
scrollContainerRef,
|
||||
scrollToMessageImperative,
|
||||
targetMessageId,
|
||||
virtualizerOwnsPrependAnchoring,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -526,6 +589,18 @@ export function useAnchoredScroll({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onVirtualizerAtBottomStateChange = React.useCallback(
|
||||
(atBottom: boolean) => {
|
||||
if (!virtualizerOwnsPrependAnchoring) return;
|
||||
if (atBottom) {
|
||||
anchorRef.current = { kind: "at-bottom" };
|
||||
setNewMessageCount(0);
|
||||
}
|
||||
setIsAtBottom(atBottom);
|
||||
},
|
||||
[virtualizerOwnsPrependAnchoring],
|
||||
);
|
||||
|
||||
return {
|
||||
onScroll,
|
||||
isAtBottom,
|
||||
@@ -534,5 +609,6 @@ export function useAnchoredScroll({
|
||||
scrollToBottom: scrollToBottomImperative,
|
||||
scrollToBottomOnNextUpdate,
|
||||
scrollToMessage: scrollToMessageImperative,
|
||||
onVirtualizerAtBottomStateChange,
|
||||
};
|
||||
}
|
||||
|
||||
Generated
+17
@@ -162,6 +162,9 @@ importers:
|
||||
react-markdown:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0(@types/react@19.2.17)(react@19.2.7)
|
||||
react-virtuoso:
|
||||
specifier: ^4.18.10
|
||||
version: 4.18.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
remark-breaks:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0
|
||||
@@ -286,6 +289,9 @@ importers:
|
||||
react-markdown:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0(@types/react@19.2.17)(react@19.2.7)
|
||||
react-virtuoso:
|
||||
specifier: ^4.18.10
|
||||
version: 4.18.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
remark-gfm:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
@@ -2809,6 +2815,12 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
react-virtuoso@4.18.10:
|
||||
resolution: {integrity: sha512-P6GIZ7kWAPOYB2H16yRQNgy+VF9pJOuTFw1EUc1EAtCj5WxVSAF1Sql3x3fbLwaLeBFsiPnu+3U9o6sIOyTdFw==, tarball: https://global.block-artifacts.com/artifactory/api/npm/square-npm/react-virtuoso/-/react-virtuoso-4.18.10.tgz}
|
||||
peerDependencies:
|
||||
react: '>=16 || >=17 || >= 18 || >= 19'
|
||||
react-dom: '>=16 || >=17 || >= 18 || >=19'
|
||||
|
||||
react@19.2.7:
|
||||
resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -5680,6 +5692,11 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
react-virtuoso@4.18.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
|
||||
react@19.2.7: {}
|
||||
|
||||
readable-stream@4.7.0:
|
||||
|
||||
Reference in New Issue
Block a user