Fix desktop timeline scroll behavior (#178)

This commit is contained in:
Wes
2026-03-25 11:40:46 -07:00
committed by GitHub
parent b74ce1da78
commit 242b43c849
8 changed files with 198 additions and 181 deletions

View File

@@ -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
View File

@@ -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':

View File

@@ -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);
},
);
});

View File

@@ -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);
},
);
},

View File

@@ -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} />

View 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}
/>
),
);
});

View File

@@ -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,

View File

@@ -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);