| import { useMemo, memo, type FC, useCallback } from 'react'; | |
| import throttle from 'lodash/throttle'; | |
| import { Spinner, useMediaQuery } from '@librechat/client'; | |
| import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtualized'; | |
| import { TConversation } from 'librechat-data-provider'; | |
| import { useLocalize, TranslationKeys } from '~/hooks'; | |
| import { groupConversationsByDate } from '~/utils'; | |
| import Convo from './Convo'; | |
| interface ConversationsProps { | |
| conversations: Array<TConversation | null>; | |
| moveToTop: () => void; | |
| toggleNav: () => void; | |
| containerRef: React.RefObject<HTMLDivElement | List>; | |
| loadMoreConversations: () => void; | |
| isLoading: boolean; | |
| isSearchLoading: boolean; | |
| } | |
| const LoadingSpinner = memo(() => { | |
| const localize = useLocalize(); | |
| return ( | |
| <div className="mx-auto mt-2 flex items-center justify-center gap-2"> | |
| <Spinner className="text-text-primary" /> | |
| <span className="animate-pulse text-text-primary">{localize('com_ui_loading')}</span> | |
| </div> | |
| ); | |
| }); | |
| LoadingSpinner.displayName = 'LoadingSpinner'; | |
| const DateLabel: FC<{ groupName: string }> = memo(({ groupName }) => { | |
| const localize = useLocalize(); | |
| return ( | |
| <div className="mt-2 pl-2 pt-1 text-text-secondary" style={{ fontSize: '0.7rem' }}> | |
| {localize(groupName as TranslationKeys) || groupName} | |
| </div> | |
| ); | |
| }); | |
| DateLabel.displayName = 'DateLabel'; | |
| type FlattenedItem = | |
| | { type: 'header'; groupName: string } | |
| | { type: 'convo'; convo: TConversation } | |
| | { type: 'loading' }; | |
| const MemoizedConvo = memo( | |
| ({ | |
| conversation, | |
| retainView, | |
| toggleNav, | |
| }: { | |
| conversation: TConversation; | |
| retainView: () => void; | |
| toggleNav: () => void; | |
| }) => { | |
| return <Convo conversation={conversation} retainView={retainView} toggleNav={toggleNav} />; | |
| }, | |
| (prevProps, nextProps) => { | |
| return ( | |
| prevProps.conversation.conversationId === nextProps.conversation.conversationId && | |
| prevProps.conversation.title === nextProps.conversation.title && | |
| prevProps.conversation.endpoint === nextProps.conversation.endpoint | |
| ); | |
| }, | |
| ); | |
| const Conversations: FC<ConversationsProps> = ({ | |
| conversations: rawConversations, | |
| moveToTop, | |
| toggleNav, | |
| containerRef, | |
| loadMoreConversations, | |
| isLoading, | |
| isSearchLoading, | |
| }) => { | |
| const localize = useLocalize(); | |
| const isSmallScreen = useMediaQuery('(max-width: 768px)'); | |
| const convoHeight = isSmallScreen ? 44 : 34; | |
| const filteredConversations = useMemo( | |
| () => rawConversations.filter(Boolean) as TConversation[], | |
| [rawConversations], | |
| ); | |
| const groupedConversations = useMemo( | |
| () => groupConversationsByDate(filteredConversations), | |
| [filteredConversations], | |
| ); | |
| const flattenedItems = useMemo(() => { | |
| const items: FlattenedItem[] = []; | |
| groupedConversations.forEach(([groupName, convos]) => { | |
| items.push({ type: 'header', groupName }); | |
| items.push(...convos.map((convo) => ({ type: 'convo' as const, convo }))); | |
| }); | |
| if (isLoading) { | |
| items.push({ type: 'loading' } as any); | |
| } | |
| return items; | |
| }, [groupedConversations, isLoading]); | |
| const cache = useMemo( | |
| () => | |
| new CellMeasurerCache({ | |
| fixedWidth: true, | |
| defaultHeight: convoHeight, | |
| keyMapper: (index) => { | |
| const item = flattenedItems[index]; | |
| if (item.type === 'header') { | |
| return `header-${index}`; | |
| } | |
| if (item.type === 'convo') { | |
| return `convo-${item.convo.conversationId}`; | |
| } | |
| if (item.type === 'loading') { | |
| return `loading-${index}`; | |
| } | |
| return `unknown-${index}`; | |
| }, | |
| }), | |
| [flattenedItems, convoHeight], | |
| ); | |
| const rowRenderer = useCallback( | |
| ({ index, key, parent, style }) => { | |
| const item = flattenedItems[index]; | |
| if (item.type === 'loading') { | |
| return ( | |
| <CellMeasurer cache={cache} columnIndex={0} key={key} parent={parent} rowIndex={index}> | |
| {({ registerChild }) => ( | |
| <div ref={registerChild} style={style}> | |
| <LoadingSpinner /> | |
| </div> | |
| )} | |
| </CellMeasurer> | |
| ); | |
| } | |
| let rendering: JSX.Element; | |
| if (item.type === 'header') { | |
| rendering = <DateLabel groupName={item.groupName} />; | |
| } else if (item.type === 'convo') { | |
| rendering = ( | |
| <MemoizedConvo conversation={item.convo} retainView={moveToTop} toggleNav={toggleNav} /> | |
| ); | |
| } | |
| return ( | |
| <CellMeasurer cache={cache} columnIndex={0} key={key} parent={parent} rowIndex={index}> | |
| {({ registerChild }) => ( | |
| <div ref={registerChild} style={style}> | |
| {rendering} | |
| </div> | |
| )} | |
| </CellMeasurer> | |
| ); | |
| }, | |
| [cache, flattenedItems, moveToTop, toggleNav], | |
| ); | |
| const getRowHeight = useCallback( | |
| ({ index }: { index: number }) => cache.getHeight(index, 0), | |
| [cache], | |
| ); | |
| const throttledLoadMore = useMemo( | |
| () => throttle(loadMoreConversations, 300), | |
| [loadMoreConversations], | |
| ); | |
| const handleRowsRendered = useCallback( | |
| ({ stopIndex }: { stopIndex: number }) => { | |
| if (stopIndex >= flattenedItems.length - 8) { | |
| throttledLoadMore(); | |
| } | |
| }, | |
| [flattenedItems.length, throttledLoadMore], | |
| ); | |
| return ( | |
| <div className="relative flex h-full flex-col pb-2 text-sm text-text-primary"> | |
| {isSearchLoading ? ( | |
| <div className="flex flex-1 items-center justify-center"> | |
| <Spinner className="text-text-primary" /> | |
| <span className="ml-2 text-text-primary">{localize('com_ui_loading')}</span> | |
| </div> | |
| ) : ( | |
| <div className="flex-1"> | |
| <AutoSizer> | |
| {({ width, height }) => ( | |
| <List | |
| ref={containerRef as React.RefObject<List>} | |
| width={width} | |
| height={height} | |
| deferredMeasurementCache={cache} | |
| rowCount={flattenedItems.length} | |
| rowHeight={getRowHeight} | |
| rowRenderer={rowRenderer} | |
| overscanRowCount={10} | |
| className="outline-none" | |
| style={{ outline: 'none' }} | |
| aria-label="Conversations" | |
| onRowsRendered={handleRowsRendered} | |
| tabIndex={-1} | |
| /> | |
| )} | |
| </AutoSizer> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| export default memo(Conversations); | |