mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Fix desktop timeline scroll behavior (#178)
This commit is contained in:
@@ -35,7 +35,6 @@
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-virtual": "^3.13.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
|
||||
20
desktop/pnpm-lock.yaml
generated
20
desktop/pnpm-lock.yaml
generated
@@ -41,9 +41,6 @@ importers:
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.90.21
|
||||
version: 5.90.21(react@19.2.4)
|
||||
'@tanstack/react-virtual':
|
||||
specifier: ^3.13.0
|
||||
version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@tauri-apps/api':
|
||||
specifier: ^2
|
||||
version: 2.10.1
|
||||
@@ -1045,15 +1042,6 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18 || ^19
|
||||
|
||||
'@tanstack/react-virtual@3.13.23':
|
||||
resolution: {integrity: sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/virtual-core@3.13.23':
|
||||
resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==}
|
||||
|
||||
'@tauri-apps/api@2.10.1':
|
||||
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
|
||||
|
||||
@@ -2804,14 +2792,6 @@ snapshots:
|
||||
'@tanstack/query-core': 5.90.20
|
||||
react: 19.2.4
|
||||
|
||||
'@tanstack/react-virtual@3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.13.23
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
'@tanstack/virtual-core@3.13.23': {}
|
||||
|
||||
'@tauri-apps/api@2.10.1': {}
|
||||
|
||||
'@tauri-apps/cli-darwin-arm64@2.10.1':
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { updateChannelLastMessageAt } from "@/features/channels/hooks";
|
||||
import { getChannelIdFromTags } from "@/features/messages/lib/threading";
|
||||
import { mergeMessages } from "@/features/messages/hooks";
|
||||
import { mergeTimelineCacheMessages } from "@/features/messages/hooks";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import type { Channel, RelayEvent } from "@/shared/api/types";
|
||||
|
||||
@@ -198,7 +198,7 @@ export function useUnreadChannels(
|
||||
return current;
|
||||
}
|
||||
|
||||
return mergeMessages(current, event);
|
||||
return mergeTimelineCacheMessages(current, event);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -22,6 +22,9 @@ type MessageQueryContext = {
|
||||
queryKey: readonly ["channel-messages", string];
|
||||
};
|
||||
|
||||
const CHANNEL_HISTORY_LIMIT = 200;
|
||||
const MAX_TIMELINE_MESSAGES = CHANNEL_HISTORY_LIMIT * 2;
|
||||
|
||||
function dedupeMessagesById(messages: RelayEvent[]) {
|
||||
const seenIds = new Set<string>();
|
||||
const deduped: RelayEvent[] = [];
|
||||
@@ -40,9 +43,28 @@ function dedupeMessagesById(messages: RelayEvent[]) {
|
||||
return deduped.reverse();
|
||||
}
|
||||
|
||||
export function mergeMessages(
|
||||
function sortMessages(messages: RelayEvent[]) {
|
||||
return dedupeMessagesById(messages).sort(
|
||||
(left, right) => left.created_at - right.created_at,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTimelineMessages(messages: RelayEvent[]) {
|
||||
const normalized = sortMessages(messages);
|
||||
|
||||
if (normalized.length <= MAX_TIMELINE_MESSAGES) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// Keep the live timeline bounded so de-virtualized rendering does not grow
|
||||
// into an unbounded DOM during long-lived channel sessions.
|
||||
return normalized.slice(-MAX_TIMELINE_MESSAGES);
|
||||
}
|
||||
|
||||
function mergeMessagesWithNormalizer(
|
||||
current: RelayEvent[],
|
||||
incoming: RelayEvent,
|
||||
normalize: (messages: RelayEvent[]) => RelayEvent[],
|
||||
): RelayEvent[] {
|
||||
const normalizedCurrent = dedupeMessagesById(current);
|
||||
const deduped = normalizedCurrent.filter(
|
||||
@@ -51,8 +73,24 @@ export function mergeMessages(
|
||||
!(message.pending && incoming.content === message.content),
|
||||
);
|
||||
|
||||
return dedupeMessagesById([...deduped, incoming]).sort(
|
||||
(left, right) => left.created_at - right.created_at,
|
||||
return normalize([...deduped, incoming]);
|
||||
}
|
||||
|
||||
export function mergeMessages(
|
||||
current: RelayEvent[],
|
||||
incoming: RelayEvent,
|
||||
): RelayEvent[] {
|
||||
return mergeMessagesWithNormalizer(current, incoming, sortMessages);
|
||||
}
|
||||
|
||||
export function mergeTimelineCacheMessages(
|
||||
current: RelayEvent[],
|
||||
incoming: RelayEvent,
|
||||
): RelayEvent[] {
|
||||
return mergeMessagesWithNormalizer(
|
||||
current,
|
||||
incoming,
|
||||
normalizeTimelineMessages,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -117,13 +155,16 @@ export function useChannelMessagesQuery(channel: Channel | null) {
|
||||
throw new Error("No channel selected.");
|
||||
}
|
||||
|
||||
const history = await relayClient.fetchChannelHistory(channel.id, 200);
|
||||
const history = await relayClient.fetchChannelHistory(
|
||||
channel.id,
|
||||
CHANNEL_HISTORY_LIMIT,
|
||||
);
|
||||
const currentMessages =
|
||||
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
|
||||
const mergedHistory = dedupeMessagesById([
|
||||
const mergedHistory = normalizeTimelineMessages([
|
||||
...currentMessages,
|
||||
...history,
|
||||
]).sort((left, right) => left.created_at - right.created_at);
|
||||
]);
|
||||
|
||||
return mergedHistory;
|
||||
},
|
||||
@@ -141,14 +182,18 @@ export function useChannelSubscription(channel: Channel | null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const history = await relayClient.fetchChannelHistory(channelId, 200);
|
||||
const history = await relayClient.fetchChannelHistory(
|
||||
channelId,
|
||||
CHANNEL_HISTORY_LIMIT,
|
||||
);
|
||||
|
||||
queryClient.setQueryData<RelayEvent[]>(
|
||||
["channel-messages", channelId],
|
||||
(current = []) => {
|
||||
const mergedHistory = dedupeMessagesById([...current, ...history]).sort(
|
||||
(left, right) => left.created_at - right.created_at,
|
||||
);
|
||||
const mergedHistory = normalizeTimelineMessages([
|
||||
...current,
|
||||
...history,
|
||||
]);
|
||||
|
||||
return mergedHistory;
|
||||
},
|
||||
@@ -167,7 +212,7 @@ export function useChannelSubscription(channel: Channel | null) {
|
||||
);
|
||||
queryClient.setQueryData<RelayEvent[]>(
|
||||
["channel-messages", channelId],
|
||||
(current = []) => mergeMessages(current, event),
|
||||
(current = []) => mergeTimelineCacheMessages(current, event),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -344,7 +389,7 @@ export function useSendMessageMutation(
|
||||
|
||||
queryClient.setQueryData<RelayEvent[]>(
|
||||
queryKey,
|
||||
mergeMessages(previousMessages, optimisticMessage),
|
||||
mergeTimelineCacheMessages(previousMessages, optimisticMessage),
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -379,7 +424,7 @@ export function useSendMessageMutation(
|
||||
const withoutOptimistic = current.filter(
|
||||
(item) => item.id !== context.optimisticId,
|
||||
);
|
||||
return mergeMessages(withoutOptimistic, message);
|
||||
return mergeTimelineCacheMessages(withoutOptimistic, message);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
import * as React from "react";
|
||||
import { ArrowDown } from "lucide-react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Separator } from "@/shared/ui/separator";
|
||||
import { MessageRow } from "./MessageRow";
|
||||
import { SystemMessageRow } from "./SystemMessageRow";
|
||||
import { TimelineSkeleton } from "./TimelineSkeleton";
|
||||
import { TimelineMessageList } from "./TimelineMessageList";
|
||||
import { useTimelineScrollManager } from "./useTimelineScrollManager";
|
||||
|
||||
const ESTIMATED_ROW_HEIGHT = 60;
|
||||
const OVERSCAN_COUNT = 10;
|
||||
|
||||
type MessageTimelineProps = {
|
||||
channelId?: string | null;
|
||||
messages: TimelineMessage[];
|
||||
@@ -50,13 +44,6 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
}: MessageTimelineProps) {
|
||||
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: messages.length,
|
||||
getScrollElement: () => scrollContainerRef.current,
|
||||
estimateSize: () => ESTIMATED_ROW_HEIGHT,
|
||||
overscan: OVERSCAN_COUNT,
|
||||
});
|
||||
|
||||
const {
|
||||
bottomAnchorRef,
|
||||
contentRef,
|
||||
@@ -72,12 +59,8 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
onTargetReached,
|
||||
scrollContainerRef,
|
||||
targetMessageId,
|
||||
virtualizer,
|
||||
});
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
const totalSize = virtualizer.getTotalSize();
|
||||
|
||||
return (
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div
|
||||
@@ -118,43 +101,15 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
) : null}
|
||||
|
||||
{!isLoading && messages.length > 0 ? (
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{ height: `${totalSize}px` }}
|
||||
>
|
||||
{virtualItems.map((virtualRow) => {
|
||||
const message = messages[virtualRow.index];
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{message.kind === KIND_SYSTEM_MESSAGE ? (
|
||||
<SystemMessageRow
|
||||
body={message.body}
|
||||
currentPubkey={currentPubkey}
|
||||
profiles={profiles}
|
||||
time={message.time}
|
||||
/>
|
||||
) : (
|
||||
<MessageRow
|
||||
activeReplyTargetId={activeReplyTargetId}
|
||||
highlighted={message.id === highlightedMessageId}
|
||||
message={message}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onReply={onReply}
|
||||
profiles={profiles}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<TimelineMessageList
|
||||
activeReplyTargetId={activeReplyTargetId}
|
||||
currentPubkey={currentPubkey}
|
||||
highlightedMessageId={highlightedMessageId}
|
||||
messages={messages}
|
||||
onReply={onReply}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
|
||||
|
||||
53
desktop/src/features/messages/ui/TimelineMessageList.tsx
Normal file
53
desktop/src/features/messages/ui/TimelineMessageList.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
|
||||
import { MessageRow } from "./MessageRow";
|
||||
import { SystemMessageRow } from "./SystemMessageRow";
|
||||
|
||||
type TimelineMessageListProps = {
|
||||
activeReplyTargetId?: string | null;
|
||||
currentPubkey?: string;
|
||||
highlightedMessageId?: string | null;
|
||||
messages: TimelineMessage[];
|
||||
onReply?: (message: TimelineMessage) => void;
|
||||
onToggleReaction?: (
|
||||
message: TimelineMessage,
|
||||
emoji: string,
|
||||
remove: boolean,
|
||||
) => Promise<void>;
|
||||
profiles?: UserProfileLookup;
|
||||
};
|
||||
|
||||
export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
activeReplyTargetId = null,
|
||||
currentPubkey,
|
||||
highlightedMessageId = null,
|
||||
messages,
|
||||
onReply,
|
||||
onToggleReaction,
|
||||
profiles,
|
||||
}: TimelineMessageListProps) {
|
||||
return messages.map((message) =>
|
||||
message.kind === KIND_SYSTEM_MESSAGE ? (
|
||||
<SystemMessageRow
|
||||
key={message.id}
|
||||
body={message.body}
|
||||
currentPubkey={currentPubkey}
|
||||
profiles={profiles}
|
||||
time={message.time}
|
||||
/>
|
||||
) : (
|
||||
<MessageRow
|
||||
key={message.id}
|
||||
activeReplyTargetId={activeReplyTargetId}
|
||||
highlighted={message.id === highlightedMessageId}
|
||||
message={message}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onReply={onReply}
|
||||
profiles={profiles}
|
||||
/>
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -1,9 +1,21 @@
|
||||
import * as React from "react";
|
||||
import type { Virtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { isNearBottom } from "./messageTimelineUtils";
|
||||
|
||||
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,
|
||||
@@ -11,16 +23,7 @@ export function useTimelineScrollManager({
|
||||
onTargetReached,
|
||||
scrollContainerRef,
|
||||
targetMessageId,
|
||||
virtualizer,
|
||||
}: {
|
||||
channelId?: string | null;
|
||||
isLoading: boolean;
|
||||
messages: TimelineMessage[];
|
||||
onTargetReached?: (messageId: string) => void;
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
targetMessageId?: string | null;
|
||||
virtualizer?: Virtualizer<HTMLDivElement, Element>;
|
||||
}) {
|
||||
}: UseTimelineScrollManagerOptions) {
|
||||
const timelineRef = scrollContainerRef;
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const bottomAnchorRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -40,12 +43,7 @@ export function useTimelineScrollManager({
|
||||
>(null);
|
||||
const [newMessageCount, setNewMessageCount] = React.useState(0);
|
||||
|
||||
// Keep a ref to the virtualizer so callbacks don't need it as a dependency
|
||||
const virtualizerRef = React.useRef(virtualizer);
|
||||
virtualizerRef.current = virtualizer;
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is intentionally the sole trigger — we reset all scroll state when the channel changes
|
||||
React.useLayoutEffect(() => {
|
||||
const resetScrollTracking = React.useCallback(() => {
|
||||
hasInitializedRef.current = false;
|
||||
shouldStickToBottomRef.current = true;
|
||||
isAtBottomRef.current = true;
|
||||
@@ -59,7 +57,43 @@ export function useTimelineScrollManager({
|
||||
setIsAtBottom(true);
|
||||
setHighlightedMessageId(null);
|
||||
setNewMessageCount(0);
|
||||
}, [channelId]);
|
||||
}, []);
|
||||
|
||||
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;
|
||||
@@ -81,38 +115,24 @@ export function useTimelineScrollManager({
|
||||
if (movedAwayFromBottom) {
|
||||
isProgrammaticBottomScrollRef.current = false;
|
||||
} else if (!atBottom) {
|
||||
shouldStickToBottomRef.current = true;
|
||||
isAtBottomRef.current = true;
|
||||
setIsAtBottom((current) => (current ? current : true));
|
||||
pinToBottom();
|
||||
return;
|
||||
} else {
|
||||
isProgrammaticBottomScrollRef.current = false;
|
||||
shouldStickToBottomRef.current = true;
|
||||
isAtBottomRef.current = true;
|
||||
setIsAtBottom((current) => (current ? current : true));
|
||||
setNewMessageCount(0);
|
||||
pinToBottom({ clearNewMessageCount: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldStickToBottomRef.current && !atBottom && !movedAwayFromBottom) {
|
||||
previousScrollTopRef.current = scrollTop;
|
||||
shouldStickToBottomRef.current = true;
|
||||
isAtBottomRef.current = true;
|
||||
setIsAtBottom((current) => (current ? current : true));
|
||||
setNewMessageCount(0);
|
||||
pinToBottom({ clearNewMessageCount: true });
|
||||
return;
|
||||
}
|
||||
|
||||
previousScrollTopRef.current = scrollTop;
|
||||
shouldStickToBottomRef.current = atBottom;
|
||||
isAtBottomRef.current = atBottom;
|
||||
setIsAtBottom((current) => (current === atBottom ? current : atBottom));
|
||||
|
||||
if (atBottom) {
|
||||
setNewMessageCount(0);
|
||||
}
|
||||
}, []);
|
||||
setObservedBottomState(atBottom);
|
||||
}, [pinToBottom, setObservedBottomState]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes
|
||||
const restoreScrollPosition = React.useCallback(
|
||||
@@ -157,11 +177,6 @@ export function useTimelineScrollManager({
|
||||
|
||||
isProgrammaticBottomScrollRef.current = true;
|
||||
|
||||
const virt = virtualizerRef.current;
|
||||
if (virt && virt.options.count > 0) {
|
||||
virt.scrollToIndex(virt.options.count - 1, { align: "end" });
|
||||
}
|
||||
|
||||
const alignToBottom = (nextBehavior: ScrollBehavior) => {
|
||||
bottomAnchorRef.current?.scrollIntoView({
|
||||
block: "end",
|
||||
@@ -176,10 +191,7 @@ export function useTimelineScrollManager({
|
||||
alignToBottom(behavior);
|
||||
lockedScrollTopRef.current = null;
|
||||
previousScrollTopRef.current = timeline.scrollTop;
|
||||
shouldStickToBottomRef.current = true;
|
||||
isAtBottomRef.current = true;
|
||||
setIsAtBottom(true);
|
||||
setNewMessageCount(0);
|
||||
pinToBottom({ clearNewMessageCount: true });
|
||||
|
||||
if (behavior === "smooth") {
|
||||
requestAnimationFrame(() => {
|
||||
@@ -205,7 +217,7 @@ export function useTimelineScrollManager({
|
||||
|
||||
settleAlignment(2);
|
||||
},
|
||||
[syncScrollState],
|
||||
[pinToBottom, syncScrollState],
|
||||
);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes
|
||||
@@ -332,55 +344,25 @@ export function useTimelineScrollManager({
|
||||
|
||||
const settleOnTarget = () => {
|
||||
handledTargetMessageIdRef.current = targetMessageId;
|
||||
shouldStickToBottomRef.current = false;
|
||||
isAtBottomRef.current = false;
|
||||
isProgrammaticBottomScrollRef.current = false;
|
||||
previousScrollTopRef.current = timeline.scrollTop;
|
||||
setIsAtBottom(false);
|
||||
unpinFromBottom(timeline.scrollTop);
|
||||
setHighlightedMessageId(targetMessageId);
|
||||
setNewMessageCount(0);
|
||||
onTargetReached?.(targetMessageId);
|
||||
};
|
||||
|
||||
// With virtualization the target row may not be in the DOM yet.
|
||||
// Use scrollToIndex to bring it into view first, then highlight.
|
||||
const virt = virtualizerRef.current;
|
||||
const targetIndex = messages.findIndex((m) => m.id === targetMessageId);
|
||||
|
||||
if (virt && targetIndex >= 0) {
|
||||
virt.scrollToIndex(targetIndex, { align: "center" });
|
||||
|
||||
// Give the virtualizer a frame to render the row before querying the DOM.
|
||||
requestAnimationFrame(() => {
|
||||
const targetElement = timeline.querySelector<HTMLElement>(
|
||||
`[data-message-id="${targetMessageId}"]`,
|
||||
);
|
||||
|
||||
if (targetElement) {
|
||||
targetElement.scrollIntoView({
|
||||
block: "center",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
settleOnTarget();
|
||||
});
|
||||
} else {
|
||||
// Fallback for non-virtualized usage or unknown target
|
||||
const targetElement = timeline.querySelector<HTMLElement>(
|
||||
`[data-message-id="${targetMessageId}"]`,
|
||||
);
|
||||
if (!targetElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetElement.scrollIntoView({
|
||||
block: "center",
|
||||
behavior: "smooth",
|
||||
});
|
||||
settleOnTarget();
|
||||
const targetElement = timeline.querySelector<HTMLElement>(
|
||||
`[data-message-id="${targetMessageId}"]`,
|
||||
);
|
||||
if (!targetElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetElement.scrollIntoView({
|
||||
block: "center",
|
||||
behavior: "smooth",
|
||||
});
|
||||
settleOnTarget();
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
setHighlightedMessageId((current) =>
|
||||
current === targetMessageId ? null : current,
|
||||
@@ -390,7 +372,7 @@ export function useTimelineScrollManager({
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [isLoading, messages, onTargetReached, targetMessageId]);
|
||||
}, [isLoading, messages, onTargetReached, targetMessageId, unpinFromBottom]);
|
||||
|
||||
return {
|
||||
bottomAnchorRef,
|
||||
|
||||
@@ -364,6 +364,9 @@ test("keeps scroll position when new messages arrive above the fold", async ({
|
||||
|
||||
await pageTwo.getByTestId("message-scroll-to-latest").click();
|
||||
|
||||
await expect(pageTwo.getByTestId("message-timeline")).toContainText(
|
||||
incomingMessage,
|
||||
);
|
||||
await expect
|
||||
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
|
||||
.toBeLessThan(8);
|
||||
|
||||
Reference in New Issue
Block a user