LibreChat/client/src/components/Nav/Nav.tsx

257 lines
8.2 KiB
TypeScript

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 FavoritesList = lazy(() => import('./Favorites/FavoritesList'));
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(
() => (
<>
<Suspense fallback={null}>
<FavoritesList />
</Suspense>
{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} />
</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;