diff --git a/desktop/package.json b/desktop/package.json index a556f53961..3c59a544b1 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -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", diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 4d2cf0c48b..3dbdb312fd 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -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': diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index ad1f82b644..e08f6ffa4b 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -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); }, ); }); diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 8644fd9db5..b98aee3bb4 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -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(); 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(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( ["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( ["channel-messages", channelId], - (current = []) => mergeMessages(current, event), + (current = []) => mergeTimelineCacheMessages(current, event), ); }); @@ -344,7 +389,7 @@ export function useSendMessageMutation( queryClient.setQueryData( 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); }, ); }, diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index be2fa113b7..afdcdd2b4d 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -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(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 (
0 ? ( -
- {virtualItems.map((virtualRow) => { - const message = messages[virtualRow.index]; - return ( -
- {message.kind === KIND_SYSTEM_MESSAGE ? ( - - ) : ( - - )} -
- ); - })} -
+ ) : null}
diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx new file mode 100644 index 0000000000..d9ba312215 --- /dev/null +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -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; + 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 ? ( + + ) : ( + + ), + ); +}); diff --git a/desktop/src/features/messages/ui/useTimelineScrollManager.ts b/desktop/src/features/messages/ui/useTimelineScrollManager.ts index f733828521..3ef4c00afd 100644 --- a/desktop/src/features/messages/ui/useTimelineScrollManager.ts +++ b/desktop/src/features/messages/ui/useTimelineScrollManager.ts @@ -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; + 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; - targetMessageId?: string | null; - virtualizer?: Virtualizer; -}) { +}: UseTimelineScrollManagerOptions) { const timelineRef = scrollContainerRef; const contentRef = React.useRef(null); const bottomAnchorRef = React.useRef(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( - `[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( - `[data-message-id="${targetMessageId}"]`, - ); - if (!targetElement) { - return; - } - - targetElement.scrollIntoView({ - block: "center", - behavior: "smooth", - }); - settleOnTarget(); + const targetElement = timeline.querySelector( + `[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, diff --git a/desktop/tests/e2e/stream.spec.ts b/desktop/tests/e2e/stream.spec.ts index 0cd801b3e1..a8fd886ce0 100644 --- a/desktop/tests/e2e/stream.spec.ts +++ b/desktop/tests/e2e/stream.spec.ts @@ -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);