Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ const defaultProps = {
debouncedClearNewMessagesOnScroll: jest.fn(),
handleDateScroll: jest.fn(),
debouncedMessageRead: jest.fn(),
setKeepAtBottom: jest.fn(),
};

describe('MessageList scroll position', () => {
Expand Down
21 changes: 20 additions & 1 deletion apps/meteor/client/views/room/MessageList/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ type MessageListProps = {
handleDateScroll: (topMessage: IMessage | undefined, offset: number) => void;
setShouldJumpToBottom: Dispatch<SetStateAction<boolean>>;
debouncedMessageRead: () => void;
setKeepAtBottom: (keepAtBottom: () => void) => void;
};

export const MessageList = function MessageList({
Expand All @@ -66,6 +67,7 @@ export const MessageList = function MessageList({
debouncedClearNewMessagesOnScroll,
handleDateScroll,
debouncedMessageRead,
setKeepAtBottom,
}: MessageListProps) {
// Prepend ref needed for adjusting the message list shift
// https://inokawa.github.io/virtua/?path=/story/advanced-chat--default
Expand All @@ -79,6 +81,18 @@ export const MessageList = function MessageList({

const messages = useMessages({ rid });

const messagesLength = canPreview ? messages.length + 1 : messages.length;

useEffect(() => {
setKeepAtBottom(() => {
if (virtualizerRef.current) {
virtualizerRef.current.scrollToIndex(messagesLength, {
align: 'end',
});
}
});
}, [messagesLength, setKeepAtBottom]);
Comment thread
gabriellsh marked this conversation as resolved.

const keepMountedMessages = useKeepMountedMessages(messages, canPreview);

useTryToJumpToMessage({ rid, virtualizerRef, setIsJumpingToMessage, messages });
Expand All @@ -98,6 +112,11 @@ export const MessageList = function MessageList({
const scrollSize = virtualizerRef.current?.scrollSize ?? 0;
const viewportSize = virtualizerRef.current?.viewportSize ?? 0;

if (hasMoreNextMessages) {
Comment thread
gabriellsh marked this conversation as resolved.
isAtBottom.current = false;
return;
}

if (scrollSize >= viewportSize) {
isAtBottom.current = true;
}
Expand All @@ -107,7 +126,7 @@ export const MessageList = function MessageList({
setShouldJumpToBottom(false);
}
},
[isAtBottom, setShouldJumpToBottom, shouldJumpToBottom],
[isAtBottom, setShouldJumpToBottom, shouldJumpToBottom, hasMoreNextMessages],
);

const isRoomInitialized = useRef<boolean>(false);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useSafeRefCallback } from '@rocket.chat/fuselage-hooks';
import type { MutableRefObject } from 'react';
import { useCallback, useRef } from 'react';

// This hook is responsible for keeping the message list at the bottom despite any size changes to the container.
// Some examples of when this is needed are:
// - When the user is at the bottom and a new message arrives, the message list will grow and we want to keep it at the bottom. (This one is already handled in another place, but this hook also does this job)
// - When the user is at the bottom and the composer grows (e.g. when typing a long message), we want to keep it at the bottom.
// - When the user is at the bottom and the window is resized, the elements might reflow.
// - When the user is at the bottom and a thread opens, the horizontal size of the container will shrink, and the elements might reflow.
// - When the user is at the bottom and a message is reacted to, the message will grow and shift the list.
// - When the user is at the bottom and a video is loading, after loading the video element can change sizes and shift the list
export const useKeepAtBottom = (isAtBottom: MutableRefObject<boolean | null>) => {
const handleRef = useRef<(() => void) | null>(null);
const keepAtBottomRef = useSafeRefCallback(
useCallback(
(node: HTMLDivElement) => {
const listWrapper = node.firstChild;
const observer = new ResizeObserver(() => {
if (isAtBottom.current) {
if (handleRef.current) {
handleRef.current();
}
}
});

observer.observe(node);
if (listWrapper instanceof HTMLElement) {
observer.observe(listWrapper);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return () => {
observer.disconnect();
};
},
[isAtBottom],
),
);

const setKeepAtBottom = useCallback((handle: () => void | null) => {
handleRef.current = handle;
}, []);

return { keepAtBottomRef, setKeepAtBottom };
};
6 changes: 5 additions & 1 deletion apps/meteor/client/views/room/body/RoomBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { useGetMore } from './hooks/useGetMore';
import { useHasNewMessages } from './hooks/useHasNewMessages';
import { useSelectAllAndScrollToTop } from './hooks/useSelectAllAndScrollToTop';
import { useHandleUnread } from './hooks/useUnreadMessages';
import { useKeepAtBottom } from '../MessageList/hooks/useKeepAtBottom';
import useTryToJumpToThreadMessage from '../MessageList/hooks/useTryToJumpToThreadMessage';

const RoomBody = (): ReactElement => {
Expand Down Expand Up @@ -109,7 +110,9 @@ const RoomBody = (): ReactElement => {
debouncedClearNewMessagesOnScroll,
} = useHasNewMessages(room._id, user?._id, setShouldJumpToBottom, isAtBottom);

const innerRef = useMergedRefsV2(getMoreInnerRef, selectAndScrollRef, messageListRef);
const { keepAtBottomRef, setKeepAtBottom } = useKeepAtBottom(isAtBottom);

const innerRef = useMergedRefsV2(getMoreInnerRef, selectAndScrollRef, messageListRef, keepAtBottomRef);

const handleNavigateToPreviousMessage = useCallback((): void => {
chat.messageEditing.toPreviousMessage();
Expand Down Expand Up @@ -222,6 +225,7 @@ const RoomBody = (): ReactElement => {
debouncedClearNewMessagesOnScroll={debouncedClearNewMessagesOnScroll}
handleDateScroll={handleDateScroll}
debouncedMessageRead={debouncedMessageRead}
setKeepAtBottom={setKeepAtBottom}
/>
</CustomVirtuaScrollbars>
</MessageListErrorBoundary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import type { VirtualizerHandle } from 'virtua';
import { VList } from 'virtua';

import { ThreadMessageItem } from './ThreadMessageItem';
import { useMergedRefsV2 } from '../../../../../hooks/useMergedRefsV2';
import { setMessageJumpQueryStringParameter } from '../../../../../lib/utils/setMessageJumpQueryStringParameter';
import { BubbleDate } from '../../../BubbleDate';
import { useKeepAtBottom } from '../../../MessageList/hooks/useKeepAtBottom';
import { useKeepMountedMessages } from '../../../MessageList/hooks/useKeepMountedMessages';
import { isMessageNewDay } from '../../../MessageList/lib/isMessageNewDay';
import MessageListProvider from '../../../MessageList/providers/MessageListProvider';
Expand Down Expand Up @@ -75,6 +77,20 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot
const virtualizerRef = useRef<VirtualizerHandle | null>(null);
const isAtBottom = useRef<boolean | null>(null);

const { keepAtBottomRef, setKeepAtBottom } = useKeepAtBottom(isAtBottom);
const messagesLength = messages.length;
useEffect(() => {
setKeepAtBottom(() => {
if (virtualizerRef.current) {
virtualizerRef.current.scrollToIndex(messagesLength + 1, {
align: 'end',
});
}
});
}, [messagesLength, setKeepAtBottom]);

const mergedRefs = useMergedRefsV2(messageListRef, keepAtBottomRef);

const lastScrollSizeRef = useRef(0);

const items = loading ? [] : [mainMessage, ...messages];
Expand Down Expand Up @@ -180,7 +196,7 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot
return (
<div className={['thread-list js-scroll-thread', hideUsernames && 'hide-usernames'].filter(isTruthy).join(' ')}>
<BubbleDate ref={bubbleRef} {...bubbleDate} />
<CustomVirtuaScrollbars ref={messageListRef}>
<CustomVirtuaScrollbars ref={mergedRefs}>
<MessageListProvider>
<VList
ref={virtualizerRef}
Expand All @@ -189,9 +205,14 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot
aria-busy={loading}
role='list'
keepMounted={keepMountedMessages}
onScroll={(offset: number) => {
onScroll={(offset) => {
const handle = virtualizerRef.current;
if (!handle) return;

// Copied from messageList, I'm unsure why this is necessary, but it seems to be needed to properly set the isAtBottom state
Comment thread
alfredodelfabro marked this conversation as resolved.
if (handle.scrollSize >= handle.viewportSize) {
isAtBottom.current = true;
}
isAtBottom.current = offset - handle.scrollSize + handle.viewportSize >= -20;

const topMessage = items[handle.findItemIndex(handle.scrollOffset)];
Expand Down
Loading