| import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef } from 'react'; | |
| import { useRecoilValue } from 'recoil'; | |
| import { AnimatePresence, motion } from 'framer-motion'; | |
| import { useMediaQuery } from '@librechat/client'; | |
| import { PermissionTypes, Permissions } from 'librechat-data-provider'; | |
| import type { ConversationListResponse } from 'librechat-data-provider'; | |
| import type { InfiniteQueryObserverResult } from '@tanstack/react-query'; | |
| import { | |
| useLocalize, | |
| useHasAccess, | |
| useAuthContext, | |
| useLocalStorage, | |
| useNavScrolling, | |
| } from '~/hooks'; | |
| import { useConversationsInfiniteQuery } from '~/data-provider'; | |
| import { Conversations } from '~/components/Conversations'; | |
| import SearchBar from './SearchBar'; | |
| import NewChat from './NewChat'; | |
| import { cn } from '~/utils'; | |
| import store from '~/store'; | |
| const BookmarkNav = lazy(() => import('./Bookmarks/BookmarkNav')); | |
| const AccountSettings = lazy(() => import('./AccountSettings')); | |
| const AgentMarketplaceButton = lazy(() => import('./AgentMarketplaceButton')); | |
| const NAV_WIDTH_DESKTOP = '260px'; | |
| const NAV_WIDTH_MOBILE = '320px'; | |
| const NavMask = memo( | |
| ({ navVisible, toggleNavVisible }: { navVisible: boolean; toggleNavVisible: () => void }) => ( | |
| <div | |
| id="mobile-nav-mask-toggle" | |
| role="button" | |
| tabIndex={0} | |
| className={`nav-mask transition-opacity duration-200 ease-in-out ${navVisible ? 'active opacity-100' : 'opacity-0'}`} | |
| onClick={toggleNavVisible} | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| toggleNavVisible(); | |
| } | |
| }} | |
| aria-label="Toggle navigation" | |
| /> | |
| ), | |
| ); | |
| const MemoNewChat = memo(NewChat); | |
| const Nav = memo( | |
| ({ | |
| navVisible, | |
| setNavVisible, | |
| }: { | |
| navVisible: boolean; | |
| setNavVisible: React.Dispatch<React.SetStateAction<boolean>>; | |
| }) => { | |
| const localize = useLocalize(); | |
| const { isAuthenticated } = useAuthContext(); | |
| const [navWidth, setNavWidth] = useState(NAV_WIDTH_DESKTOP); | |
| const isSmallScreen = useMediaQuery('(max-width: 768px)'); | |
| const [newUser, setNewUser] = useLocalStorage('newUser', true); | |
| const [showLoading, setShowLoading] = useState(false); | |
| const [tags, setTags] = useState<string[]>([]); | |
| const hasAccessToBookmarks = useHasAccess({ | |
| permissionType: PermissionTypes.BOOKMARKS, | |
| permission: Permissions.USE, | |
| }); | |
| const search = useRecoilValue(store.search); | |
| const { data, fetchNextPage, isFetchingNextPage, isLoading, isFetching, refetch } = | |
| useConversationsInfiniteQuery( | |
| { | |
| tags: tags.length === 0 ? undefined : tags, | |
| search: search.debouncedQuery || undefined, | |
| }, | |
| { | |
| enabled: isAuthenticated, | |
| staleTime: 30000, | |
| cacheTime: 300000, | |
| }, | |
| ); | |
| const computedHasNextPage = useMemo(() => { | |
| if (data?.pages && data.pages.length > 0) { | |
| const lastPage: ConversationListResponse = data.pages[data.pages.length - 1]; | |
| return lastPage.nextCursor !== null; | |
| } | |
| return false; | |
| }, [data?.pages]); | |
| const outerContainerRef = useRef<HTMLDivElement>(null); | |
| const listRef = useRef<any>(null); | |
| const { moveToTop } = useNavScrolling<ConversationListResponse>({ | |
| setShowLoading, | |
| fetchNextPage: async (options?) => { | |
| if (computedHasNextPage) { | |
| return fetchNextPage(options); | |
| } | |
| return Promise.resolve( | |
| {} as InfiniteQueryObserverResult<ConversationListResponse, unknown>, | |
| ); | |
| }, | |
| isFetchingNext: isFetchingNextPage, | |
| }); | |
| const conversations = useMemo(() => { | |
| return data ? data.pages.flatMap((page) => page.conversations) : []; | |
| }, [data]); | |
| const toggleNavVisible = useCallback(() => { | |
| setNavVisible((prev: boolean) => { | |
| localStorage.setItem('navVisible', JSON.stringify(!prev)); | |
| return !prev; | |
| }); | |
| if (newUser) { | |
| setNewUser(false); | |
| } | |
| }, [newUser, setNavVisible, setNewUser]); | |
| const itemToggleNav = useCallback(() => { | |
| if (isSmallScreen) { | |
| toggleNavVisible(); | |
| } | |
| }, [isSmallScreen, toggleNavVisible]); | |
| useEffect(() => { | |
| if (isSmallScreen) { | |
| const savedNavVisible = localStorage.getItem('navVisible'); | |
| if (savedNavVisible === null) { | |
| toggleNavVisible(); | |
| } | |
| setNavWidth(NAV_WIDTH_MOBILE); | |
| } else { | |
| setNavWidth(NAV_WIDTH_DESKTOP); | |
| } | |
| }, [isSmallScreen, toggleNavVisible]); | |
| useEffect(() => { | |
| refetch(); | |
| }, [tags, refetch]); | |
| const loadMoreConversations = useCallback(() => { | |
| if (isFetchingNextPage || !computedHasNextPage) { | |
| return; | |
| } | |
| fetchNextPage(); | |
| }, [isFetchingNextPage, computedHasNextPage, fetchNextPage]); | |
| const subHeaders = useMemo( | |
| () => search.enabled === true && <SearchBar isSmallScreen={isSmallScreen} />, | |
| [search.enabled, isSmallScreen], | |
| ); | |
| const headerButtons = useMemo( | |
| () => ( | |
| <> | |
| <Suspense fallback={null}> | |
| <AgentMarketplaceButton isSmallScreen={isSmallScreen} toggleNav={toggleNavVisible} /> | |
| </Suspense> | |
| {hasAccessToBookmarks && ( | |
| <> | |
| <div className="mt-1.5" /> | |
| <Suspense fallback={null}> | |
| <BookmarkNav tags={tags} setTags={setTags} isSmallScreen={isSmallScreen} /> | |
| </Suspense> | |
| </> | |
| )} | |
| </> | |
| ), | |
| [hasAccessToBookmarks, tags, isSmallScreen, toggleNavVisible], | |
| ); | |
| const [isSearchLoading, setIsSearchLoading] = useState( | |
| !!search.query && (search.isTyping || isLoading || isFetching), | |
| ); | |
| useEffect(() => { | |
| if (search.isTyping) { | |
| setIsSearchLoading(true); | |
| } else if (!isLoading && !isFetching) { | |
| setIsSearchLoading(false); | |
| } else if (!!search.query && (isLoading || isFetching)) { | |
| setIsSearchLoading(true); | |
| } | |
| }, [search.query, search.isTyping, isLoading, isFetching]); | |
| return ( | |
| <> | |
| <AnimatePresence initial={false}> | |
| {navVisible && ( | |
| <motion.div | |
| data-testid="nav" | |
| className={cn( | |
| 'nav active max-w-[320px] flex-shrink-0 overflow-x-hidden bg-surface-primary-alt', | |
| 'md:max-w-[260px]', | |
| )} | |
| initial={{ width: 0 }} | |
| animate={{ width: navWidth }} | |
| exit={{ width: 0 }} | |
| transition={{ duration: 0.2 }} | |
| key="nav" | |
| > | |
| <div className="h-full w-[320px] md:w-[260px]"> | |
| <div className="flex h-full flex-col"> | |
| <nav | |
| id="chat-history-nav" | |
| aria-label={localize('com_ui_chat_history')} | |
| className="flex h-full flex-col px-2 pb-3.5 md:px-3" | |
| > | |
| <div className="flex flex-1 flex-col" ref={outerContainerRef}> | |
| <MemoNewChat | |
| subHeaders={subHeaders} | |
| toggleNav={toggleNavVisible} | |
| headerButtons={headerButtons} | |
| isSmallScreen={isSmallScreen} | |
| /> | |
| <Conversations | |
| conversations={conversations} | |
| moveToTop={moveToTop} | |
| toggleNav={itemToggleNav} | |
| containerRef={listRef} | |
| loadMoreConversations={loadMoreConversations} | |
| isLoading={isFetchingNextPage || showLoading || isLoading} | |
| isSearchLoading={isSearchLoading} | |
| /> | |
| </div> | |
| <Suspense fallback={null}> | |
| <AccountSettings /> | |
| </Suspense> | |
| </nav> | |
| </div> | |
| </div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| {isSmallScreen && <NavMask navVisible={navVisible} toggleNavVisible={toggleNavVisible} />} | |
| </> | |
| ); | |
| }, | |
| ); | |
| Nav.displayName = 'Nav'; | |
| export default Nav; | |