diff --git a/packages/react/package.json b/packages/react/package.json index 82cc802682..0e1f060a1a 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -93,6 +93,7 @@ "@rocket.chat/fuselage-hooks": "^0.33.1", "@rocket.chat/ui-kit": "^0.31.25", "@rollup/plugin-json": "^6.0.0", + "@tanstack/react-virtual": "^3.13.18", "date-fns": "^2.30.0", "emoji-picker-react": "^4.4.9", "emoji-toolkit": "^7.0.1", diff --git a/packages/react/src/context/MessageNavigationContext.js b/packages/react/src/context/MessageNavigationContext.js new file mode 100644 index 0000000000..bf614d13c0 --- /dev/null +++ b/packages/react/src/context/MessageNavigationContext.js @@ -0,0 +1,21 @@ +import React, { createContext, useContext } from 'react'; + +export const MessageNavigationContext = createContext(null); + +export const MessageNavigationProvider = ({ value, children }) => { + return ( + + {children} + + ); +}; + +export const useMessageNavigation = () => { + const ctx = useContext(MessageNavigationContext); + if (!ctx) { + throw new Error( + 'useMessageNavigation must be used within MessageNavigationProvider' + ); + } + return ctx; +}; diff --git a/packages/react/src/store/pinnedMessageStore.js b/packages/react/src/store/pinnedMessageStore.js index 36ee4e3851..fd1562b0c8 100644 --- a/packages/react/src/store/pinnedMessageStore.js +++ b/packages/react/src/store/pinnedMessageStore.js @@ -3,6 +3,8 @@ import { create } from 'zustand'; const usePinnedMessageStore = create((set) => ({ showPinned: false, setShowPinned: (showPinned) => set(() => ({ showPinned })), + pinnedMessages: [], + setPinnedMessages: (messages) => set(() => ({ pinnedMessages: messages })), })); export default usePinnedMessageStore; diff --git a/packages/react/src/views/ChatBody/ChatBody.js b/packages/react/src/views/ChatBody/ChatBody.js index 34f5c8bf40..afec5b3bd1 100644 --- a/packages/react/src/views/ChatBody/ChatBody.js +++ b/packages/react/src/views/ChatBody/ChatBody.js @@ -40,6 +40,7 @@ const ChatBody = ({ showRoles, messageListRef, scrollToBottom, + onRegisterJump, clearUnreadDividerRef, }) => { const { classNames, styleOverrides } = useComponentOverrides('ChatBody'); @@ -63,11 +64,15 @@ const ChatBody = ({ const upsertMessage = useMessageStore((state) => state.upsertMessage); const [loadingOlderMessages, setLoadingOlderMessages] = useState(false); const [hasMoreMessages, setHasMoreMessages] = useState(true); + const hasMoreMessagesRef = useRef(hasMoreMessages); + const loadingOlderMessagesRef = useRef(loadingOlderMessages); const removeMessage = useMessageStore((state) => state.removeMessage); const isChannelPrivate = useChannelStore((state) => state.isChannelPrivate); const channelInfo = useChannelStore((state) => state.channelInfo); const isLoginIn = useLoginStore((state) => state.isLoginIn); const setMessages = useMessageStore((state) => state.setMessages); + const offsetRef = useRef(offset); + const jumpFnRef = useRef(null); const [isThreadOpen, threadMainMessage] = useMessageStore((state) => [ state.isThreadOpen, @@ -174,6 +179,18 @@ const ChatBody = ({ }); }, [RCInstance, anonymousMode, getMessagesAndRoles]); + useEffect(() => { + hasMoreMessagesRef.current = hasMoreMessages; + }, [hasMoreMessages]); + + useEffect(() => { + loadingOlderMessagesRef.current = loadingOlderMessages; + }, [loadingOlderMessages]); + + useEffect(() => { + offsetRef.current = offset; + }, [offset]); + useEffect(() => { RCInstance.auth.onAuthChange((user) => { if (user) { @@ -289,6 +306,89 @@ const ChatBody = ({ firstUnreadMessageId, ]); + const loadOlderMessagesUntil = useCallback( + async (messageId, maxPages = 10) => { + if (!messageId) return false; + const hasMessage = () => + useMessageStore + .getState() + .messages.some((msg) => msg._id === messageId); + + if (hasMessage()) return true; + + for (let attempt = 0; attempt < maxPages; attempt += 1) { + if (!hasMoreMessagesRef.current || loadingOlderMessagesRef.current) { + return false; + } + + loadingOlderMessagesRef.current = true; + setLoadingOlderMessages(true); + + try { + const olderMessages = await RCInstance.getOlderMessages( + anonymousMode, + ECOptions?.enableThreads + ? { + query: { + tmid: { + $exists: false, + }, + }, + offset: offsetRef.current, + } + : undefined, + anonymousMode ? false : isChannelPrivate + ); + + if (olderMessages?.messages?.length) { + setMessages(olderMessages.messages, true); + const newOffset = offsetRef.current + olderMessages.messages.length; + setMessagesOffset(newOffset); + offsetRef.current = newOffset; + } else { + setHasMoreMessages(false); + hasMoreMessagesRef.current = false; + return false; + } + } catch (error) { + console.error('Error fetching older messages:', error); + setHasMoreMessages(false); + hasMoreMessagesRef.current = false; + return false; + } finally { + setLoadingOlderMessages(false); + loadingOlderMessagesRef.current = false; + } + + if (hasMessage()) return true; + } + + return hasMessage(); + }, + [ + RCInstance, + anonymousMode, + ECOptions?.enableThreads, + isChannelPrivate, + setMessages, + setMessagesOffset, + ] + ); + + const handleRegisterJump = useCallback( + (jumpFn) => { + jumpFnRef.current = jumpFn; + onRegisterJump?.(async (messageId) => { + const found = await loadOlderMessagesUntil(messageId); + if (!found) return; + requestAnimationFrame(() => { + jumpFnRef.current?.(messageId); + }); + }); + }, + [loadOlderMessagesUntil, onRegisterJump] + ); + const showNewMessagesPopup = () => { setPopupVisible(true); }; @@ -417,10 +517,12 @@ const ChatBody = ({ /> ) : ( )} @@ -449,4 +551,5 @@ export default ChatBody; ChatBody.propTypes = { anonymousMode: PropTypes.bool, showRoles: PropTypes.bool, + onRegisterJump: PropTypes.func, }; diff --git a/packages/react/src/views/ChatLayout/ChatLayout.js b/packages/react/src/views/ChatLayout/ChatLayout.js index f3b4262acb..96cc19b4e0 100644 --- a/packages/react/src/views/ChatLayout/ChatLayout.js +++ b/packages/react/src/views/ChatLayout/ChatLayout.js @@ -32,9 +32,11 @@ import CheckPreviewType from '../AttachmentPreview/CheckPreviewType'; import { useRCContext } from '../../context/RCInstance'; import UiKitContextualBar from '../ContextualBarBlock/uiKit/UiKitContextualBar'; import useUiKitStore from '../../store/uiKitStore'; +import { MessageNavigationProvider } from '../../context/MessageNavigationContext'; const ChatLayout = () => { const messageListRef = useRef(null); + const jumpToMessageRef = useRef(null); const clearUnreadDividerRef = useRef(null); const { classNames, styleOverrides } = useComponentOverrides('ChatBody'); const { RCInstance, ECOptions } = useRCContext(); @@ -98,16 +100,40 @@ const ChatLayout = () => { useEffect(() => { getStarredMessages(); }, [showSidebar]); + const registerJumpToMessage = useCallback((fn) => { + jumpToMessageRef.current = fn; + }, []); + return ( - { + const messageId = + typeof messageOrId === 'string' ? messageOrId : messageOrId?._id; + if (!messageId) return; + return jumpToMessageRef.current?.(messageId); + }, }} - className={`ec-chat-layout ${classNames}`} - onDragOver={(e) => handleDrag(e)} - onDrop={(e) => handleDragDrop(e)} > + handleDrag(e)} + onDrop={(e) => handleDragDrop(e)} + > + + + +
{ /> )} - )} - {attachmentWindowOpen ? ( - data ? ( - <> - - - ) : ( - - ) - ) : null} - + {showSidebar && ( + + {showMembers && } + {showSearch && } + {showChannelinfo && } + {showAllThreads && } + {showAllFiles && } + {showMentions && } + {showPinned && } + {showStarred && } + {showCurrentUserInfo && } + {uiKitContextualBarOpen && ( + + )} + + )} + + {attachmentWindowOpen ? ( + data ? ( + <> + + + ) : ( + + ) + ) : null} + + ); }; diff --git a/packages/react/src/views/FileMessage/FileMessage.js b/packages/react/src/views/FileMessage/FileMessage.js index 1ae977a0ef..25a67181dd 100644 --- a/packages/react/src/views/FileMessage/FileMessage.js +++ b/packages/react/src/views/FileMessage/FileMessage.js @@ -4,6 +4,7 @@ import React, { memo, useContext, useEffect, + useMemo, } from 'react'; import PropTypes from 'prop-types'; import { @@ -29,12 +30,19 @@ import { useRCContext } from '../../context/RCInstance'; import { useChannelStore, useMessageStore } from '../../store'; import { fileDisplayStyles as styles } from './Files.styles'; -const FileMessage = ({ fileMessage, onDeleteFile }) => { +const FileMessage = ({ fileMessage, onDeleteFile, onClick }) => { const { classNames, styleOverrides } = useComponentOverrides('FileMessage'); + const [fileId, setFileId] = useState(null); const dispatchToastMessage = useToastBarDispatch(); const { RCInstance } = useRCContext(); const messages = useMessageStore((state) => state.messages); + const threadMessages = useMessageStore((state) => state.threadMessages) || []; + const allMessages = useMemo( + () => [...messages, ...[...threadMessages].reverse()], + [messages, threadMessages] + ); const [files, setFiles] = useState([]); + const [fileMessageId, setFileMessageId] = useState(null); const theme = useTheme(); const isChannelPrivate = useChannelStore((state) => state.isChannelPrivate); const [isFetching, setIsFetching] = useState(true); @@ -84,6 +92,12 @@ const FileMessage = ({ fileMessage, onDeleteFile }) => { [messages, RCInstance, dispatchToastMessage] ); useEffect(() => { + const targetFileId = fileMessage._id; + const matchedMessages = allMessages.filter((message) => + message.file ? message.file._id === targetFileId : false + ); + const messageObject = matchedMessages[0]; + setFileMessageId(messageObject); const fetchAllFiles = async () => { const res = await RCInstance.getAllFiles(isChannelPrivate, ''); if (res?.files) { @@ -107,6 +121,7 @@ const FileMessage = ({ fileMessage, onDeleteFile }) => { className={appendClassNames('ec-file', classNames)} style={styleOverrides} css={[messageStyles, hoverStyle]} + onClick={() => onClick(fileMessageId)} > diff --git a/packages/react/src/views/MessageAggregators/FileGallery.js b/packages/react/src/views/MessageAggregators/FileGallery.js index 8c23f6eefd..8ec1d9ebb1 100644 --- a/packages/react/src/views/MessageAggregators/FileGallery.js +++ b/packages/react/src/views/MessageAggregators/FileGallery.js @@ -17,6 +17,13 @@ const FileGallery = () => { const [files, setFiles] = useState([]); const [selectedFilter, setSelectedFilter] = useState('all'); + const [fileMessageIds, setFileMessageIds] = useState([]); + const threadMessages = useMessageStore((state) => state.threadMessages) || []; + const allMessages = useMemo( + () => [...messages, ...[...threadMessages].reverse()], + [messages, threadMessages] + ); + const options = [ { value: 'all', label: 'All' }, { value: 'application', label: 'Files' }, @@ -45,7 +52,12 @@ const FileGallery = () => { const sortedFiles = res.files.sort( (a, b) => new Date(b.uploadedAt) - new Date(a.uploadedAt) ); + const fileIdSet = new Set(res.files.map((file) => file._id)); + const matchedMessages = allMessages + .filter((message) => fileIdSet.has(message.file?._id)) + .map((message) => message._id); setFiles(sortedFiles); + setFileMessageIds(matchedMessages); setIsFetching(false); } }; @@ -86,6 +98,7 @@ const FileGallery = () => { }} fetching={isFetching} shouldRender={(file) => file.path} + fileMessageIds={fileMessageIds} type="file" searchFiltered={filteredFiles} viewType={viewType} diff --git a/packages/react/src/views/MessageAggregators/PinnedMessages.js b/packages/react/src/views/MessageAggregators/PinnedMessages.js index 884e13529c..7b5bd512a1 100644 --- a/packages/react/src/views/MessageAggregators/PinnedMessages.js +++ b/packages/react/src/views/MessageAggregators/PinnedMessages.js @@ -1,15 +1,42 @@ -import React from 'react'; +import React, { useEffect, useContext } from 'react'; import { useComponentOverrides } from '@embeddedchat/ui-elements'; import { MessageAggregator } from './common/MessageAggregator'; +import RCContext from '../../context/RCInstance'; +import { usePinnedMessageStore, useUserStore } from '../../store'; const PinnedMessages = () => { const { variantOverrides } = useComponentOverrides('PinnedMessages'); const viewType = variantOverrides.viewType || 'Sidebar'; + const { RCInstance } = useContext(RCContext); + const isUserAuthenticated = useUserStore( + (state) => state.isUserAuthenticated + ); + const pinnedMessages = usePinnedMessageStore((state) => state.pinnedMessages); + const setPinnedMessages = usePinnedMessageStore( + (state) => state.setPinnedMessages + ); + + useEffect(() => { + let cancelled = false; + if (!isUserAuthenticated) return; + (async () => { + const { messages } = await RCInstance.getPinnedMessages(); + if (!cancelled) { + setPinnedMessages(messages || []); + } + })(); + + return () => { + cancelled = true; + }; + }, [RCInstance, isUserAuthenticated, setPinnedMessages]); + return ( msg.pinned} viewType={viewType} /> diff --git a/packages/react/src/views/MessageAggregators/common/MessageAggregator.js b/packages/react/src/views/MessageAggregators/common/MessageAggregator.js index ab8c3bc2f0..2b826acf95 100644 --- a/packages/react/src/views/MessageAggregators/common/MessageAggregator.js +++ b/packages/react/src/views/MessageAggregators/common/MessageAggregator.js @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useContext } from 'react'; import { isSameDay, format } from 'date-fns'; import { Box, @@ -9,10 +9,12 @@ import { Icon, lighten, darken, + Throbber, } from '@embeddedchat/ui-elements'; import { MessageDivider } from '../../Message/MessageDivider'; import Message from '../../Message/Message'; import getMessageAggregatorStyles from './MessageAggregator.styles'; +import { MessageNavigationContext } from '../../../context/MessageNavigationContext'; import { useMessageStore, useSidebarStore } from '../../../store'; import { useSetMessageList } from '../../../hooks/useSetMessageList'; import LoadingIndicator from './LoadingIndicator'; @@ -41,13 +43,14 @@ export const MessageAggregator = ({ const { ECOptions } = useRCContext(); const showRoles = ECOptions?.showRoles; const messages = useMessageStore((state) => state.messages); - const threadMessages = useMessageStore((state) => state.threadMessages) || []; + const threadMessages = useMessageStore((state) => state.threadMessages); const allMessages = useMemo( - () => [...messages, ...[...threadMessages].reverse()], + () => [...messages, ...[...(threadMessages || [])].reverse()], [messages, threadMessages] ); const [messageRendered, setMessageRendered] = useState(false); + const [loadingMessageId, setLoadingMessageId] = useState(null); const { loading, messageList } = useSetMessageList( fetchedMessageList || searchFiltered || allMessages, shouldRender @@ -57,75 +60,78 @@ export const MessageAggregator = ({ const openThread = useMessageStore((state) => state.openThread); const closeThread = useMessageStore((state) => state.closeThread); - const setJumpToMessage = (msg) => { - if (!msg || !msg._id) { - console.error('Invalid message object:', msg); - return; - } + const { jumpToMessage } = useContext(MessageNavigationContext); - const { _id: msgId, tmid: threadId } = msg; + const highlightMessage = (element) => { + if (!element) return; - if (msgId) { - let element; - if (threadId) { - const parentMessage = messages.find((m) => m._id === threadId); + element.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + }); - if (parentMessage) { - closeThread(); + element.style.backgroundColor = + mode === 'light' + ? lighten(theme.colors.warning, 0.85) + : darken(theme.colors.warningForeground, 0.75); - setTimeout(() => { - openThread(parentMessage); - setShowSidebar(false); + setTimeout(() => { + element.style.backgroundColor = ''; + }, 2000); + }; - setTimeout(() => { - const childElement = document.getElementById( - `ec-message-body-${msgId}` - ); - element = childElement.closest('.ec-message'); - - if (element) { - element.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - }); - - element.style.backgroundColor = - mode === 'light' - ? lighten(theme.colors.warning, 0.85) - : darken(theme.colors.warningForeground, 0.75); - - setTimeout(() => { - element.style.backgroundColor = ''; - }, 2000); - } - }, 300); - }, 300); + const waitForMessageElement = (messageId, attempts = 20) => + new Promise((resolve) => { + const findElement = (remainingAttempts) => { + const childElement = document.getElementById( + `ec-message-body-${messageId}` + ); + const element = childElement?.closest('.ec-message') || childElement; + + if (element || remainingAttempts <= 0) { + resolve(element || null); + return; } - } else { - closeThread(); setTimeout(() => { - const childElement = document.getElementById( - `ec-message-body-${msgId}` - ); - element = childElement.closest('.ec-message'); - - if (element) { - setShowSidebar(false); - element.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - - element.style.backgroundColor = - mode === 'light' - ? lighten(theme.colors.warning, 0.85) - : darken(theme.colors.warningForeground, 0.75); - - setTimeout(() => { - element.style.backgroundColor = ''; - }, 2000); - } - }, 300); - } + findElement(remainingAttempts - 1); + }, 150); + }; + + findElement(attempts); + }); + + const setJumpToMessage = async (msg) => { + if (!msg?._id) { + return; + } + + const { _id: msgId, tmid: threadId } = msg; + + if (!threadId) { + closeThread(); + await jumpToMessage?.(msgId); + const element = await waitForMessageElement(msgId, 10); + highlightMessage(element); + return; + } + + closeThread(); + await jumpToMessage?.(threadId); + + const parentMessage = useMessageStore + .getState() + .messages.find((message) => message._id === threadId); + + if (!parentMessage) { + return; } + + openThread(parentMessage); + setShowSidebar(false); + + const element = await waitForMessageElement(msgId); + highlightMessage(element); }; const isMessageNewDay = (current, previous) => @@ -136,6 +142,16 @@ export const MessageAggregator = ({ const noMessages = messageList?.length === 0 || !messageRendered; const ViewComponent = viewType === 'Popup' ? Popup : Sidebar; + const handleOnActionClick = async (msg) => { + if (!msg?._id) return; + setLoadingMessageId(msg._id); + try { + await setJumpToMessage(msg); + } finally { + setLoadingMessageId(null); + } + }; + return ( )} {type === 'file' ? ( - + <> + + ) : ( setJumpToMessage(msg)} + disabled={loadingMessageId === msg._id} + onClick={() => handleOnActionClick(msg)} css={{ position: 'relative', zIndex: 10, marginRight: '5px', }} > - + {loadingMessageId === msg._id ? ( + + ) : ( + + )} )} diff --git a/packages/react/src/views/MessageList/MessageList.js b/packages/react/src/views/MessageList/MessageList.js index 31dd291b75..5f40446c78 100644 --- a/packages/react/src/views/MessageList/MessageList.js +++ b/packages/react/src/views/MessageList/MessageList.js @@ -1,88 +1,194 @@ -import React from 'react'; +import React, { useEffect, useMemo, useCallback } from 'react'; import PropTypes from 'prop-types'; import { css } from '@emotion/react'; import { isSameDay } from 'date-fns'; -import { Box, Icon, Throbber, useTheme } from '@embeddedchat/ui-elements'; +import { Box, Icon, Throbber } from '@embeddedchat/ui-elements'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { useMessageStore } from '../../store'; import MessageReportWindow from '../ReportMessage/MessageReportWindow'; import isMessageSequential from '../../lib/isMessageSequential'; import { Message } from '../Message'; import isMessageLastSequential from '../../lib/isMessageLastSequential'; import { MessageBody } from '../Message/MessageBody'; +import { MessageNavigationProvider } from '../../context/MessageNavigationContext'; import { MessageDivider } from '../Message/MessageDivider'; const MessageList = ({ - messages, + messagesS, loadingOlderMessages, isUserAuthenticated, hasMoreMessages, + messageContainerRef, + onRegisterJump, firstUnreadMessageId, }) => { const showReportMessage = useMessageStore((state) => state.showReportMessage); const messageToReport = useMessageStore((state) => state.messageToReport); const isMessageLoaded = useMessageStore((state) => state.isMessageLoaded); - const { theme } = useTheme(); - const isMessageNewDay = (current, previous) => !previous || !isSameDay(new Date(current.ts), new Date(previous.ts)); - const filteredMessages = messages.filter((msg) => !msg.tmid); + const filteredMessages = messagesS.filter((msg) => !msg.tmid); + const reversedMessages = filteredMessages.slice().reverse(); + const orderedMessages = useMemo( + () => reversedMessages.filter((m) => !m.tmid), + [reversedMessages] + ); + + const messages = useMessageStore((state) => state.messages); + + const rowVirtualizer = useVirtualizer({ + count: orderedMessages.length, + getScrollElement: () => messageContainerRef.current, + getItemKey: (index) => orderedMessages[index]?._id ?? index, + overscan: 10, + estimateSize: () => 50, + measureElement: (element) => element.offsetHeight, + // onScroll: ({ scrollOffset }) => { + // onRegisterJump(scrollOffset); + // }, + }); const reportedMessage = messages.find((msg) => msg._id === messageToReport); + const jumpToMessage = useCallback( + (messageId) => { + const index = orderedMessages.findIndex((msg) => msg._id === messageId); + + if (index === -1) return; + + rowVirtualizer.measure(); + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + rowVirtualizer.scrollToIndex(index, { + align: 'center', + }); + setTimeout(() => { + const body = document.getElementById( + `ec-message-body-${messageId}` + ); + const element = body?.closest('.ec-message') || body; + if (!element) return; + const prevBg = element.style.backgroundColor; + const prevTransition = element.style.transition; + element.style.transition = 'background-color 300ms ease'; + element.style.backgroundColor = 'rgba(255, 230, 145, 0.6)'; + setTimeout(() => { + element.style.backgroundColor = prevBg; + element.style.transition = prevTransition; + }, 1000); + }, 50); + }); + }); + }, + [orderedMessages, rowVirtualizer] + ); + + useEffect(() => { + onRegisterJump?.(jumpToMessage); + }, [onRegisterJump, jumpToMessage]); + return ( <> - {filteredMessages.length === 0 ? ( - - - - {isMessageLoaded - ? 'No messages' - : 'Ready to chat? Login now to join the fun.'} + + {filteredMessages.length === 0 ? ( + + + + {isMessageLoaded + ? 'No messages' + : 'Ready to chat? Login now to join the fun.'} + - - ) : ( - <> - {!hasMoreMessages && isUserAuthenticated && ( - + {!hasMoreMessages && isUserAuthenticated && ( + + Start of conversation + + )} + {loadingOlderMessages && isUserAuthenticated && ( + + + + )} +
- Start of conversation - - )} - {loadingOlderMessages && isUserAuthenticated && ( - - - - )} - {filteredMessages - .slice() - .reverse() - .map((msg, index, arr) => { - const prev = arr[index - 1]; - const next = arr[index + 1]; + {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const { index } = virtualRow; + const msg = orderedMessages[index]; + if (!msg) return null; + + const prev = orderedMessages[index - 1]; + const next = orderedMessages[index + 1]; + const newDay = isMessageNewDay(msg, prev); + const sequential = isMessageSequential(msg, prev, 300); + const lastSequential = + sequential && isMessageLastSequential(msg, next); + return ( +
+ +
+ ); + })} +
+ {showReportMessage && ( + + )} + + )} + if (!msg) return null; const newDay = isMessageNewDay(msg, prev); const sequential = isMessageSequential(msg, prev, 300); diff --git a/yarn.lock b/yarn.lock index 23b67cfd11..0a346f3c7a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2473,6 +2473,7 @@ __metadata: "@storybook/react": ^7.0.26 "@storybook/react-webpack5": ^7.0.26 "@storybook/testing-library": ^0.2.0 + "@tanstack/react-virtual": ^3.13.18 "@testing-library/react": ^12.1.4 babel-jest: ^27.5.1 concurrently: ^7.2.0 @@ -9237,6 +9238,25 @@ __metadata: languageName: node linkType: hard +"@tanstack/react-virtual@npm:^3.13.18": + version: 3.13.18 + resolution: "@tanstack/react-virtual@npm:3.13.18" + dependencies: + "@tanstack/virtual-core": 3.13.18 + 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: 73dcfd5681db1c07834c9d286a7d2194ebded84f09466b37072c630bad1ec9d546c0b58981e8a4cf855b0bd6a5c53c64834c6428022fe12096ff3a905da2a4a4 + languageName: node + linkType: hard + +"@tanstack/virtual-core@npm:3.13.18": + version: 3.13.18 + resolution: "@tanstack/virtual-core@npm:3.13.18" + checksum: 9344c797be9a1b100adb5f1b9fbba2a6292aad56723d39f2d48f976d237f35e8473292949ed680b79338cb0f13823700c4e1ac21318097a5c197e8a706405263 + languageName: node + linkType: hard + "@testing-library/dom@npm:^8.0.0": version: 8.20.1 resolution: "@testing-library/dom@npm:8.20.1"