diff --git a/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts b/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts index 1d901f0c80bdb..006b63f9c19b5 100644 --- a/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts +++ b/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts @@ -3,7 +3,6 @@ import { Emitter } from '@rocket.chat/emitter'; import { differenceInMilliseconds } from 'date-fns'; import { ReactiveVar } from 'meteor/reactive-var'; import { Tracker } from 'meteor/tracker'; -import type { MutableRefObject } from 'react'; import { onClientMessageReceived } from '../../../../client/lib/onClientMessageReceived'; import { getUserId } from '../../../../client/lib/user'; @@ -119,7 +118,13 @@ class RoomHistoryManagerClass extends Emitter { this.run(() => this.emit(requestId)); } - public async getMore(rid: IRoom['_id'], { limit = defaultLimit }: { limit?: number } = {}): Promise { + public async getMore( + rid: IRoom['_id'], + { + limit = defaultLimit, + scrollPositionCallback, + }: { limit?: number; scrollPositionCallback?: () => { scrollHeight: number; scrollTop: number } } = {}, + ): Promise { const room = this.getRoom(rid); if (Tracker.nonreactive(() => room.hasMore.get()) !== true) { @@ -162,12 +167,9 @@ class RoomHistoryManagerClass extends Emitter { room.oldestTs = messages[messages.length - 1].ts; } - const wrapper = await waitForElement('.messages-box .wrapper [data-overlayscrollbars-viewport]'); - - room.scroll = { - scrollHeight: wrapper.scrollHeight, - scrollTop: wrapper.scrollTop, - }; + if (scrollPositionCallback) { + room.scroll = scrollPositionCallback(); + } await upsertMessageBulk({ msgs: messages.filter((msg) => msg.t !== 'command'), @@ -199,31 +201,19 @@ class RoomHistoryManagerClass extends Emitter { } } - public restoreScroll(rid: IRoom['_id']) { + public getStoredScrollPosition(rid: IRoom['_id']) { const room = this.getRoom(rid); - const wrapper = document.querySelector('.messages-box .wrapper [data-overlayscrollbars-viewport]'); - - if (room.scroll === undefined) { - return; - } - if (!wrapper) { - return; - } - - const heightDiff = wrapper.scrollHeight - (room.scroll.scrollHeight ?? NaN); - wrapper.scrollTop = room.scroll.scrollTop + heightDiff; - room.scroll = undefined; + return room.scroll; } - public async getMoreNext(rid: IRoom['_id'], atBottomRef: MutableRefObject) { + public async getMoreNext(rid: IRoom['_id']) { const room = this.getRoom(rid); if (Tracker.nonreactive(() => room.hasMoreNext.get()) !== true) { return; } await this.queue(); - atBottomRef.current = false; room.isLoading.set(true); @@ -296,6 +286,14 @@ class RoomHistoryManagerClass extends Emitter { } public async getSurroundingMessages(message?: Pick & { ts?: Date }) { + return this.loadSurroundingMessages(message, true); + } + + public async getSurroundingChannelMessages(message?: Pick & { ts?: Date }) { + return this.loadSurroundingMessages(message, false); + } + + private async loadSurroundingMessages(message: (Pick & { ts?: Date }) | undefined, showThreadMessages: boolean) { if (!message?.rid) { return; } @@ -309,7 +307,7 @@ class RoomHistoryManagerClass extends Emitter { const room = this.getRoom(message.rid); const subscription = Subscriptions.state.find((record) => record.rid === message.rid); - const result = await callWithErrorHandling('loadSurroundingMessages', message, defaultLimit); + const result = await callWithErrorHandling('loadSurroundingMessages', message, defaultLimit, showThreadMessages); this.clear(message.rid); diff --git a/apps/meteor/client/components/message/list/MessageListContext.tsx b/apps/meteor/client/components/message/list/MessageListContext.tsx index 1f462d2539398..cb1ff6fdda813 100644 --- a/apps/meteor/client/components/message/list/MessageListContext.tsx +++ b/apps/meteor/client/components/message/list/MessageListContext.tsx @@ -32,7 +32,6 @@ export type MessageListContextValue = { }; autoTranslateLanguage?: string; showColors: boolean; - jumpToMessageParam?: string; username: string | undefined; apiEmbedEnabled: boolean; readReceipts: { @@ -90,9 +89,6 @@ export const useMessageListShowRoles = (): MessageListContextValue['showRoles'] export const useMessageListShowRealName = (): MessageListContextValue['showRealName'] => useContext(MessageListContext).showRealName; export const useMessageListShowUsername = (): MessageListContextValue['showUsername'] => useContext(MessageListContext).showUsername; export const useMessageListHighlights = (): MessageListContextValue['highlights'] => useContext(MessageListContext).highlights; -export const useMessageListJumpToMessageParam = (): MessageListContextValue['jumpToMessageParam'] => - useContext(MessageListContext).jumpToMessageParam; - export const useUserHasReacted: MessageListContextValue['useUserHasReacted'] = (message: IMessage) => useContext(MessageListContext).useUserHasReacted(message); export const useOpenEmojiPicker: MessageListContextValue['useOpenEmojiPicker'] = (...args) => diff --git a/apps/meteor/client/components/message/variants/RoomMessage.tsx b/apps/meteor/client/components/message/variants/RoomMessage.tsx index 14895b05d0929..368e00fbbf602 100644 --- a/apps/meteor/client/components/message/variants/RoomMessage.tsx +++ b/apps/meteor/client/components/message/variants/RoomMessage.tsx @@ -14,7 +14,6 @@ import { useIsSelectedMessage, useCountSelected, } from '../../../views/room/MessageList/contexts/SelectedMessagesContext'; -import { useJumpToMessage } from '../../../views/room/MessageList/hooks/useJumpToMessage'; import Emoji from '../../Emoji'; import IgnoredContent from '../IgnoredContent'; import MessageHeader from '../MessageHeader'; @@ -86,11 +85,9 @@ const RoomMessage = ({ const { enabled: readReceiptEnabled } = useMessageListReadReceipts(); useCountSelected(); - const messageRef = useJumpToMessage(message._id); return ( ['messageListRef']; + scrollContainerRef?: MutableRefObject; + isLoadingMoreMessages: boolean; + canPreview: boolean; + hasMorePreviousMessages: boolean; + hasMoreNextMessages: boolean; + user: IUser | null; + room: IRoom; + retentionPolicy: RetentionPolicy; + innerRef: Ref; + virtualizerHandle: RefObject; }; -export const MessageList = function MessageList({ rid, messageListRef }: MessageListProps) { +export const MessageList = function MessageList({ + rid, + messageListRef, + scrollContainerRef, + isLoadingMoreMessages, + canPreview, + hasMorePreviousMessages, + hasMoreNextMessages, + user, + room, + retentionPolicy, + innerRef, + virtualizerHandle, +}: MessageListProps) { const messages = useMessages({ rid }); const subscription = useRoomSubscription(); const showUserAvatar = !!useUserPreference('displayAvatars'); @@ -28,27 +48,24 @@ export const MessageList = function MessageList({ rid, messageListRef }: Message return ( - {messages.map((message, index, { [index - 1]: previous }) => { - const sequential = isMessageSequential(message, previous, messageGroupingPeriod); - const showUnreadDivider = firstUnreadMessageId === message._id; - const system = MessageTypes.isSystemMessage(message); - const visible = !isThreadMessage(message) && !system; - - return ( - - - - ); - })} + ); diff --git a/apps/meteor/client/views/room/MessageList/VirtualizedMessageList.tsx b/apps/meteor/client/views/room/MessageList/VirtualizedMessageList.tsx new file mode 100644 index 0000000000000..9f097559d3812 --- /dev/null +++ b/apps/meteor/client/views/room/MessageList/VirtualizedMessageList.tsx @@ -0,0 +1,325 @@ +import type { IRoom, IMessage, ISubscription, IUser } from '@rocket.chat/core-typings'; +import { isThreadMessage } from '@rocket.chat/core-typings'; +import { Box } from '@rocket.chat/fuselage'; +import { MessageTypes } from '@rocket.chat/message-types'; +import { VirtualScrollbars } from '@rocket.chat/ui-client'; +import { useRouter, useSearchParameter } from '@rocket.chat/ui-contexts'; +import type { ScrollToOptions, VirtualItem } from '@tanstack/react-virtual'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import type { MutableRefObject, Ref, RefObject } from 'react'; +import { useCallback, useEffect, useImperativeHandle, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { MessageListItem } from './MessageListItem'; +import { isMessageSequential } from './lib/isMessageSequential'; +import { setHighlightMessage, clearHighlightMessage } from './providers/messageHighlightSubscription'; +import { RoomHistoryManager } from '../../../../app/ui-utils/client'; +import { withThrottling } from '../../../../lib/utils/highOrderFunctions'; +import { RoomManager } from '../../../lib/RoomManager'; +import LoadingMessagesIndicator from '../body/LoadingMessagesIndicator'; +import RetentionPolicyWarning from '../body/RetentionPolicyWarning'; +import RoomForeword from '../body/RoomForeword/RoomForeword'; + +const ESTIMATE_SIZE = 84; +const OVERSCAN = 5; +const DEFAULT_MAX_RENDERED = 50; + +export type VirtualizerHandle = { + scrollToIndex: (index: number, opts?: ScrollToOptions) => void; + scrollToOffset: (offset: number, opts?: ScrollToOptions) => void; + scrollToEnd: (opts?: ScrollToOptions) => void; + getTotalSize: () => number; + isAtBottom: () => boolean; + scrollElement: HTMLElement | null; + requestScrollToEnd: () => void; + requestJumpToMessage: (messageId: string) => void; + clearRequestJumpToMessage: () => void; +}; + +type VirtualizedMessageListProps = { + rid: IRoom['_id']; + messages: IMessage[]; + scrollContainerRef?: MutableRefObject; + messageGroupingPeriod: number; + firstUnreadMessageId: string | undefined; + showUserAvatar: boolean; + subscription: ISubscription | undefined; + innerRef: Ref; + isLoadingMoreMessages: boolean; + canPreview: boolean; + hasMorePreviousMessages: boolean; + hasMoreNextMessages: boolean; + user: IUser | null; + room: IRoom; + retentionPolicy: RetentionPolicy; + virtualizerHandle: RefObject; +}; + +export function VirtualizedMessageList({ + rid, + messages, + scrollContainerRef, + messageGroupingPeriod, + firstUnreadMessageId, + showUserAvatar, + subscription, + innerRef, + isLoadingMoreMessages, + canPreview, + hasMorePreviousMessages, + hasMoreNextMessages, + user, + room, + retentionPolicy, + virtualizerHandle, +}: VirtualizedMessageListProps) { + const { t } = useTranslation(); + const router = useRouter(); + + const overscan = Math.min(OVERSCAN, Math.max(0, Math.floor(DEFAULT_MAX_RENDERED / 2) - 2)); + + const virtualizer = useVirtualizer({ + count: messages?.length ?? 0, + getScrollElement: () => scrollContainerRef?.current ?? null, + estimateSize: () => ESTIMATE_SIZE, + overscan, + getItemKey: (index: number) => messages[index]?._id ?? index, + useFlushSync: false, + }); + + const scrollToEnd = useCallback( + (opts?: ScrollToOptions) => { + virtualizer.scrollToIndex(virtualizer.options.count - 1, { align: 'end', ...opts }); + }, + [virtualizer], + ); + + const isAtBottom = useCallback(() => { + console.log( + 'isAtBottom', + virtualizer.getVirtualItems()[virtualizer.getVirtualItems().length - 1]?.index, + virtualizer.options.count - 1, + ); + return virtualizer.getVirtualItems()[virtualizer.getVirtualItems().length - 1]?.index === virtualizer.options.count - 1; + }, [virtualizer]); + + const requestScrollToEnd = useCallback(() => { + shouldScrollToEndRef.current = true; + }, []); + + const clearRequestScrollToEnd = useCallback(() => { + shouldScrollToEndRef.current = false; + }, []); + + useImperativeHandle(virtualizerHandle, () => ({ + scrollToIndex: (...args: Parameters) => virtualizer.scrollToIndex(...args), + scrollToOffset: (...args: Parameters) => virtualizer.scrollToOffset(...args), + scrollToEnd, + getTotalSize: () => virtualizer.getTotalSize(), + isAtBottom, + scrollElement: virtualizer.scrollElement, + requestScrollToEnd, + })); + + // useEffect(() => { + // if (!innerRef?.current) { + // return; + // } + // innerRef.current = virtualizer.scrollElement; + // }, [virtualizer.scrollElement, innerRef]); + + const totalSize = virtualizer.getTotalSize(); + const virtualItems = virtualizer.getVirtualItems(); + + const jumpToMessageParam = useSearchParameter('msg'); + const jumpedToMsgRef = useRef(null); + + // Guards concurrent fetches. + const isFetchingRef = useRef(false); + // Saves the first visible message _id before a prepend so we can restore the viewport afterward. + const firstVisibleIdRef = useRef(null); + // Set during the initial load so the scroll restoration effect scrolls to bottom once messages arrive. + const shouldScrollToEndRef = useRef(false); + + // TODO: Change is at bottom to call the virutalize callback instead of updating a ref all the time + // Update atBottomRef from virtualizer state — no DOM read required. + // useEffect(() => { + // if (scrollRectHeight !== null && scrollOffset !== null) { + // atBottomRef.current = scrollOffset + scrollRectHeight >= totalSize - AT_BOTTOM_THRESHOLD; + // } + // }, [atBottomRef, scrollOffset, scrollRectHeight, totalSize]); + + // Trigger loading previous messages when the first item is visible (index === 0). + useEffect(() => { + if (isLoadingMoreMessages || isFetchingRef.current || jumpToMessageParam) { + return; + } + + const firstItem = virtualItems[0]; + if ((virtualItems.length === 0 || firstItem?.index === 0) && hasMorePreviousMessages) { + if (messages.length > 0) { + firstVisibleIdRef.current = messages[0]?._id ?? null; + } else { + requestScrollToEnd(); + } + isFetchingRef.current = true; + RoomHistoryManager.getMore(rid).finally(() => { + isFetchingRef.current = false; + }); + } + }, [virtualItems, hasMorePreviousMessages, isLoadingMoreMessages, messages, rid, requestScrollToEnd, jumpToMessageParam]); + + // Trigger loading next messages when the last item is visible. + useEffect(() => { + if (isLoadingMoreMessages || isFetchingRef.current || jumpToMessageParam) { + return; + } + + const lastItem = virtualItems[virtualItems.length - 1]; + if (lastItem && lastItem.index >= messages.length - 1 && hasMoreNextMessages) { + isFetchingRef.current = true; + RoomHistoryManager.getMoreNext(rid).finally(() => { + isFetchingRef.current = false; + }); + } + }, [virtualItems, hasMoreNextMessages, isLoadingMoreMessages, messages.length, rid, jumpToMessageParam]); + + useEffect(() => { + // Controls scroll to end + if (shouldScrollToEndRef.current && messages.length > 0) { + clearRequestScrollToEnd(); + scrollToEnd(); + } + + // if (firstVisibleIdRef.current) { + // const anchorIndex = messages.findIndex((m) => m._id === firstVisibleIdRef.current); + // firstVisibleIdRef.current = null; + // if (anchorIndex >= 0) { + // virtualizer.scrollToIndex(anchorIndex, { align: 'start' }); + // } + // } + }, [clearRequestScrollToEnd, messages, scrollToEnd, virtualizer]); + + // useRestoreScrollPosition functionality + // TODO: decide if we will keep this with the other effects, if we will use effects or if another approach + useEffect(() => { + const store = RoomManager.getStore(rid); + console.log('store', store, store?.atBottom, store?.scroll); + if (store?.atBottom) { + requestScrollToEnd(); + } else if (store?.scroll !== undefined) { + virtualizer.scrollToOffset(store.scroll); + } + + const scrollEl = virtualizer.scrollElement; + if (!scrollEl) { + return; + } + + const handleScroll = withThrottling({ wait: 100 })((e: Event) => { + const target = e.target as HTMLElement; + const currentStore = RoomManager.getStore(rid); + currentStore?.update({ scroll: target.scrollTop, atBottom: isAtBottom() }); + }); + + scrollEl.addEventListener('scroll', handleScroll, { passive: true }); + return () => { + handleScroll.cancel(); + scrollEl.removeEventListener('scroll', handleScroll); + }; + }, [rid, virtualizer, isAtBottom, requestScrollToEnd]); + + const isSurroundingLoadingRef = useRef(false); + + useEffect(() => { + if (!jumpToMessageParam) { + isSurroundingLoadingRef.current = false; + return; + } + if (jumpedToMsgRef.current === jumpToMessageParam) { + return; + } + const index = messages.findIndex((m) => m._id === jumpToMessageParam); + if (index === -1) { + // Message not in current window — load surrounding messages. + if (!isSurroundingLoadingRef.current) { + isSurroundingLoadingRef.current = true; + RoomHistoryManager.getSurroundingMessages({ _id: jumpToMessageParam, rid }).finally(() => { + isSurroundingLoadingRef.current = false; + }); + } + return; + } + // Message is loaded — scroll to it, then clear the ?msg= param. + virtualizer.scrollToIndex(index, { align: 'center' }); + setHighlightMessage(jumpToMessageParam); + jumpedToMsgRef.current = jumpToMessageParam; + const { msg: _, ...search } = router.getSearchParameters(); + router.navigate({ pathname: router.getLocationPathname(), search }, { replace: true }); + setTimeout(clearHighlightMessage, 2000); + }, [jumpToMessageParam, messages, virtualizer, rid, router]); + + return messages.length > 0 ? ( + + + {canPreview ? ( + <> + {hasMorePreviousMessages ? ( +
  • {isLoadingMoreMessages ? : null}
  • + ) : ( +
  • + + {retentionPolicy?.isActive ? : null} +
  • + )} + + ) : null} + + {virtualItems.map((virtualRow: VirtualItem) => { + const message = messages[virtualRow.index]; + if (!message) { + return null; + } + const previous = messages[virtualRow.index - 1]; + const sequential = isMessageSequential(message, previous, messageGroupingPeriod); + const showUnreadDivider = firstUnreadMessageId === message._id; + const system = MessageTypes.isSystemMessage(message); + const visible = !isThreadMessage(message) && !system; + + return ( + + + + ); + })} + {hasMoreNextMessages ?
  • {isLoadingMoreMessages ? : null}
  • : null} +
    +
    + ) : null; +} diff --git a/apps/meteor/client/views/room/MessageList/hooks/useJumpToMessage.ts b/apps/meteor/client/views/room/MessageList/hooks/useJumpToMessage.ts index 7300e63cd8a97..c25fc206077b2 100644 --- a/apps/meteor/client/views/room/MessageList/hooks/useJumpToMessage.ts +++ b/apps/meteor/client/views/room/MessageList/hooks/useJumpToMessage.ts @@ -1,103 +1,26 @@ -import type { IMessage } from '@rocket.chat/core-typings'; -import { useMergedRefs, useSafeRefCallback } from '@rocket.chat/fuselage-hooks'; -import { useRouter } from '@rocket.chat/ui-contexts'; +import { useMergedRefs } from '@rocket.chat/fuselage-hooks'; import { useCallback, useRef } from 'react'; -import { useMessageListJumpToMessageParam, useMessageListRef } from '../../../../components/message/list/MessageListContext'; -import { setRef } from '../../composer/hooks/useMessageComposerMergedRefs'; -import { setHighlightMessage, clearHighlightMessage } from '../providers/messageHighlightSubscription'; - -/** - * That is completely messy, CustomScrollbars force us to initialize the scrollbars inside an effect - * all refCallbacks happen before the effect, more than that, the scrollbars also reset the scroll position - * so we need to check if the scrollbars are initialized and if there is any message to be highlighted - */ - export const useJumpToMessageImperative = () => { + const virtualizerRef = useMessageListVirtualizer(); const jumpToRef = useRef(null); const containerRef = useRef(null); const jumpToRefAction = useCallback(() => { + if (virtualizerRef?.current) { + return; + } if (!jumpToRef.current || !containerRef.current) { return; } - // calculate the scroll position to center the message // avoiding scrollIntoView because it will can scroll parent elements containerRef.current.scrollTop = jumpToRef.current.offsetTop - containerRef.current.clientHeight / 2 + jumpToRef.current.offsetHeight / 2; - }, []); + }, [virtualizerRef]); return { jumpToRef: useMergedRefs(jumpToRef, jumpToRefAction), innerRef: useMergedRefs(containerRef, jumpToRefAction), }; }; - -/** - * `listRef` is a reference to the message node in the message list. - * its shared between other hooks like `useLoadSurroundingMessages`, `useJumpToMessage`, `useGetMore`, `useListIsAtBottom` and `useRestoreScrollPosition` - * since each hook has a different concern, this ref helps each other aware if a message is being highlighted which changes the scroll position - - */ - -export const useJumpToMessage = (messageId: IMessage['_id']) => { - const jumpToMessageParam = useMessageListJumpToMessageParam(); - const listRef = useMessageListRef(); - const router = useRouter(); - - const ref = useSafeRefCallback( - useCallback( - (node: HTMLElement) => { - if (!listRef || !scroll) { - return; - } - - setRef(listRef, node); - - const handleScroll = () => { - const { msg: _, ...search } = router.getSearchParameters(); - router.navigate( - { - pathname: router.getLocationPathname(), - search, - }, - { replace: true }, - ); - setTimeout(clearHighlightMessage, 2000); - }; - - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - handleScroll(); - } - }); - }, - { - threshold: 0.1, - }, - ); - - observer.observe(node); - - setHighlightMessage(messageId); - - return () => { - observer.disconnect(); - if (listRef) { - setRef(listRef, undefined); - } - }; - }, - [listRef, messageId, router], - ), - ); - - if (jumpToMessageParam !== messageId) { - return undefined; - } - - return ref; -}; diff --git a/apps/meteor/client/views/room/MessageList/hooks/useLoadSurroundingMessages.ts b/apps/meteor/client/views/room/MessageList/hooks/useLoadSurroundingMessages.ts index 678420171a1e7..3537eba35c384 100644 --- a/apps/meteor/client/views/room/MessageList/hooks/useLoadSurroundingMessages.ts +++ b/apps/meteor/client/views/room/MessageList/hooks/useLoadSurroundingMessages.ts @@ -1,13 +1,12 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { useEndpoint, useSearchParameter } from '@rocket.chat/ui-contexts'; import { useQueryClient } from '@tanstack/react-query'; -import { useEffect, useRef } from 'react'; +import { useEffect } from 'react'; import { legacyJumpToMessage } from '../../../../lib/utils/legacyJumpToMessage'; export const useLoadSurroundingMessages = () => { const msgId = useSearchParameter('msg'); - const jumpToRef = useRef(undefined); const queryClient = useQueryClient(); const getMessage = useEndpoint('GET', '/v1/chat.getMessage'); @@ -17,24 +16,17 @@ export const useLoadSurroundingMessages = () => { return; } - if (jumpToRef.current) { - return; - } - const abort = new AbortController(); queryClient .fetchQuery({ queryKey: ['chat.getMessage', msgId], - queryFn: () => { - return getMessage({ msgId }); - }, + queryFn: () => getMessage({ msgId }), }) .then(({ message }) => { if (abort.signal.aborted) { return; } - // Serialized IMessage dates are strings. For this function, only ts is needed legacyJumpToMessage({ ...message, ts: new Date(message.ts) } as any as IMessage); }) .catch((error) => { @@ -44,6 +36,4 @@ export const useLoadSurroundingMessages = () => { abort.abort(); }; }, [msgId, queryClient, getMessage]); - - return { jumpToRef }; }; diff --git a/apps/meteor/client/views/room/MessageList/providers/MessageListProvider.tsx b/apps/meteor/client/views/room/MessageList/providers/MessageListProvider.tsx index af366350fad68..bc5e1080e1e68 100644 --- a/apps/meteor/client/views/room/MessageList/providers/MessageListProvider.tsx +++ b/apps/meteor/client/views/room/MessageList/providers/MessageListProvider.tsx @@ -1,5 +1,5 @@ import { isRoomFederated, isThreadMainMessage } from '@rocket.chat/core-typings'; -import { useLayout, useUser, useUserPreference, useSetting, useEndpoint, useSearchParameter } from '@rocket.chat/ui-contexts'; +import { useLayout, useUser, useUserPreference, useSetting, useEndpoint } from '@rocket.chat/ui-contexts'; import type { ReactNode, RefCallback } from 'react'; import { useMemo, memo } from 'react'; @@ -59,7 +59,6 @@ const MessageListProvider = ({ children, messageListRef, attachmentDimension }: const formatTime = useFormatTime(); const formatDate = useFormatDate(); const hasSubscription = Boolean(subscription); - const msgParameter = useSearchParameter('msg'); const chat = useChat(); @@ -93,7 +92,6 @@ const MessageListProvider = ({ children, messageListRef, attachmentDimension }: showRealName, showUsername, messageListRef, - jumpToMessageParam: msgParameter, ...(katexEnabled && { katex: { dollarSyntaxEnabled: katexDollarSyntaxEnabled, @@ -140,7 +138,6 @@ const MessageListProvider = ({ children, messageListRef, attachmentDimension }: highlights, reactToMessage, showColors, - msgParameter, messageListRef, chat?.emojiPicker, readReceiptsEnabled, diff --git a/apps/meteor/client/views/room/body/RoomBody.tsx b/apps/meteor/client/views/room/body/RoomBody.tsx index cff19dec9641d..a8db0c73a2132 100644 --- a/apps/meteor/client/views/room/body/RoomBody.tsx +++ b/apps/meteor/client/views/room/body/RoomBody.tsx @@ -1,19 +1,19 @@ import { Box } from '@rocket.chat/fuselage'; import { isTruthy } from '@rocket.chat/tools'; -import { CustomScrollbars, useEmbeddedLayout } from '@rocket.chat/ui-client'; +import { useEmbeddedLayout } from '@rocket.chat/ui-client'; import { usePermission, useRole, useSetting, useTranslation, useUser, useUserPreference, useRoomToolbox } from '@rocket.chat/ui-contexts'; -import type { MouseEvent, ReactElement } from 'react'; -import { memo, useCallback, useMemo } from 'react'; +import type { MouseEvent, ReactElement, RefCallback } from 'react'; +import { memo, useCallback, useMemo, useRef, useState } from 'react'; import { useMergedRefsV2 } from '../../../hooks/useMergedRefsV2'; import { BubbleDate } from '../BubbleDate'; import { MessageList } from '../MessageList'; import DropTargetOverlay from './DropTargetOverlay'; import JumpToRecentMessageButton from './JumpToRecentMessageButton'; -import LoadingMessagesIndicator from './LoadingMessagesIndicator'; -import RetentionPolicyWarning from './RetentionPolicyWarning'; import MessageListErrorBoundary from '../MessageList/MessageListErrorBoundary'; import RoomAnnouncement from '../RoomAnnouncement'; +import UnreadMessagesIndicator from './UnreadMessagesIndicator'; +import { UploadProgressContainer, UploadProgressIndicator } from './UploadProgress'; import ComposerContainer from '../composer/ComposerContainer'; import { useQuoteMessageByUrl } from './hooks/useQuoteMessageByUrl'; import { useReadMessageWindowEvents } from './hooks/useReadMessageWindowEvents'; @@ -23,19 +23,13 @@ import { useRoom, useRoomSubscription, useRoomMessages } from '../contexts/RoomC import { useDateScroll } from '../hooks/useDateScroll'; import { useMessageListNavigation } from '../hooks/useMessageListNavigation'; import { useRetentionPolicy } from '../hooks/useRetentionPolicy'; -import RoomForeword from './RoomForeword/RoomForeword'; -import UnreadMessagesIndicator from './UnreadMessagesIndicator'; -import { UploadProgressContainer, UploadProgressIndicator } from './UploadProgress'; import { useFileUpload } from './hooks/useFileUpload'; -import { useGetMore } from './hooks/useGetMore'; import { useGoToHomeOnRemoved } from './hooks/useGoToHomeOnRemoved'; import { useHasNewMessages } from './hooks/useHasNewMessages'; -import { useListIsAtBottom } from './hooks/useListIsAtBottom'; -import { useRestoreScrollPosition } from './hooks/useRestoreScrollPosition'; +import { useStoreScrollPosition } from './hooks/useRestoreScrollPosition'; import { useSelectAllAndScrollToTop } from './hooks/useSelectAllAndScrollToTop'; import { useHandleUnread } from './hooks/useUnreadMessages'; -import { useJumpToMessageImperative } from '../MessageList/hooks/useJumpToMessage'; -import { useLoadSurroundingMessages } from '../MessageList/hooks/useLoadSurroundingMessages'; +import type { VirtualizerHandle } from '../MessageList/VirtualizedMessageList'; const RoomBody = (): ReactElement => { const chat = useChat(); @@ -65,6 +59,8 @@ const RoomBody = (): ReactElement => { const subscribed = !!subscription; + const virtualizerHandle = useRef(null); + const canPreview = useMemo(() => { if (room && room.t !== 'c') { return true; @@ -81,10 +77,6 @@ const RoomBody = (): ReactElement => { return subscribed; }, [allowAnonymousRead, canPreviewChannelRoom, room, subscribed]); - const { jumpToRef: jumpToRefGetMoreImperative, innerRef: jumpToRefGetMoreImperativeInnerRef } = useJumpToMessageImperative(); - - const { jumpToRef: surroundingMessagesJumpTpRef } = useLoadSurroundingMessages(); - const { wrapperRef, innerRef: unreadBarInnerRef, @@ -95,27 +87,6 @@ const RoomBody = (): ReactElement => { const { innerRef: dateScrollInnerRef, bubbleRef, listStyle, ...bubbleDate } = useDateScroll(); - const { - innerRef: isAtBottomInnerRef, - atBottomRef, - sendToBottom, - sendToBottomIfNecessary, - isAtBottom, - jumpToRef: jumpToRefIsAtBottom, - } = useListIsAtBottom(); - - const { innerRef: getMoreInnerRef, jumpToRef: jumpToRefGetMore } = useGetMore(room._id, atBottomRef); - - const { innerRef: restoreScrollPositionInnerRef, jumpToRef: jumpToRefRestoreScrollPosition } = useRestoreScrollPosition(room._id); - - const jumpToRef = useMergedRefsV2( - jumpToRefIsAtBottom, - jumpToRefGetMore, - jumpToRefRestoreScrollPosition, - jumpToRefGetMoreImperative, - surroundingMessagesJumpTpRef, - ); - const { uploads, handleUploadFiles, @@ -124,25 +95,30 @@ const RoomBody = (): ReactElement => { } = useFileUpload(); const { messageListRef } = useMessageListNavigation(); - const { innerRef: selectAndScrollRef, selectAllAndScrollToTop } = useSelectAllAndScrollToTop(); + const { selectAllAndScrollToTop } = useSelectAllAndScrollToTop(); + + const scrollContainerRef = useRef(null); + + const [, setScrollContainerReady] = useState(false); + + const scrollContainerRefCallback = useCallback((el: HTMLElement | null) => { + scrollContainerRef.current = el; + setScrollContainerReady((prev) => (el ? true : prev)); + }, []); + + const { handleNewMessageButtonClick, handleJumpToRecentButtonClick, handleComposerResize, hasNewMessages } = useHasNewMessages( + room._id, + user?._id, + ); - const { handleNewMessageButtonClick, handleJumpToRecentButtonClick, handleComposerResize, hasNewMessages, newMessagesScrollRef } = - useHasNewMessages(room._id, user?._id, atBottomRef, { - sendToBottom, - sendToBottomIfNecessary, - isAtBottom, - }); + const { innerRef: scrollContainerInnerRef } = useStoreScrollPosition(room._id, 100, virtualizerHandle); const innerRef = useMergedRefsV2( + scrollContainerRefCallback, dateScrollInnerRef, - restoreScrollPositionInnerRef, - isAtBottomInnerRef, - newMessagesScrollRef, unreadBarInnerRef, - getMoreInnerRef, - selectAndScrollRef, messageListRef, - jumpToRefGetMoreImperativeInnerRef, + scrollContainerInnerRef, ); const handleNavigateToPreviousMessage = useCallback((): void => { @@ -251,26 +227,20 @@ const RoomBody = (): ReactElement => { .join(' ')} > - -
      - {canPreview ? ( - <> - {hasMorePreviousMessages ? ( -
    • {isLoadingMoreMessages ? : null}
    • - ) : ( -
    • - - {retentionPolicy?.isActive ? : null} -
    • - )} - - ) : null} - - {hasMoreNextMessages ? ( -
    • {isLoadingMoreMessages ? : null}
    • - ) : null} -
    -
    +
    diff --git a/apps/meteor/client/views/room/body/hooks/useGetMore.spec.tsx b/apps/meteor/client/views/room/body/hooks/useGetMore.spec.tsx deleted file mode 100644 index 5be7ae0494293..0000000000000 --- a/apps/meteor/client/views/room/body/hooks/useGetMore.spec.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { mockAppRoot } from '@rocket.chat/mock-providers'; -import { render, screen, waitFor } from '@testing-library/react'; -import React from 'react'; - -import { useGetMore } from './useGetMore'; -import { getBoundingClientRect } from '../../../../../app/ui/client/views/app/lib/scrolling'; -import { RoomHistoryManager } from '../../../../../app/ui-utils/client'; - -jest.mock('../../../../../app/ui-utils/client', () => ({ - RoomHistoryManager: { - isLoading: jest.fn(), - isLoadingNext: jest.fn(), - hasMore: jest.fn(), - hasMoreNext: jest.fn(), - getMore: jest.fn(), - getMoreNext: jest.fn(), - restoreScroll: jest.fn(), - }, -})); - -jest.mock('../../../../../app/ui/client/views/app/lib/scrolling', () => ({ - getBoundingClientRect: jest.fn(), -})); - -const mockGetMore = jest.fn(); - -describe('useGetMore', () => { - it('should call getMore when scrolling near top and hasMore is true', async () => { - const root = mockAppRoot(); - - const Test = () => { - const atBottomRef = React.useRef(false); - const { innerRef } = useGetMore('room-id', atBottomRef); - return ( -
    -
    -
    - ); - }; - (RoomHistoryManager.isLoading as jest.Mock).mockReturnValue(false); - (RoomHistoryManager.hasMore as jest.Mock).mockReturnValue(true); - (RoomHistoryManager.hasMoreNext as jest.Mock).mockReturnValue(false); - (RoomHistoryManager.getMore as jest.Mock).mockImplementation(mockGetMore); - - (getBoundingClientRect as jest.Mock).mockReturnValue({ - scrollTop: 10, - clientHeight: 100, - scrollHeight: 800, - }); - - render(, { - wrapper: root.build(), - }); - - const scrollableElement = screen.getByTestId('scrollable-element'); - scrollableElement.scrollTop = 10; - scrollableElement.dispatchEvent(new Event('scroll')); - - expect(screen.getByTestId('scrollable-element')).toBeInTheDocument(); - - await waitFor(() => { - expect(RoomHistoryManager.getMore).toHaveBeenCalledWith('room-id'); - }); - }); - - it('should call getMoreNext when scrolling near bottom and hasMoreNext is true', () => { - const root = mockAppRoot(); - (RoomHistoryManager.isLoading as jest.Mock).mockReturnValue(false); - (RoomHistoryManager.hasMore as jest.Mock).mockReturnValue(false); - (RoomHistoryManager.hasMoreNext as jest.Mock).mockReturnValue(true); - (RoomHistoryManager.getMoreNext as jest.Mock).mockImplementation(mockGetMore); - - const Test = () => { - const atBottomRef = React.useRef(false); - const { innerRef } = useGetMore('room-id', atBottomRef); - return ( -
    -
    -
    - ); - }; - (getBoundingClientRect as jest.Mock).mockReturnValue({ - scrollTop: 700, - clientHeight: 100, - scrollHeight: 800, - }); - render(, { - wrapper: root.build(), - }); - const scrollableElement = screen.getByTestId('scrollable-element'); - scrollableElement.scrollTop = 700; - scrollableElement.dispatchEvent(new Event('scroll')); - expect(screen.getByTestId('scrollable-element')).toBeInTheDocument(); - expect(RoomHistoryManager.getMoreNext).toHaveBeenCalledWith('room-id', expect.anything()); - }); -}); diff --git a/apps/meteor/client/views/room/body/hooks/useGetMore.ts b/apps/meteor/client/views/room/body/hooks/useGetMore.ts deleted file mode 100644 index 6da4f14ea69b9..0000000000000 --- a/apps/meteor/client/views/room/body/hooks/useGetMore.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { useSafeRefCallback } from '@rocket.chat/fuselage-hooks'; -import { useSearchParameter } from '@rocket.chat/ui-contexts'; -import type { MutableRefObject } from 'react'; -import { useCallback, useEffect, useRef } from 'react'; -import { flushSync } from 'react-dom'; - -import { getBoundingClientRect } from '../../../../../app/ui/client/views/app/lib/scrolling'; -import { RoomHistoryManager } from '../../../../../app/ui-utils/client'; -import { withThrottling } from '../../../../../lib/utils/highOrderFunctions'; - -export const useGetMore = (rid: string, atBottomRef: MutableRefObject) => { - const msgId = useSearchParameter('msg'); - const msgIdRef = useRef(msgId); - const jumpToRef = useRef(undefined); - - useEffect(() => { - msgIdRef.current = msgId; - }, [msgId]); - - const ref = useSafeRefCallback( - useCallback( - (element: HTMLElement) => { - const checkPositionAndGetMore = withThrottling({ wait: 100 })(async () => { - if (!element.isConnected) { - return; - } - - const { scrollTop, clientHeight, scrollHeight } = getBoundingClientRect(element); - - if (msgIdRef.current && !RoomHistoryManager.isLoaded(rid)) { - return; - } - - const lastScrollTopRef = scrollTop; - const height = clientHeight; - const isLoading = RoomHistoryManager.isLoading(rid); - const hasMore = RoomHistoryManager.hasMore(rid); - const hasMoreNext = RoomHistoryManager.hasMoreNext(rid); - - if (jumpToRef.current) { - return; - } - - if (isLoading) { - return; - } - - if (hasMore === true && lastScrollTopRef <= height / 3) { - await RoomHistoryManager.getMore(rid); - - if (jumpToRef.current) { - return; - } - flushSync(() => { - RoomHistoryManager.restoreScroll(rid); - }); - } else if (hasMoreNext === true && Math.ceil(lastScrollTopRef) >= scrollHeight - height) { - await RoomHistoryManager.getMoreNext(rid, atBottomRef); - atBottomRef.current = false; - } - }); - - const mutationObserver = new MutationObserver((mutations) => { - mutations.forEach(() => { - checkPositionAndGetMore(); - }); - }); - - mutationObserver.observe(element, { childList: true, subtree: true }); - - const observer = new ResizeObserver(() => { - checkPositionAndGetMore(); - }); - - observer.observe(element); - - const handleScroll = function () { - checkPositionAndGetMore(); - }; - - element.addEventListener('scroll', handleScroll, { - passive: true, - }); - - return () => { - observer.disconnect(); - mutationObserver.disconnect(); - checkPositionAndGetMore.cancel(); - element.removeEventListener('scroll', handleScroll); - }; - }, - [rid, atBottomRef], - ), - ); - - return { - innerRef: ref, - jumpToRef, - }; -}; diff --git a/apps/meteor/client/views/room/body/hooks/useHasNewMessages.ts b/apps/meteor/client/views/room/body/hooks/useHasNewMessages.ts index c18489d6819b8..abc73a1a8d602 100644 --- a/apps/meteor/client/views/room/body/hooks/useHasNewMessages.ts +++ b/apps/meteor/client/views/room/body/hooks/useHasNewMessages.ts @@ -1,27 +1,13 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { isEditedMessage } from '@rocket.chat/core-typings'; import { clientCallbacks } from '@rocket.chat/ui-client'; -import type { MutableRefObject } from 'react'; import { useCallback, useEffect, useState } from 'react'; import { RoomHistoryManager } from '../../../../../app/ui-utils/client'; -import { withThrottling } from '../../../../../lib/utils/highOrderFunctions'; +import type { VirtualizerHandle } from '../../MessageList/VirtualizedMessageList'; import { useChat } from '../../contexts/ChatContext'; -export const useHasNewMessages = ( - rid: string, - uid: string | undefined, - atBottomRef: MutableRefObject, - { - sendToBottom, - sendToBottomIfNecessary, - isAtBottom, - }: { - sendToBottom: () => void; - sendToBottomIfNecessary: () => void; - isAtBottom: (threshold?: number) => boolean; - }, -) => { +export const useHasNewMessages = (rid: string, uid: string | undefined, virtualizerRef: RefObject) => { const chat = useChat(); if (!chat) { @@ -31,22 +17,22 @@ export const useHasNewMessages = ( const [hasNewMessages, setHasNewMessages] = useState(false); const handleNewMessageButtonClick = useCallback(() => { - atBottomRef.current = true; - sendToBottomIfNecessary(); + virtualizerRef?.current?.requestScrollToEnd(); setHasNewMessages(false); chat.composer?.focus(); - }, [atBottomRef, chat.composer, sendToBottomIfNecessary]); + }, [chat.composer, virtualizerRef]); const handleJumpToRecentButtonClick = useCallback(() => { - atBottomRef.current = true; RoomHistoryManager.clear(rid); RoomHistoryManager.getMoreIfIsEmpty(rid); - }, [atBottomRef, rid]); + }, [rid]); const handleComposerResize = useCallback((): void => { - sendToBottomIfNecessary(); + if (virtualizerRef?.current?.isAtBottom()) { + virtualizerRef.current.requestScrollToEnd(); + } setHasNewMessages(false); - }, [sendToBottomIfNecessary]); + }, [virtualizerRef]); useEffect(() => { clientCallbacks.add( @@ -56,7 +42,7 @@ export const useHasNewMessages = ( return; } - if (!isAtBottom()) { + if (!virtualizerRef?.current?.isAtBottom()) { setHasNewMessages(true); } }, @@ -68,7 +54,7 @@ export const useHasNewMessages = ( 'afterSaveMessage', (msg: IMessage) => { if (msg.u._id === uid) { - sendToBottom(); + virtualizerRef?.current?.requestScrollToEnd(); setHasNewMessages(false); } }, @@ -80,29 +66,9 @@ export const useHasNewMessages = ( clientCallbacks.remove('streamNewMessage', rid); clientCallbacks.remove('afterSaveMessage', rid); }; - }, [isAtBottom, rid, sendToBottom, uid]); - - const ref = useCallback( - (node: HTMLElement | null) => { - if (!node) { - return; - } - - node.addEventListener( - 'scroll', - withThrottling({ wait: 100 })(() => { - atBottomRef.current && setHasNewMessages(false); - }), - { - passive: true, - }, - ); - }, - [atBottomRef], - ); + }, [rid, uid, virtualizerRef]); return { - newMessagesScrollRef: ref, handleNewMessageButtonClick, handleJumpToRecentButtonClick, handleComposerResize, diff --git a/apps/meteor/client/views/room/body/hooks/useListIsAtBottom.ts b/apps/meteor/client/views/room/body/hooks/useListIsAtBottom.ts index 873ace14ae15d..c4b52af54890c 100644 --- a/apps/meteor/client/views/room/body/hooks/useListIsAtBottom.ts +++ b/apps/meteor/client/views/room/body/hooks/useListIsAtBottom.ts @@ -4,8 +4,10 @@ import { useCallback, useRef } from 'react'; import { isAtBottom as isAtBottomLib } from '../../../../../app/ui/client/views/app/lib/scrolling'; import { withThrottling } from '../../../../../lib/utils/highOrderFunctions'; +import { useMessageListVirtualizer } from '../../../../components/message/list/MessageListContext'; export const useListIsAtBottom = () => { + const virtualizerRef = useMessageListVirtualizer(); const atBottomRef = useRef(true); const jumpToRef = useRef(undefined); @@ -13,8 +15,12 @@ export const useListIsAtBottom = () => { const innerBoxRef = useRef(null); const sendToBottom = useCallback(() => { + if (virtualizerRef?.current) { + virtualizerRef.current.scrollToEnd(); + return; + } innerBoxRef.current?.scrollTo({ left: 30, top: innerBoxRef.current?.scrollHeight }); - }, []); + }, [virtualizerRef]); const sendToBottomIfNecessary = useCallback(() => { if (jumpToRef.current) { @@ -45,8 +51,13 @@ export const useListIsAtBottom = () => { if (jumpToRef.current) { atBottomRef.current = false; } + if (atBottomRef.current === true) { - node.scrollTo({ left: 30, top: node.scrollHeight }); + if (virtualizerRef?.current) { + virtualizerRef.current.scrollToEnd(); + } else { + node.scrollTo({ left: 30, top: node.scrollHeight }); + } } }); @@ -65,7 +76,7 @@ export const useListIsAtBottom = () => { node.removeEventListener('scroll', handleScroll); }; }, - [isAtBottom], + [isAtBottom, virtualizerRef], ), ); @@ -74,7 +85,6 @@ export const useListIsAtBottom = () => { innerRef: useMergedRefs(ref, innerBoxRef) as unknown as MutableRefObject, sendToBottom, sendToBottomIfNecessary, - isAtBottom, jumpToRef, }; }; diff --git a/apps/meteor/client/views/room/body/hooks/useRestoreScrollPosition.ts b/apps/meteor/client/views/room/body/hooks/useRestoreScrollPosition.ts index b2e7e33c3e431..dd8032e0ae2a2 100644 --- a/apps/meteor/client/views/room/body/hooks/useRestoreScrollPosition.ts +++ b/apps/meteor/client/views/room/body/hooks/useRestoreScrollPosition.ts @@ -1,27 +1,18 @@ import { useSafeRefCallback } from '@rocket.chat/fuselage-hooks'; -import { useCallback, useRef } from 'react'; +import type { RefObject } from 'react'; +import { useCallback } from 'react'; -import { isAtBottom } from '../../../../../app/ui/client/views/app/lib/scrolling'; import { withThrottling } from '../../../../../lib/utils/highOrderFunctions'; import { RoomManager } from '../../../../lib/RoomManager'; +import type { VirtualizerHandle } from '../../MessageList/VirtualizedMessageList'; -export function useRestoreScrollPosition(rid: string, wait = 100) { - const jumpToRef = useRef(undefined); +export function useStoreScrollPosition(rid: string, wait = 100, virtualizerHandle: RefObject) { const ref = useSafeRefCallback( useCallback( (node: HTMLElement) => { - const store = RoomManager.getStore(rid); - if (store?.atBottom) { - node.scrollTop = node.scrollHeight; - node.scrollLeft = 30; - } - if (!jumpToRef.current && store?.scroll !== undefined && !store.atBottom) { - node.scrollTop = store.scroll; - node.scrollLeft = 30; - } const handleWrapperScroll = withThrottling({ wait })((event) => { const store = RoomManager.getStore(rid); - store?.update({ scroll: event.target.scrollTop, atBottom: isAtBottom(event.target, 50) }); + store?.update({ scroll: event.target.scrollTop, atBottom: virtualizerHandle?.current?.isAtBottom() }); }); node.addEventListener('scroll', handleWrapperScroll, { passive: true }); return () => { @@ -29,12 +20,11 @@ export function useRestoreScrollPosition(rid: string, wait = 100) { node.removeEventListener('scroll', handleWrapperScroll); }; }, - [rid, wait], + [rid, virtualizerHandle, wait], ), ); return { - jumpToRef, innerRef: ref, }; } diff --git a/apps/meteor/client/views/room/body/hooks/useSelectAllAndScrollToTop.ts b/apps/meteor/client/views/room/body/hooks/useSelectAllAndScrollToTop.ts index bf53178fa67e6..fb395a8c423ac 100644 --- a/apps/meteor/client/views/room/body/hooks/useSelectAllAndScrollToTop.ts +++ b/apps/meteor/client/views/room/body/hooks/useSelectAllAndScrollToTop.ts @@ -1,15 +1,15 @@ -import { useRef } from 'react'; +import type { RefObject } from 'react'; +import type { VirtualizerHandle } from '../../MessageList/VirtualizedMessageList'; import { useToggleSelectAll } from '../../MessageList/contexts/SelectedMessagesContext'; -export const useSelectAllAndScrollToTop = () => { - const ref = useRef(null); +export const useSelectAllAndScrollToTop = (virtualizerRef: RefObject) => { const handleToggleAll = useToggleSelectAll(); const selectAllAndScrollToTop = () => { - ref.current?.scrollTo({ top: 0, behavior: 'smooth' }); + virtualizerRef?.current?.scrollToOffset(0, { behavior: 'smooth' }); handleToggleAll(); }; - return { innerRef: ref, selectAllAndScrollToTop }; + return { selectAllAndScrollToTop }; }; diff --git a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useLegacyThreadMessageListScrolling.ts b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useLegacyThreadMessageListScrolling.ts index 81ed25c77ea77..3dbf61fd22b1e 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useLegacyThreadMessageListScrolling.ts +++ b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useLegacyThreadMessageListScrolling.ts @@ -8,7 +8,7 @@ import { useListIsAtBottom } from '../../../body/hooks/useListIsAtBottom'; import { useRoom } from '../../../contexts/RoomContext'; export const useLegacyThreadMessageListScrolling = (mainMessage: IMessage) => { - const { atBottomRef, innerRef, sendToBottom, sendToBottomIfNecessary, isAtBottom, jumpToRef } = useListIsAtBottom(); + const { atBottomRef, innerRef, sendToBottom, sendToBottomIfNecessary, jumpToRef } = useListIsAtBottom(); const room = useRoom(); const uid = useUserId(); useEffect(() => { @@ -33,5 +33,5 @@ export const useLegacyThreadMessageListScrolling = (mainMessage: IMessage) => { }; }, [room._id, atBottomRef, sendToBottomIfNecessary, uid, mainMessage._id]); - return { atBottomRef, innerRef, sendToBottom, sendToBottomIfNecessary, isAtBottom, jumpToRef }; + return { atBottomRef, innerRef, sendToBottom, sendToBottomIfNecessary, jumpToRef }; }; diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 2ea7d85d76bec..e132c00cbe21c 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -161,6 +161,7 @@ "@slack/bolt": "^3.22.0", "@slack/rtm-api": "~7.0.4", "@tanstack/react-query": "~5.65.1", + "@tanstack/react-virtual": "^3.13.2", "@types/meteor": "^2.9.10", "@xmldom/xmldom": "~0.8.11", "adm-zip": "0.5.16", diff --git a/packages/ui-client/src/components/CustomScrollbars/VirtualScrollbars.tsx b/packages/ui-client/src/components/CustomScrollbars/VirtualScrollbars.tsx new file mode 100644 index 0000000000000..eee34788ad2d7 --- /dev/null +++ b/packages/ui-client/src/components/CustomScrollbars/VirtualScrollbars.tsx @@ -0,0 +1,67 @@ +import { useOverlayScrollbars } from 'overlayscrollbars-react'; +import type { HTMLAttributes, ReactElement, RefObject } from 'react'; +import { useEffect, useRef, forwardRef, memo } from 'react'; + +import type { OverlayScrollbars } from '.'; +import BaseScrollbars, { getScrollbarsOptions } from './BaseScrollbars'; + +type VirtualScrollbarsProps = { + children: ReactElement; + overflowX?: boolean; + onScroll?: (args: OverlayScrollbars) => void; + viewportRef?: RefObject; +} & Omit, 'is' | 'onScroll'>; + +const VirtualScrollbars = forwardRef(function VirtualScrollbars( + { overflowX, onScroll, ...props }, + ref, +) { + const rootRef = useRef(null); + const scrollbarsOptions = getScrollbarsOptions(overflowX); + const [initialize, osInstance] = useOverlayScrollbars({ + options: scrollbarsOptions, + defer: true, + events: { + scroll: (args) => onScroll?.(args), + initialized(osInstance) { + const { viewport } = osInstance.elements(); + viewport.style.overflowX = `var(--os-viewport-overflow-x)`; + viewport.style.overflowY = `var(--os-viewport-overflow-y)`; + // Set the ref here, where the instance is guaranteed to exist + if (ref) { + if (typeof ref === 'function') { + ref(viewport || null); + } else { + ref.current = viewport || null; + } + } + }, + }, + }); + + useEffect(() => { + const { current: root } = rootRef; + + if (root) { + initialize({ + target: root, + }); + + const instance = osInstance(); + if (!instance || !ref) { + return; + } + if (typeof ref === 'function') { + ref(instance.elements().viewport || null); + return; + } + ref.current = instance.elements().viewport || null; + } + + return () => osInstance()?.destroy(); + }, [initialize, osInstance, ref]); + + return ; +}); + +export default memo(VirtualScrollbars); diff --git a/packages/ui-client/src/components/CustomScrollbars/index.ts b/packages/ui-client/src/components/CustomScrollbars/index.ts index 9c0814c8ff81e..4579edbfe3d47 100644 --- a/packages/ui-client/src/components/CustomScrollbars/index.ts +++ b/packages/ui-client/src/components/CustomScrollbars/index.ts @@ -3,3 +3,4 @@ import { OverlayScrollbars } from 'overlayscrollbars'; export { OverlayScrollbars }; export { default as CustomScrollbars } from './CustomScrollbars'; export { default as VirtualizedScrollbars } from './VirtualizedScrollbars'; +export { default as VirtualScrollbars } from './VirtualScrollbars'; diff --git a/yarn.lock b/yarn.lock index ef1cc5b3ab342..03f1048e82f38 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9864,6 +9864,7 @@ __metadata: "@storybook/react": "npm:^8.6.17" "@storybook/react-webpack5": "npm:^8.6.17" "@tanstack/react-query": "npm:~5.65.1" + "@tanstack/react-virtual": "npm:^3.13.2" "@testing-library/dom": "npm:~10.4.1" "@testing-library/react": "npm:~16.3.2" "@testing-library/user-event": "npm:~14.6.1" @@ -13334,6 +13335,25 @@ __metadata: languageName: node linkType: hard +"@tanstack/react-virtual@npm:^3.13.2": + version: 3.13.21 + resolution: "@tanstack/react-virtual@npm:3.13.21" + dependencies: + "@tanstack/virtual-core": "npm:3.13.21" + 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 + checksum: 10/c4dd558ab6a260fa959d35b736a9b42d32927ff90995a00bb4f8382cb28f84cbe9b2ea387e5631166df4b1f862ed13bd68c11208931fc9c73ee7950f9e5830ea + languageName: node + linkType: hard + +"@tanstack/virtual-core@npm:3.13.21": + version: 3.13.21 + resolution: "@tanstack/virtual-core@npm:3.13.21" + checksum: 10/efd45e986dcfbd4aaa33537e255ccb3fc847b1a5eba1d7e73cd397f354b4622ecb824acb94f173dc3c8607e3094aa3a5b153e8a94d0839d0fa106f4f741d0c1f + languageName: node + linkType: hard + "@testing-library/dom@npm:10.4.0": version: 10.4.0 resolution: "@testing-library/dom@npm:10.4.0"