diff --git a/desktop/package.json b/desktop/package.json index e307e592c..ff679e7b0 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -64,6 +64,7 @@ "react-diff-view": "^3.3.2", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", + "react-virtuoso": "^4.18.7", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "shiki": "^4.0.2", diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 3dd4e6c2f..c79265a38 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ name: "smoke", testMatch: [ "**/smoke.spec.ts", + "**/scroll-history.spec.ts", "**/channels.spec.ts", "**/badge.spec.ts", "**/channel-browser.spec.ts", diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 1fa347a20..ab379d8ab 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -1,9 +1,11 @@ import * as React from "react"; import { ArrowDown, Hash } from "lucide-react"; +import type { VirtuosoHandle } from "react-virtuoso"; import type { TimelineMessage } from "@/features/messages/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ChannelType } from "@/shared/api/types"; +import { isSameDay } from "@/features/messages/lib/dateFormatters"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { cn } from "@/shared/lib/cn"; import { channelChrome } from "@/shared/layout/chromeLayout"; @@ -11,9 +13,7 @@ import { Button } from "@/shared/ui/button"; import { Spinner } from "@/shared/ui/spinner"; import { TooltipProvider } from "@/shared/ui/tooltip"; import { TimelineSkeleton } from "./TimelineSkeleton"; -import { TimelineMessageList } from "./TimelineMessageList"; -import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll"; -import { useTimelineScrollManager } from "./useTimelineScrollManager"; +import { VirtualizedTimelineMessageList } from "./VirtualizedTimelineMessageList"; type MessageTimelineProps = { agentPubkeys?: ReadonlySet; @@ -88,6 +88,35 @@ type ChannelIntro = { icon?: React.ReactNode; }; +function mergeRefs(...refs: Array | undefined>) { + return (value: T | null) => { + for (const ref of refs) { + if (!ref) continue; + if (typeof ref === "function") { + ref(value); + } else { + ref.current = value; + } + } + }; +} + +function getTimelineItemIndex(messages: TimelineMessage[], messageId: string) { + let itemIndex = 0; + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index]; + const previous = index > 0 ? messages[index - 1] : null; + if (!previous || !isSameDay(previous.createdAt, message.createdAt)) { + itemIndex += 1; + } + if (message.id === messageId) { + return itemIndex; + } + itemIndex += 1; + } + return -1; +} + export const MessageTimeline = React.memo(function MessageTimeline({ agentPubkeys, channelId, @@ -125,63 +154,45 @@ export const MessageTimeline = React.memo(function MessageTimeline({ onTargetReached, }: MessageTimelineProps) { const internalScrollRef = React.useRef(null); + const virtuosoRef = React.useRef(null); const scrollContainerRef = externalScrollRef ?? internalScrollRef; - const topSentinelRef = React.useRef(null); + const fetchOlderInFlightRef = React.useRef(false); + const hasInitializedScrollRef = React.useRef(false); + const shouldStickToBottomRef = React.useRef(true); + const handledTargetMessageIdRef = React.useRef(null); + const previousLastMessageKeyRef = React.useRef(undefined); + const previousMessageCountRef = React.useRef(0); + const previousChannelIdRef = React.useRef(channelId); + const [highlightedMessageId, setHighlightedMessageId] = React.useState< + string | null + >(null); + const [isAtBottom, setIsAtBottom] = React.useState(true); + const [newMessageCount, setNewMessageCount] = React.useState(0); const scrollRestorationId = targetMessageId ? `message-timeline:${channelId ?? "none"}:target:${targetMessageId}` : `message-timeline:${channelId ?? "none"}`; - const { - bottomAnchorRef, - contentRef, - highlightedMessageId, - isAtBottom, - newMessageCount, - restoreScrollPosition, - scrollToBottom, - syncScrollState, - } = useTimelineScrollManager({ - channelId, - isLoading, - messages, - onTargetReached, - scrollContainerRef, - targetMessageId, - }); - - // Scroll to the active search match when it changes. - const prevSearchActiveRef = React.useRef(null); - // biome-ignore lint/correctness/useExhaustiveDependencies: scrollContainerRef is a stable React ref - React.useEffect(() => { - if ( - !searchActiveMessageId || - searchActiveMessageId === prevSearchActiveRef.current - ) { - prevSearchActiveRef.current = searchActiveMessageId; + React.useLayoutEffect(() => { + if (previousChannelIdRef.current === channelId) { return; } - prevSearchActiveRef.current = searchActiveMessageId; - const container = scrollContainerRef.current; - if (!container) return; - - const el = container.querySelector( - `[data-message-id="${searchActiveMessageId}"]`, - ); - if (el) { - el.scrollIntoView({ block: "center", behavior: "smooth" }); - } - }, [searchActiveMessageId]); - - useLoadOlderOnScroll({ - fetchOlder, - hasOlderMessages, - isLoading, - restoreScrollPosition, - scrollContainerRef, - sentinelRef: topSentinelRef, + previousChannelIdRef.current = channelId; + hasInitializedScrollRef.current = false; + shouldStickToBottomRef.current = true; + handledTargetMessageIdRef.current = null; + previousLastMessageKeyRef.current = undefined; + previousMessageCountRef.current = 0; + setHighlightedMessageId(null); + setIsAtBottom(true); + setNewMessageCount(0); }); + const setScrollerRef = React.useMemo( + () => mergeRefs(scrollContainerRef), + [scrollContainerRef], + ); + const showDirectMessageIntro = !isLoading && directMessageIntro !== null; const showChannelIntro = !isLoading && channelIntro !== null && directMessageIntro === null; @@ -192,204 +203,261 @@ export const MessageTimeline = React.memo(function MessageTimeline({ directMessageIntro === null && channelIntro === null; const showMessageList = !isLoading && messages.length > 0; + const latestMessage = + messages.length > 0 ? messages[messages.length - 1] : undefined; + const latestMessageKey = latestMessage + ? (latestMessage.renderKey ?? latestMessage.id) + : undefined; + + const scrollToBottom = React.useCallback( + (behavior: ScrollBehavior) => { + if (messages.length === 0) return; + shouldStickToBottomRef.current = true; + setIsAtBottom(true); + setNewMessageCount(0); + virtuosoRef.current?.scrollToIndex({ + align: "end", + behavior: behavior === "smooth" ? "smooth" : "auto", + index: "LAST", + }); + }, + [messages.length], + ); + + const handleAtBottomStateChange = React.useCallback((atBottom: boolean) => { + shouldStickToBottomRef.current = atBottom; + setIsAtBottom(atBottom); + if (atBottom) { + setNewMessageCount(0); + } + }, []); + + const handleStartReached = React.useCallback(() => { + if ( + !fetchOlder || + !hasOlderMessages || + isLoading || + isFetchingOlder || + fetchOlderInFlightRef.current + ) { + return; + } + + fetchOlderInFlightRef.current = true; + void fetchOlder().finally(() => { + fetchOlderInFlightRef.current = false; + }); + }, [fetchOlder, hasOlderMessages, isFetchingOlder, isLoading]); + + React.useLayoutEffect(() => { + if (isLoading || !showMessageList) { + return; + } + + if (!hasInitializedScrollRef.current) { + hasInitializedScrollRef.current = true; + previousLastMessageKeyRef.current = latestMessageKey; + previousMessageCountRef.current = messages.length; + + if (!targetMessageId) { + scrollToBottom("auto"); + } + return; + } + + const previousLastMessageKey = previousLastMessageKeyRef.current; + const previousMessageCount = previousMessageCountRef.current; + const hasNewLatestMessage = + latestMessage !== undefined && + latestMessageKey !== previousLastMessageKey; + + if (hasNewLatestMessage) { + if ( + !targetMessageId && + (shouldStickToBottomRef.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, + showMessageList, + targetMessageId, + ]); + + React.useEffect(() => { + if (!searchActiveMessageId) return; + const index = getTimelineItemIndex(messages, searchActiveMessageId); + if (index < 0) return; + virtuosoRef.current?.scrollToIndex({ + align: "center", + behavior: "smooth", + index, + }); + }, [messages, searchActiveMessageId]); + + React.useEffect(() => { + if (!targetMessageId) { + handledTargetMessageIdRef.current = null; + setHighlightedMessageId(null); + return; + } + + if (handledTargetMessageIdRef.current === targetMessageId || isLoading) { + return; + } + + const index = getTimelineItemIndex(messages, targetMessageId); + if (index < 0) return; + + handledTargetMessageIdRef.current = targetMessageId; + shouldStickToBottomRef.current = false; + setHighlightedMessageId(targetMessageId); + setNewMessageCount(0); + virtuosoRef.current?.scrollToIndex({ + align: "center", + behavior: "auto", + index, + }); + onTargetReached?.(targetMessageId); + + const timeout = window.setTimeout(() => { + setHighlightedMessageId((current) => + current === targetMessageId ? null : current, + ); + }, 2_000); + + return () => { + window.clearTimeout(timeout); + }; + }, [isLoading, messages, onTargetReached, targetMessageId]); return (
-
-
- - {isFetchingOlder ? ( -
- -
- ) : null} - - {isLoading ? : null} - - {showDirectMessageIntro ? ( + {showMessageList ? ( + + shouldStickToBottomRef.current ? "auto" : false + } + followThreadById={followThreadById} + hasOlderMessages={hasOlderMessages} + highlightedMessageId={highlightedMessageId} + isFetchingOlder={isFetchingOlder} + isFollowingThreadById={isFollowingThreadById} + messageFooters={messageFooters} + messages={messages} + onDelete={onDelete} + onEdit={onEdit} + onMarkUnread={onMarkUnread} + onReply={onReply} + onStartReached={handleStartReached} + isSendingVideoReviewComment={isSendingVideoReviewComment} + onSendVideoReviewComment={onSendVideoReviewComment} + onToggleReaction={onToggleReaction} + personaLookup={personaLookup} + profiles={profiles} + scrollerRef={(element) => { + setScrollerRef(element); + if (element) { + element.dataset.scrollRestorationId = scrollRestorationId; + } + }} + searchActiveMessageId={searchActiveMessageId} + searchMatchingMessageIds={searchMatchingMessageIds} + searchQuery={searchQuery} + topHeader={ + showDirectMessageIntro ? ( + + ) : showChannelIntro ? ( + + ) : null + } + unfollowThreadById={unfollowThreadById} + virtuosoRef={virtuosoRef} + /> + ) : ( +
- -

- {directMessageIntro.displayName} -

-

- This is the beginning of your direct message with{" "} - - {directMessageIntro.displayName} - - . -

-
- ) : null} + {isLoading ? : null} - {showChannelIntro ? ( -
-
- {channelIntro.icon ?? ( - - )} -
-

- #{channelIntro.channelName} -

-

- This is the beginning of the{" "} - - {channelIntro.channelKindLabel} - - . -

- {channelIntro.description ? ( -

- {channelIntro.description} -

+ {showDirectMessageIntro ? ( + ) : null} - {channelIntro.actions?.length ? ( -
- {channelIntro.actions.map((action) => { - const hasDescription = Boolean(action.description); - - return ( - - ); - })} -
+ + {showChannelIntro ? ( + ) : null} -
- ) : null} - {showGenericEmpty ? ( -
-

- {emptyTitle} -

-

- {emptyDescription} -

+ {showGenericEmpty ? ( +
+

+ {emptyTitle} +

+

+ {emptyDescription} +

+
+ ) : null}
- ) : null} +
+ )} - {showMessageList ? ( -
- + {isFetchingOlder && showMessageList ? ( +
+
+
- ) : null} - -
-
+
+ ) : null}
{!isAtBottom ? ( @@ -420,3 +488,126 @@ export const MessageTimeline = React.memo(function MessageTimeline({ ); }); + +function DirectMessageIntroCard({ + directMessageIntro, +}: { + directMessageIntro: { + avatarUrl: string | null; + displayName: string; + }; +}) { + return ( +
+ +

+ {directMessageIntro.displayName} +

+

+ This is the beginning of your direct message with{" "} + + {directMessageIntro.displayName} + + . +

+
+ ); +} + +function ChannelIntroCard({ channelIntro }: { channelIntro: ChannelIntro }) { + return ( +
+
+ {channelIntro.icon ?? } +
+

+ #{channelIntro.channelName} +

+

+ This is the beginning of the{" "} + + {channelIntro.channelKindLabel} + + . +

+ {channelIntro.description ? ( +

+ {channelIntro.description} +

+ ) : null} + {channelIntro.actions?.length ? ( +
+ {channelIntro.actions.map((action) => { + const hasDescription = Boolean(action.description); + + return ( + + ); + })} +
+ ) : null} +
+ ); +} diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index a649fa357..7938acca1 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -16,7 +16,7 @@ import { MessageRow } from "./MessageRow"; import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; import { SystemMessageRow } from "./SystemMessageRow"; -type TimelineMessageListProps = { +export type TimelineMessageListProps = { agentPubkeys?: ReadonlySet; channelId?: string | null; channelName?: string; @@ -56,7 +56,11 @@ type TimelineMessageListProps = { searchQuery?: string; }; -function hasVideoAttachment(message: TimelineMessage): boolean { +export type TimelineMessageEntry = ReturnType< + typeof buildMainTimelineEntries +>[number]; + +export function hasVideoAttachment(message: TimelineMessage): boolean { if (message.body.includes("![video](")) return true; return ( @@ -68,7 +72,7 @@ function hasVideoAttachment(message: TimelineMessage): boolean { ); } -function buildReviewCommentsByRootId( +export function buildReviewCommentsByRootId( messages: TimelineMessage[], ): Map { const messageById = new Map(messages.map((message) => [message.id, message])); @@ -100,6 +104,209 @@ function buildReviewCommentsByRootId( return commentsByRootId; } +type BuildVideoReviewContextOptions = Pick< + TimelineMessageListProps, + | "channelId" + | "channelName" + | "channelType" + | "isSendingVideoReviewComment" + | "onSendVideoReviewComment" + | "onToggleReaction" + | "profiles" +> & { + messages: TimelineMessage[]; + reviewCommentsByRootId: Map; +}; + +export function buildVideoReviewContextById({ + channelId, + channelName, + channelType, + isSendingVideoReviewComment = false, + messages, + onSendVideoReviewComment, + onToggleReaction, + profiles, + reviewCommentsByRootId, +}: BuildVideoReviewContextOptions): Map { + const contexts = new Map(); + for (const message of messages) { + if (!hasVideoAttachment(message)) continue; + const comments = reviewCommentsByRootId.get(message.id) ?? []; + contexts.set(message.id, { + channelId, + channelName, + channelType, + comments, + disabled: !onSendVideoReviewComment || message.pending, + isSending: isSendingVideoReviewComment, + onSendComment: onSendVideoReviewComment + ? (content, mentionPubkeys, mediaTags, parentEventId) => + onSendVideoReviewComment( + message, + content, + mentionPubkeys, + mediaTags, + parentEventId, + ) + : undefined, + onToggleCommentReaction: onToggleReaction + ? (comment, emoji, remove) => { + const sourceComment = comments.find( + (candidate) => candidate.id === comment.id, + ); + if (!sourceComment) return Promise.resolve(); + return onToggleReaction(sourceComment, emoji, remove); + } + : undefined, + profiles, + rootEventId: message.id, + }); + } + return contexts; +} + +type RenderTimelineMessageEntryOptions = Omit< + TimelineMessageListProps, + "messages" | "messageFooters" +> & { + entry: TimelineMessageEntry; + footer?: React.ReactNode; + messageKey?: React.Key; + videoReviewContext?: VideoReviewContext; +}; + +export function renderTimelineMessageEntry({ + agentPubkeys, + channelId, + currentPubkey, + entry, + followThreadById, + footer, + highlightedMessageId = null, + isFollowingThreadById, + messageKey, + onDelete, + onEdit, + onMarkUnread, + onReply, + onToggleReaction, + personaLookup, + profiles, + searchActiveMessageId = null, + searchMatchingMessageIds, + searchQuery, + unfollowThreadById, + videoReviewContext, +}: RenderTimelineMessageEntryOptions) { + const { message, summary } = entry; + const key = messageKey ?? message.renderKey ?? message.id; + + if (message.kind === KIND_SYSTEM_MESSAGE) { + return ( +
+ + {footer} +
+ ); + } + + if (summary && onReply) { + const isHighlighted = message.id === highlightedMessageId; + return ( +
+ followThreadById(message.id) : undefined + } + onMarkUnread={onMarkUnread} + onToggleReaction={onToggleReaction} + onReply={onReply} + onUnfollowThread={ + unfollowThreadById + ? () => unfollowThreadById(message.id) + : undefined + } + profiles={profiles} + videoReviewContext={videoReviewContext} + /> + + {footer} +
+ ); + } + + const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false; + const isSearchActive = message.id === searchActiveMessageId; + + return ( +
+ + {footer} +
+ ); +} + export const TimelineMessageList = React.memo(function TimelineMessageList({ agentPubkeys, channelId, @@ -137,53 +344,31 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ // comparisons hold across unrelated timeline re-renders (typing // indicators, presence updates) — a fresh context object per render would // defeat the memo and re-render every video message on every pass. - const videoReviewContextById = React.useMemo(() => { - const contexts = new Map(); - for (const message of messages) { - if (!hasVideoAttachment(message)) continue; - const comments = reviewCommentsByRootId.get(message.id) ?? []; - contexts.set(message.id, { + const videoReviewContextById = React.useMemo( + () => + buildVideoReviewContextById({ channelId, channelName, channelType, - comments, - disabled: !onSendVideoReviewComment || message.pending, - isSending: isSendingVideoReviewComment, - onSendComment: onSendVideoReviewComment - ? (content, mentionPubkeys, mediaTags, parentEventId) => - onSendVideoReviewComment( - message, - content, - mentionPubkeys, - mediaTags, - parentEventId, - ) - : undefined, - onToggleCommentReaction: onToggleReaction - ? (comment, emoji, remove) => { - const sourceComment = comments.find( - (candidate) => candidate.id === comment.id, - ); - if (!sourceComment) return Promise.resolve(); - return onToggleReaction(sourceComment, emoji, remove); - } - : undefined, + isSendingVideoReviewComment, + messages, + onSendVideoReviewComment, + onToggleReaction, profiles, - rootEventId: message.id, - }); - } - return contexts; - }, [ - channelId, - channelName, - channelType, - isSendingVideoReviewComment, - messages, - onSendVideoReviewComment, - onToggleReaction, - profiles, - reviewCommentsByRootId, - ]); + reviewCommentsByRootId, + }), + [ + channelId, + channelName, + channelType, + isSendingVideoReviewComment, + messages, + onSendVideoReviewComment, + onToggleReaction, + profiles, + reviewCommentsByRootId, + ], + ); const dayGroups: Array<{ key: string; label: string; @@ -192,7 +377,8 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ let currentDayGroup: (typeof dayGroups)[number] | null = null; for (let i = 0; i < entries.length; i++) { - const { message, summary } = entries[i]; + const entry = entries[i]; + const { message } = entry; const prev = i > 0 ? entries[i - 1]?.message : null; const messageRenderKey = message.renderKey ?? message.id; @@ -205,110 +391,31 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ dayGroups.push(currentDayGroup); } - if (message.kind === KIND_SYSTEM_MESSAGE) { - const footer = messageFooters?.[message.id] ?? null; - currentDayGroup?.elements.push( -
- - {footer} -
, - ); - } else if (summary && onReply) { - const footer = messageFooters?.[message.id] ?? null; - const isHighlighted = message.id === highlightedMessageId; - currentDayGroup?.elements.push( -
- followThreadById(message.id) : undefined - } - onMarkUnread={onMarkUnread} - onToggleReaction={onToggleReaction} - onReply={onReply} - onUnfollowThread={ - unfollowThreadById - ? () => unfollowThreadById(message.id) - : undefined - } - profiles={profiles} - videoReviewContext={videoReviewContextById.get(message.id)} - /> - - {footer} -
, - ); - } else { - const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false; - const isSearchActive = message.id === searchActiveMessageId; - const footer = messageFooters?.[message.id] ?? null; - - currentDayGroup?.elements.push( -
- - {footer} -
, - ); - } + currentDayGroup?.elements.push( + renderTimelineMessageEntry({ + agentPubkeys, + channelId, + currentPubkey, + entry, + followThreadById, + footer: messageFooters?.[message.id] ?? null, + highlightedMessageId, + isFollowingThreadById, + messageKey: messageRenderKey, + onDelete, + onEdit, + onMarkUnread, + onReply, + onToggleReaction, + personaLookup, + profiles, + searchActiveMessageId, + searchMatchingMessageIds, + searchQuery, + unfollowThreadById, + videoReviewContext: videoReviewContextById.get(message.id), + }), + ); } return dayGroups.map((group) => ( diff --git a/desktop/src/features/messages/ui/VirtualizedTimelineMessageList.tsx b/desktop/src/features/messages/ui/VirtualizedTimelineMessageList.tsx new file mode 100644 index 000000000..83972c091 --- /dev/null +++ b/desktop/src/features/messages/ui/VirtualizedTimelineMessageList.tsx @@ -0,0 +1,266 @@ +import * as React from "react"; +import { + Virtuoso, + type FollowOutput, + type VirtuosoHandle, +} from "react-virtuoso"; + +import { + formatDayHeading, + isSameDay, +} from "@/features/messages/lib/dateFormatters"; +import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel"; +import type { TimelineMessage } from "@/features/messages/types"; +import { DayDivider } from "./DayDivider"; +import { + buildReviewCommentsByRootId, + buildVideoReviewContextById, + renderTimelineMessageEntry, + type TimelineMessageListProps, +} from "./TimelineMessageList"; + +type TimelineEntry = + | { + key: string; + type: "day"; + label: string; + } + | { + key: string; + type: "message"; + message: TimelineMessage; + summary: ReturnType[number]["summary"]; + }; + +type VirtualizedTimelineMessageListProps = TimelineMessageListProps & { + atBottomStateChange?: (atBottom: boolean) => void; + bottomFooterClassName?: string; + followOutput?: FollowOutput; + hasOlderMessages: boolean; + isFetchingOlder: boolean; + onStartReached?: () => void; + scrollerRef?: (element: HTMLDivElement | null) => void; + topHeader?: React.ReactNode; + virtuosoRef?: React.RefObject; +}; + +const FIRST_ITEM_INDEX_BASE = 1_000_000; + +const TimelineList = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> +>(function TimelineList({ children, style, ...props }, ref) { + return ( +
+ {children} +
+ ); +}); +TimelineList.displayName = "VirtualizedTimelineList"; + +function buildTimelineItems(messages: TimelineMessage[]): TimelineEntry[] { + const entries = buildMainTimelineEntries(messages); + const items: TimelineEntry[] = []; + + for (let index = 0; index < entries.length; index += 1) { + const { message, summary } = entries[index]; + const prev = index > 0 ? entries[index - 1]?.message : null; + + if (!prev || !isSameDay(prev.createdAt, message.createdAt)) { + items.push({ + key: `day-${message.createdAt}`, + label: formatDayHeading(message.createdAt), + type: "day", + }); + } + + items.push({ + key: message.renderKey ?? message.id, + message, + summary, + type: "message", + }); + } + + return items; +} + +export const VirtualizedTimelineMessageList = React.memo( + function VirtualizedTimelineMessageList({ + agentPubkeys, + atBottomStateChange, + bottomFooterClassName, + channelId, + channelName, + channelType, + currentPubkey, + followOutput = false, + followThreadById, + hasOlderMessages, + highlightedMessageId = null, + isFetchingOlder, + isFollowingThreadById, + messageFooters, + messages, + onDelete, + onEdit, + onMarkUnread, + onReply, + onStartReached, + isSendingVideoReviewComment = false, + onSendVideoReviewComment, + onToggleReaction, + personaLookup, + profiles, + scrollerRef, + searchActiveMessageId = null, + searchMatchingMessageIds, + searchQuery, + topHeader, + unfollowThreadById, + virtuosoRef, + }: VirtualizedTimelineMessageListProps) { + const items = React.useMemo(() => buildTimelineItems(messages), [messages]); + const reviewCommentsByRootId = React.useMemo( + () => buildReviewCommentsByRootId(messages), + [messages], + ); + const videoReviewContextById = React.useMemo( + () => + buildVideoReviewContextById({ + channelId, + channelName, + channelType, + isSendingVideoReviewComment, + messages, + onSendVideoReviewComment, + onToggleReaction, + profiles, + reviewCommentsByRootId, + }), + [ + channelId, + channelName, + channelType, + isSendingVideoReviewComment, + messages, + onSendVideoReviewComment, + onToggleReaction, + profiles, + reviewCommentsByRootId, + ], + ); + + const firstItemIndexStateRef = React.useRef({ + anchorIndex: -1, + anchorKey: null as string | null, + firstItemIndex: FIRST_ITEM_INDEX_BASE, + items: [] as readonly TimelineEntry[], + }); + + const firstItemIndex = React.useMemo(() => { + const state = firstItemIndexStateRef.current; + const previousItems = state.items; + + if (items.length === 0) { + state.anchorIndex = -1; + state.anchorKey = null; + state.firstItemIndex = FIRST_ITEM_INDEX_BASE; + state.items = items; + return state.firstItemIndex; + } + + const anchorEntryIndex = items.findIndex( + (item) => item.type === "message", + ); + const anchorKey = + anchorEntryIndex >= 0 ? (items[anchorEntryIndex]?.key ?? null) : null; + + if (previousItems !== items) { + if (state.anchorKey) { + const nextAnchorIndex = items.findIndex( + (item) => item.key === state.anchorKey, + ); + if (nextAnchorIndex >= 0 && state.anchorIndex >= 0) { + state.firstItemIndex -= nextAnchorIndex - state.anchorIndex; + } else { + state.firstItemIndex = FIRST_ITEM_INDEX_BASE; + } + } + + state.anchorIndex = anchorEntryIndex; + state.anchorKey = anchorKey; + state.items = items; + } + + return state.firstItemIndex; + }, [items]); + + const components = React.useMemo( + () => ({ + Footer: () =>
, + Header: topHeader + ? () =>
{topHeader}
+ : undefined, + List: TimelineList, + }), + [bottomFooterClassName, topHeader], + ); + + return ( + + atBottomStateChange={atBottomStateChange} + atBottomThreshold={32} + className="h-full w-full" + components={components} + computeItemKey={(_, item) => item.key} + data={items} + data-scroll-restoration-id="virtualized-message-timeline" + data-testid="message-timeline" + defaultItemHeight={96} + firstItemIndex={firstItemIndex} + followOutput={followOutput} + initialTopMostItemIndex={{ align: "end", index: items.length - 1 }} + increaseViewportBy={{ bottom: 600, top: 900 }} + itemContent={(_index, item) => { + if (item.type === "day") { + return ; + } + + return renderTimelineMessageEntry({ + agentPubkeys, + channelId, + currentPubkey, + entry: item, + followThreadById, + footer: messageFooters?.[item.message.id] ?? null, + highlightedMessageId, + isFollowingThreadById, + onDelete, + onEdit, + onMarkUnread, + onReply, + onToggleReaction, + personaLookup, + profiles, + searchActiveMessageId, + searchMatchingMessageIds, + searchQuery, + unfollowThreadById, + videoReviewContext: videoReviewContextById.get(item.message.id), + }); + }} + overscan={{ main: 800, reverse: 800 }} + ref={virtuosoRef} + scrollerRef={(element) => { + scrollerRef?.(element instanceof HTMLDivElement ? element : null); + }} + startReached={() => { + if (hasOlderMessages && !isFetchingOlder) { + onStartReached?.(); + } + }} + /> + ); + }, +); diff --git a/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts b/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts deleted file mode 100644 index 73efbd3fc..000000000 --- a/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts +++ /dev/null @@ -1,92 +0,0 @@ -import * as React from "react"; - -type UseLoadOlderOnScrollOptions = { - fetchOlder?: () => Promise; - hasOlderMessages: boolean; - isLoading: boolean; - restoreScrollPosition: (scrollTop: number) => void; - scrollContainerRef: React.RefObject; - sentinelRef: React.RefObject; -}; - -/** - * 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, - ]); -} diff --git a/desktop/src/features/messages/ui/useTimelineScrollManager.ts b/desktop/src/features/messages/ui/useTimelineScrollManager.ts index da2fd7ff6..445a973c8 100644 --- a/desktop/src/features/messages/ui/useTimelineScrollManager.ts +++ b/desktop/src/features/messages/ui/useTimelineScrollManager.ts @@ -33,7 +33,6 @@ export function useTimelineScrollManager({ const isProgrammaticBottomScrollRef = React.useRef(false); const previousTimelineHeightRef = React.useRef(null); const previousScrollTopRef = React.useRef(0); - const lockedScrollTopRef = React.useRef(null); const previousLastMessageKeyRef = React.useRef(undefined); const previousMessageCountRef = React.useRef(0); const handledTargetMessageIdRef = React.useRef(null); @@ -50,7 +49,6 @@ export function useTimelineScrollManager({ isProgrammaticBottomScrollRef.current = false; previousTimelineHeightRef.current = null; previousScrollTopRef.current = 0; - lockedScrollTopRef.current = null; previousLastMessageKeyRef.current = undefined; previousMessageCountRef.current = 0; handledTargetMessageIdRef.current = null; @@ -108,7 +106,7 @@ export function useTimelineScrollManager({ return; } - const scrollTop = lockedScrollTopRef.current ?? timeline.scrollTop; + const scrollTop = timeline.scrollTop; const atBottom = isNearBottom(timeline); const movedAwayFromBottom = scrollTop + 1 < previousScrollTopRef.current; @@ -137,38 +135,6 @@ export function useTimelineScrollManager({ 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) => { @@ -192,7 +158,6 @@ export function useTimelineScrollManager({ }; alignToBottom(behavior); - lockedScrollTopRef.current = null; previousScrollTopRef.current = timeline.scrollTop; pinToBottom({ clearNewMessageCount: true }); @@ -223,6 +188,11 @@ export function useTimelineScrollManager({ [pinToBottom, syncScrollState], ); + // Keep the timeline pinned only when its viewport size changes while the user + // is already at the bottom. When reading history, do not restore an old + // absolute scrollTop: prepends are handled by `useLoadOlderOnScroll` with a + // message anchor, and an absolute multi-frame restore can overwrite that + // correction on WebKit. // biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes React.useEffect(() => { const timeline = timelineRef.current; @@ -232,7 +202,6 @@ export function useTimelineScrollManager({ } previousTimelineHeightRef.current = timeline.clientHeight; - previousScrollTopRef.current = timeline.scrollTop; const observer = new ResizeObserver(([entry]) => { const previousTimelineHeight = previousTimelineHeightRef.current; @@ -248,10 +217,7 @@ export function useTimelineScrollManager({ if (shouldStickToBottomRef.current || isAtBottomRef.current) { scrollToBottom("auto"); - return; } - - restoreScrollPosition(previousScrollTopRef.current); }); observer.observe(timeline); @@ -259,7 +225,7 @@ export function useTimelineScrollManager({ return () => { observer.disconnect(); }; - }, [restoreScrollPosition, scrollToBottom]); + }, [scrollToBottom]); React.useEffect(() => { const content = contentRef.current; @@ -409,7 +375,6 @@ export function useTimelineScrollManager({ highlightedMessageId, isAtBottom, newMessageCount, - restoreScrollPosition, scrollToBottom, syncScrollState, }; diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index b21b45e73..1396cf0ae 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -121,16 +121,28 @@ function useStableArray(arr: T[]): T[] { return ref.current; } -function aspectRatioFromDim(dim?: string): number | undefined { +function dimensionsFromDim( + dim?: string, +): { width: number; height: number } | undefined { if (!dim) return undefined; const match = dim.match(/^(\d+)x(\d+)$/i); if (!match) return undefined; const width = Number(match[1]); const height = Number(match[2]); - if (!Number.isFinite(width) || !Number.isFinite(height) || height <= 0) { + if ( + !Number.isFinite(width) || + !Number.isFinite(height) || + width <= 0 || + height <= 0 + ) { return undefined; } - return width / height; + return { width, height }; +} + +function aspectRatioFromDim(dim?: string): number | undefined { + const dimensions = dimensionsFromDim(dim); + return dimensions ? dimensions.width / dimensions.height : undefined; } /** @@ -231,10 +243,12 @@ type MarkdownVariant = "default" | "compact" | "tight"; */ function ImageBlock({ alt, + dimensions, resolvedSrc, src, }: { alt: string | undefined; + dimensions: { width: number; height: number } | undefined; resolvedSrc: string | undefined; src: string | undefined; }) { @@ -290,7 +304,9 @@ function ImageBlock({ {alt} setLightboxOpen(true)} onContextMenuCapture={handleContextMenu} /> @@ -899,7 +915,14 @@ function createMarkdownComponents( } return ( - + ); }, diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index b8afdef20..4cfb69649 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -74,6 +74,7 @@ type E2eConfig = { profileReadError?: string; profileUpdateError?: string; searchProfiles?: MockSearchProfileSeed[]; + historyDelayMs?: number; updateChannelDelayMs?: number; stallWebsocketSends?: boolean; userSearchDelayMs?: number; @@ -458,6 +459,8 @@ type MockFilter = { "#h"?: string[]; authors?: string[]; kinds?: number[]; + limit?: number; + until?: number; }; type MockSocket = { @@ -585,6 +588,14 @@ declare global { channelName: string; pubkey?: string; }) => RelayEvent; + __BUZZ_E2E_PREPEND_MOCK_HISTORY__?: (input: { + channelName: string; + count: number; + startIndex?: number; + lineCount?: number; + createdAtStart?: number; + emit?: boolean; + }) => RelayEvent[]; __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( command: string, payload?: Record, @@ -2192,12 +2203,87 @@ function getMockMessageStore(channelId: string): RelayEvent[] { return seeded; } -function emitMockHistory(socket: MockSocket, subId: string, channelId: string) { - const events = getMockMessageStore(channelId); - for (const event of events) { - sendWsText(socket.handler, ["EVENT", subId, event]); +function prependMockHistory(input: { + channelName: string; + count: number; + startIndex?: number; + lineCount?: number; + createdAtStart?: number; + emit?: boolean; +}) { + const channel = mockChannels.find( + (candidate) => candidate.name === input.channelName, + ); + if (!channel) { + throw new Error(`Unknown mock channel: ${input.channelName}`); + } + + const store = getMockMessageStore(channel.id); + const earliestCreatedAt = store.reduce( + (earliest, event) => Math.min(earliest, event.created_at), + Math.floor(Date.now() / 1000), + ); + const createdAtStart = + input.createdAtStart ?? earliestCreatedAt - input.count - 1; + const startIndex = input.startIndex ?? 0; + const lineCount = input.lineCount ?? 1; + + const events = Array.from({ length: input.count }, (_, offset) => { + const index = startIndex + offset; + const body = Array.from( + { length: lineCount }, + (_unused, lineIndex) => `mock older ${index} line ${lineIndex + 1}`, + ).join("\n"); + + return createMockEvent( + 9, + body, + [["h", channel.id]], + ALICE_PUBKEY, + createdAtStart + offset, + `mock-older-${channel.name}-${index}`.replace(/[^a-zA-Z0-9]/g, ""), + ); + }); + + store.unshift(...events); + store.sort((left, right) => left.created_at - right.created_at); + + if (input.emit) { + for (const event of events) { + emitMockLiveEvent(channel.id, event); + } + } + + return events; +} + +function emitMockHistory( + socket: MockSocket, + subId: string, + channelId: string, + filter: MockFilter = {}, +) { + const events = getMockMessageStore(channelId) + .filter((event) => + filter.until !== undefined ? event.created_at <= filter.until : true, + ) + .sort((left, right) => right.created_at - left.created_at) + .slice(0, filter.limit ?? 50); + + const emit = () => { + for (const event of events) { + sendWsText(socket.handler, ["EVENT", subId, event]); + } + sendWsText(socket.handler, ["EOSE", subId]); + }; + + const delayMs = getConfig()?.mock?.historyDelayMs ?? 0; + if (delayMs > 0 && subId.startsWith("history-")) { + window.setTimeout(emit, delayMs); + return; } - sendWsText(socket.handler, ["EOSE", subId]); + + emit(); } function emitMockLiveEvent(channelId: string, event: RelayEvent) { @@ -5683,7 +5769,7 @@ function sendToMockSocket(args: { return; } - emitMockHistory(socket, subId, channelId); + emitMockHistory(socket, subId, channelId, filter); return; } @@ -5801,6 +5887,7 @@ export function maybeInstallE2eTauriMocks() { return; } + mockMessages.clear(); resetMockRelayMembers(config); resetMockManagedAgents(config); resetMockPersonas(config); @@ -5842,6 +5929,7 @@ export function maybeInstallE2eTauriMocks() { extraTags, ); }; + window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ = prependMockHistory; window.__BUZZ_E2E_EMIT_MOCK_TYPING__ = ({ channelName, pubkey }) => { const channel = mockChannels.find( (candidate) => candidate.name === channelName, diff --git a/desktop/tests/e2e/scroll-history.spec.ts b/desktop/tests/e2e/scroll-history.spec.ts new file mode 100644 index 000000000..4665ca5c5 --- /dev/null +++ b/desktop/tests/e2e/scroll-history.spec.ts @@ -0,0 +1,218 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +async function getTimelineMetrics(page: import("@playwright/test").Page) { + return page.getByTestId("message-timeline").evaluate((element) => { + const timeline = element as HTMLDivElement; + + return { + clientHeight: timeline.clientHeight, + scrollHeight: timeline.scrollHeight, + scrollTop: timeline.scrollTop, + }; + }); +} + +async function getFirstVisibleMessage(page: import("@playwright/test").Page) { + return page.getByTestId("message-timeline").evaluate((element) => { + const timeline = element as HTMLDivElement; + const timelineRect = timeline.getBoundingClientRect(); + const messages = Array.from( + timeline.querySelectorAll("[data-message-id]"), + ); + + for (const message of messages) { + const rect = message.getBoundingClientRect(); + if (rect.bottom <= timelineRect.top || rect.top >= timelineRect.bottom) { + continue; + } + + return { + id: message.dataset.messageId ?? "", + text: message.textContent?.replace(/\s+/g, " ").slice(0, 80) ?? "", + top: rect.top - timelineRect.top, + }; + } + + return null; + }); +} + +async function getMessagePosition( + page: import("@playwright/test").Page, + messageId: string, +) { + return page.getByTestId("message-timeline").evaluate((element, id) => { + const timeline = element as HTMLDivElement; + const message = timeline.querySelector( + `[data-message-id="${CSS.escape(id)}"]`, + ); + if (!message) { + return null; + } + + return { + id, + top: + message.getBoundingClientRect().top - + timeline.getBoundingClientRect().top, + }; + }, messageId); +} + +test("preserves user scroll while older channel history loads", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + await page.waitForFunction( + () => + typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && + typeof window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ === "function", + ); + + await page.evaluate(() => { + for (let index = 0; index < 40; index += 1) { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: `visible current ${index}\nsecond line ${index}`, + }); + } + window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__?.({ + channelName: "general", + count: 250, + lineCount: 3, + }); + }); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + const timeline = page.getByTestId("message-timeline"); + await expect(timeline).toContainText("visible current 39"); + + // Initial load should receive enough history to make the page scrollable. + // Delay only the next history request, so the test isolates pagination while + // the user is actively scrolling. + await page.evaluate(() => { + window.__BUZZ_E2E__ = { + ...window.__BUZZ_E2E__, + mock: { + ...window.__BUZZ_E2E__?.mock, + historyDelayMs: 2_000, + }, + }; + }); + + await page.waitForFunction(() => { + const element = document.querySelector( + '[data-testid="message-timeline"]', + ) as HTMLDivElement | null; + return element && element.scrollHeight > element.clientHeight + 1000; + }); + + // Scroll with real wheel input instead of assigning `scrollTop` directly. + // Virtualized lists own their range model and may correct arbitrary absolute + // scroll offsets, but user-wheel scrolling is the behavior we must preserve. + const box = await timeline.boundingBox(); + expect(box).not.toBeNull(); + await page.mouse.move( + (box?.x ?? 0) + (box?.width ?? 0) / 2, + (box?.y ?? 0) + (box?.height ?? 0) / 2, + ); + + for (let index = 0; index < 80; index += 1) { + await page.mouse.wheel(0, -1200); + await page.waitForTimeout(20); + const metrics = await getTimelineMetrics(page); + if (metrics.scrollTop <= 900) { + break; + } + } + + const beforeFetch = await getTimelineMetrics(page); + expect(beforeFetch.scrollTop).toBeLessThanOrEqual(900); + + await page.mouse.wheel(0, 160); + await page.waitForTimeout(50); + const anchorDuringFetch = await getFirstVisibleMessage(page); + expect(anchorDuringFetch).not.toBeNull(); + + await expect + .poll( + async () => { + const [anchor, metrics] = await Promise.all([ + getMessagePosition(page, anchorDuringFetch?.id ?? ""), + getTimelineMetrics(page), + ]); + if (metrics.scrollHeight <= beforeFetch.scrollHeight + 1000) { + return Number.POSITIVE_INFINITY; + } + return anchor + ? Math.abs(anchor.top - (anchorDuringFetch?.top ?? 0)) + : Number.POSITIVE_INFINITY; + }, + { + timeout: 4_000, + }, + ) + .toBeLessThanOrEqual(2); +}); + +const REAL_BUZZ_BUGS_IMAGE_SHA = + "ff2862080bac3d009f97cad4bb94e6efec328eaaee058a405e854acd49fc1483"; +const REAL_BUZZ_BUGS_IMAGE_URL = `https://sprout-oss.stage.blox.sqprod.co/media/${REAL_BUZZ_BUGS_IMAGE_SHA}.png`; +const REAL_BUZZ_BUGS_IMAGE_TAG = [ + "imeta", + `url ${REAL_BUZZ_BUGS_IMAGE_URL}`, + "m image/png", + `x ${REAL_BUZZ_BUGS_IMAGE_SHA}`, + "size 26257", + "dim 951x244", + "filename image.png", +] as string[]; + +test("reserves real buzz-bugs imeta image height before image loads", async ({ + page, +}) => { + await page.route("**/media/**", () => new Promise(() => {})); + await installMockBridge(page); + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ); + + await page.evaluate( + ({ content, extraTags }) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content, + extraTags, + }); + }, + { + content: `this setting gets reverted on every update\n![image](${REAL_BUZZ_BUGS_IMAGE_URL})`, + extraTags: [REAL_BUZZ_BUGS_IMAGE_TAG], + }, + ); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const image = page.getByAltText("image").last(); + const rect = await image.evaluate((element) => { + const img = element as HTMLImageElement; + const box = img.getBoundingClientRect(); + return { + attrHeight: img.getAttribute("height"), + attrWidth: img.getAttribute("width"), + height: box.height, + offsetHeight: img.offsetHeight, + offsetWidth: img.offsetWidth, + width: box.width, + }; + }); + expect(rect.attrWidth).toBe("951"); + expect(rect.attrHeight).toBe("244"); + expect(rect.offsetHeight).toBeGreaterThan(80); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index a4d4489dc..daaddc8ff 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -99,6 +99,7 @@ type MockBridgeOptions = { profileReadError?: string; profileUpdateError?: string; searchProfiles?: MockSearchProfileSeed[]; + historyDelayMs?: number; updateChannelDelayMs?: number; stallWebsocketSends?: boolean; userSearchDelayMs?: number; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0cdeff65..49876452f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,6 +150,9 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.15)(react@19.2.6) + react-virtuoso: + specifier: ^4.18.7 + version: 4.18.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) remark-breaks: specifier: ^4.0.0 version: 4.0.0 @@ -2746,6 +2749,12 @@ packages: '@types/react': optional: true + react-virtuoso@4.18.7: + resolution: {integrity: sha512-xNF5zDGEEIMB7cKwcen/pLig0YDf6OnfFrVgKFa7sHPf9fRem0CaLshyObbBcP88jzn0enavL39EgplgdyT21g==} + peerDependencies: + react: '>=16 || >=17 || >= 18 || >= 19' + react-dom: '>=16 || >=17 || >= 18 || >=19' + react@19.2.6: resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} engines: {node: '>=0.10.0'} @@ -5582,6 +5591,11 @@ snapshots: optionalDependencies: '@types/react': 19.2.15 + react-virtuoso@4.18.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react@19.2.6: {} readable-stream@4.7.0: