| 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); |
| |