diff --git a/apps/meteor/client/views/room/MessageList/MessageList.spec.tsx b/apps/meteor/client/views/room/MessageList/MessageList.spec.tsx index dd1fcc06390ed..a26191835e6da 100644 --- a/apps/meteor/client/views/room/MessageList/MessageList.spec.tsx +++ b/apps/meteor/client/views/room/MessageList/MessageList.spec.tsx @@ -117,6 +117,7 @@ const defaultProps = { debouncedClearNewMessagesOnScroll: jest.fn(), handleDateScroll: jest.fn(), debouncedMessageRead: jest.fn(), + setKeepAtBottom: jest.fn(), }; describe('MessageList scroll position', () => { diff --git a/apps/meteor/client/views/room/MessageList/MessageList.tsx b/apps/meteor/client/views/room/MessageList/MessageList.tsx index ad6b68ff9ea00..5b45eb944d722 100644 --- a/apps/meteor/client/views/room/MessageList/MessageList.tsx +++ b/apps/meteor/client/views/room/MessageList/MessageList.tsx @@ -45,6 +45,7 @@ type MessageListProps = { handleDateScroll: (topMessage: IMessage | undefined, offset: number) => void; setShouldJumpToBottom: Dispatch>; debouncedMessageRead: () => void; + setKeepAtBottom: (keepAtBottom: () => void) => void; }; export const MessageList = function MessageList({ @@ -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 @@ -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]); + const keepMountedMessages = useKeepMountedMessages(messages, canPreview); useTryToJumpToMessage({ rid, virtualizerRef, setIsJumpingToMessage, messages }); @@ -98,6 +112,11 @@ export const MessageList = function MessageList({ const scrollSize = virtualizerRef.current?.scrollSize ?? 0; const viewportSize = virtualizerRef.current?.viewportSize ?? 0; + if (hasMoreNextMessages) { + isAtBottom.current = false; + return; + } + if (scrollSize >= viewportSize) { isAtBottom.current = true; } @@ -107,7 +126,7 @@ export const MessageList = function MessageList({ setShouldJumpToBottom(false); } }, - [isAtBottom, setShouldJumpToBottom, shouldJumpToBottom], + [isAtBottom, setShouldJumpToBottom, shouldJumpToBottom, hasMoreNextMessages], ); const isRoomInitialized = useRef(false); diff --git a/apps/meteor/client/views/room/MessageList/hooks/useKeepAtBottom.ts b/apps/meteor/client/views/room/MessageList/hooks/useKeepAtBottom.ts new file mode 100644 index 0000000000000..24bb2aa5499f4 --- /dev/null +++ b/apps/meteor/client/views/room/MessageList/hooks/useKeepAtBottom.ts @@ -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) => { + 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); + } + + return () => { + observer.disconnect(); + }; + }, + [isAtBottom], + ), + ); + + const setKeepAtBottom = useCallback((handle: () => void | null) => { + handleRef.current = handle; + }, []); + + return { keepAtBottomRef, setKeepAtBottom }; +}; diff --git a/apps/meteor/client/views/room/body/RoomBody.tsx b/apps/meteor/client/views/room/body/RoomBody.tsx index 6870e23c29c21..b7a5f6ae30258 100644 --- a/apps/meteor/client/views/room/body/RoomBody.tsx +++ b/apps/meteor/client/views/room/body/RoomBody.tsx @@ -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 => { @@ -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(); @@ -222,6 +225,7 @@ const RoomBody = (): ReactElement => { debouncedClearNewMessagesOnScroll={debouncedClearNewMessagesOnScroll} handleDateScroll={handleDateScroll} debouncedMessageRead={debouncedMessageRead} + setKeepAtBottom={setKeepAtBottom} /> diff --git a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadMessageList.tsx b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadMessageList.tsx index 1ffac7b34f25e..bbe57d95fa181 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadMessageList.tsx +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadMessageList.tsx @@ -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'; @@ -75,6 +77,20 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot const virtualizerRef = useRef(null); const isAtBottom = useRef(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]; @@ -180,7 +196,7 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot return (
- + { + 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 + if (handle.scrollSize >= handle.viewportSize) { + isAtBottom.current = true; + } isAtBottom.current = offset - handle.scrollSize + handle.viewportSize >= -20; const topMessage = items[handle.findItemIndex(handle.scrollOffset)];