Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

♾️ style: Infinite Scroll Nav and Sort Convos by Date/Usage #1708

Merged
merged 25 commits into from
Feb 4, 2024
Merged
Changes from 1 commit
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d68da0a
Style: Infinite Scroll and Group convos by date
walbercardoso Jan 24, 2024
9ce5356
Style: Infinite Scroll and Group convos by date- Redesign NavBar
walbercardoso Jan 24, 2024
13c581c
Style: Infinite Scroll and Group convos by date- Redesign NavBar - Cl…
walbercardoso Jan 24, 2024
2c50d92
Style: Infinite Scroll and Group convos by date- Redesign NavBar - Re…
walbercardoso Jan 25, 2024
ba30666
Style: Infinite Scroll and Group convos by date- Redesign NavBar - Re…
walbercardoso Jan 25, 2024
feed476
Style: Infinite Scroll and Group convos by date- Redesign NavBar - Re…
walbercardoso Jan 25, 2024
0cf9cd6
Including OpenRouter and Mistral icon
walbercardoso Jan 27, 2024
88e8734
Merge branch 'main' of https://github.com/walbercardoso/LibreChat int…
danny-avila Feb 2, 2024
4f725fb
refactor(Conversations): cleanup use of utility functions and typing
danny-avila Feb 2, 2024
8ca10d3
refactor(Nav/NewChat): use localStorage `lastConversationSetup` to de…
danny-avila Feb 2, 2024
eed8e9b
refactor: remove use of `isFirstToday`
danny-avila Feb 2, 2024
20dbba3
refactor(Nav): remove use of `endpointSelected`, consolidate scrollin…
danny-avila Feb 2, 2024
a58b009
refactor: Add spinner to bottom of list, throttle fetching, move quer…
danny-avila Feb 2, 2024
c65b83a
chore: sort by `updatedAt` field
danny-avila Feb 3, 2024
f77d491
refactor: optimize conversation infinite query, use optimistic update…
danny-avila Feb 3, 2024
18d8039
feat: gen_title route for generating the title for the conversation
danny-avila Feb 3, 2024
36ce64c
style(Convo): change hover bg-color
danny-avila Feb 3, 2024
7a9b25c
refactor: memoize groupedConversations and return as array of tuples,…
danny-avila Feb 3, 2024
ba150e9
style: rename Header NewChat Button -> HeaderNewChat, add NewChatIcon…
danny-avila Feb 3, 2024
b1f34f7
style(NewChat): add hover bg color
danny-avila Feb 3, 2024
044556a
style: cleanup comments, match ChatGPT nav styling, redesign search b…
danny-avila Feb 3, 2024
b712203
feat: add tests for conversation helpers and ensure no duplicate conv…
danny-avila Feb 4, 2024
9b8efd5
style: hover bg-color
danny-avila Feb 4, 2024
e6ce7f5
feat: alt-click on convo item to open conversation in new tab
danny-avila Feb 4, 2024
7bf11d7
chore: send error message when `gen_title` fails
danny-avila Feb 4, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Style: Infinite Scroll and Group convos by date
walbercardoso committed Jan 24, 2024
commit d68da0a894e35a391a8ca1670b5374a31a4b143e
4 changes: 2 additions & 2 deletions api/models/Conversation.js
Original file line number Diff line number Diff line change
@@ -30,7 +30,7 @@ module.exports = {
return { message: 'Error saving conversation' };
}
},
getConvosByPage: async (user, pageNumber = 1, pageSize = 14) => {
getConvosByPage: async (user, pageNumber = 1, pageSize = 25) => {
try {
const totalConvos = (await Conversation.countDocuments({ user })) || 1;
const totalPages = Math.ceil(totalConvos / pageSize);
@@ -45,7 +45,7 @@ module.exports = {
return { message: 'Error getting conversations' };
}
},
getConvosQueried: async (user, convoIds, pageNumber = 1, pageSize = 14) => {
getConvosQueried: async (user, convoIds, pageNumber = 1, pageSize = 25) => {
try {
if (!convoIds || convoIds.length === 0) {
return { conversations: [], pages: 1, pageNumber, pageSize };
90 changes: 82 additions & 8 deletions client/src/components/Conversations/Conversations.tsx
Original file line number Diff line number Diff line change
@@ -2,6 +2,58 @@ import Convo from './Convo';
import Conversation from './Conversation';
import { useLocation } from 'react-router-dom';
import { TConversation } from 'librechat-data-provider';
import { parseISO, isToday, isWithinInterval, subDays, getYear } from 'date-fns';

const getGroupName = (date) => {
const now = new Date();
if (isToday(date)) {
return 'Today';
}
if (isWithinInterval(date, { start: subDays(now, 7), end: now })) {
return 'Last 7 days';
}
if (isWithinInterval(date, { start: subDays(now, 30), end: now })) {
return 'Last 30 days';
}
return ' ' + getYear(date).toString(); // Returns the year for anything older than 30 days
};

// Function to group conversations
const groupConversationsByDate = (conversations) => {
if (!Array.isArray(conversations)) {
// Handle the case where conversations is not an array
return {};
}
const groups = conversations.reduce((acc, conversation) => {
const date = parseISO(conversation.updatedAt);
const groupName = getGroupName(date);
if (!acc[groupName]) {
acc[groupName] = [];
}
acc[groupName].push(conversation);
return acc;
}, {});

// Ensures groups are ordered correctly

const sortedGroups = {};
const dateGroups = ['Today', 'Last 7 days', 'Last 30 days'];
dateGroups.forEach((group) => {
if (groups[group]) {
sortedGroups[group] = groups[group];
}
});

Object.keys(groups)
.filter((group) => !dateGroups.includes(group))
.sort()
.reverse()
.forEach((year) => {
sortedGroups[year] = groups[year];
});

return sortedGroups;
};

export default function Conversations({
conversations,
@@ -15,22 +67,44 @@ export default function Conversations({
const location = useLocation();
const { pathname } = location;
const ConvoItem = pathname.includes('chat') ? Conversation : Convo;
const groupedConversations = groupConversationsByDate(conversations);
const firstTodayConvoId = conversations.find((convo) =>
isToday(parseISO(convo.updatedAt)),
)?.conversationId;

return (
<>
{conversations &&
conversations.length > 0 &&
conversations.map((convo: TConversation, i) => {
return (
<div className="flex-1 flex-col overflow-y-auto">
{Object.entries(groupedConversations).map(([groupName, convos]) => (
<div key={groupName}>
<div
style={{
color: '#aaa', // Cor do texto
fontSize: '0.7rem', // Tamanho da fonte
marginTop: '20px', // Espaço acima do cabeçalho
marginBottom: '5px', // Espaço abaixo do cabeçalho
paddingLeft: '10px', // Espaçamento à esquerda para alinhamento com as conversas
}}
>
{groupName}
</div>
{convos.map((convo, i) => (
<ConvoItem
key={convo.conversationId}
isFirstTodayConvo={convo.conversationId === firstTodayConvoId}
conversation={convo}
retainView={moveToTop}
toggleNav={toggleNav}
i={i}
/>
);
})}
</>
))}
<div
style={{
marginTop: '5px',
marginBottom: '5px',
}}
></div>
</div>
))}
</div>
);
}
11 changes: 8 additions & 3 deletions client/src/components/Conversations/Convo.tsx
Original file line number Diff line number Diff line change
@@ -18,7 +18,13 @@ import store from '~/store';

type KeyEvent = KeyboardEvent<HTMLInputElement>;

export default function Conversation({ conversation, retainView, toggleNav, i }) {
export default function Conversation({
conversation,
retainView,
toggleNav,
i,
isFirstTodayConvo,
}) {
const { conversationId: currentConvoId } = useParams();
const updateConvoMutation = useUpdateConversationMutation(currentConvoId ?? '');
const activeConvos = useRecoilValue(store.allConversationsSelector);
@@ -113,8 +119,7 @@ export default function Conversation({ conversation, retainView, toggleNav, i })
};

const activeConvo =
currentConvoId === conversationId ||
(i === 0 && currentConvoId === 'new' && activeConvos[0] && activeConvos[0] !== 'new');
currentConvoId === conversationId || (isFirstTodayConvo && currentConvoId === 'new');

if (!activeConvo) {
aProps.className =
121 changes: 68 additions & 53 deletions client/src/components/Nav/Nav.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { useSearchQuery, useGetConversationsQuery } from 'librechat-data-provider/react-query';
import {
useSearchInfiniteQuery,
useConversationsInfiniteQuery,
} from 'librechat-data-provider/react-query';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { TConversation, TSearchResults } from 'librechat-data-provider';
import type { ConversationListResponse, TConversation } from 'librechat-data-provider';
import {
useAuthContext,
useMediaQuery,
@@ -10,7 +13,7 @@ import {
useLocalStorage,
} from '~/hooks';
import { TooltipProvider, Tooltip } from '~/components/ui';
import { Conversations, Pages } from '../Conversations';
import { Conversations } from '../Conversations';
import { Spinner } from '~/components/svg';
import SearchBar from './SearchBar';
import NavToggle from './NavToggle';
@@ -37,44 +40,73 @@ export default function Nav({ navVisible, setNavVisible }) {
}
}, [isSmallScreen]);

const [conversations, setConversations] = useState<TConversation[]>([]);
const [, setConversations] = useState<TConversation[]>([]);
// current page
const [pageNumber, setPageNumber] = useState(1);
// total pages
const [pages, setPages] = useState(1);

// data provider
const getConversationsQuery = useGetConversationsQuery(pageNumber + '', {
enabled: isAuthenticated,
});

// search

const searchQuery = useRecoilValue(store.searchQuery);
const isSearchEnabled = useRecoilValue(store.isSearchEnabled);
const isSearching = useRecoilValue(store.isSearching);
const { newConversation, searchPlaceholderConversation } = useConversation();

// current conversation
const conversation = useRecoilValue(store.conversation);

const { conversationId } = conversation || {};
const setSearchResultMessages = useSetRecoilState(store.searchResultMessages);
const refreshConversationsHint = useRecoilValue(store.refreshConversationsHint);
const { refreshConversations } = useConversations();

const [isFetching, setIsFetching] = useState(false);
const queryParameters = searchQuery
? { pageNumber: pageNumber.toString(), searchQuery }
: { pageNumber: pageNumber.toString() };

// Define as opções de configuração do hook
const queryConfig = {
enabled: isAuthenticated,
};

const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useSearchInfiniteQuery(
{ pageNumber: pageNumber.toString(), searchQuery: searchQuery },
{ enabled: isAuthenticated },
);

const conversations = data?.pages.flatMap((page) => page.conversations) || [];

const handleScroll = useCallback(() => {
if (containerRef.current) {
const { scrollTop, clientHeight, scrollHeight } = containerRef.current;
const nearBottomOfList = scrollTop + clientHeight >= scrollHeight * 0.8; // 80% scroll

if (nearBottomOfList && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]); // Adicione outras dependências se necessário

useEffect(() => {
const container = containerRef.current;
if (container) {
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}
}, [handleScroll]);
const useAppropriateInfiniteQuery = searchQuery
? useSearchInfiniteQuery
: useConversationsInfiniteQuery;

const searchQueryFn = useSearchQuery(searchQuery, pageNumber + '', {
enabled: !!(!!searchQuery && searchQuery.length > 0 && isSearchEnabled && isSearching),
});
const getConversationsQuery = useAppropriateInfiniteQuery(queryParameters, queryConfig);

const onSearchSuccess = useCallback((data: TSearchResults, expectedPage?: number) => {
const onSearchSuccess = useCallback((data: ConversationListResponse, expectedPage?: number) => {
const res = data;
console.log('res', res);
setConversations(res.conversations);
if (expectedPage) {
setPageNumber(expectedPage);
}
setPages(Number(res.pages));
setIsFetching(false);
searchPlaceholderConversation();
setSearchResultMessages(res.messages);
/* disabled due recoil methods not recognized as state setters */
@@ -83,13 +115,10 @@ export default function Nav({ navVisible, setNavVisible }) {

useEffect(() => {
//we use isInitialLoading here instead of isLoading because query is disabled by default
if (searchQueryFn.isInitialLoading) {
setIsFetching(true);
} else if (searchQueryFn.data) {
onSearchSuccess(searchQueryFn.data);
if (getConversationsQuery.data) {
onSearchSuccess(getConversationsQuery.data.pages[0]);
}
}, [searchQueryFn.data, searchQueryFn.isInitialLoading, onSearchSuccess]);

}, [getConversationsQuery.data, getConversationsQuery.isInitialLoading, onSearchSuccess]);
const clearSearch = () => {
setPageNumber(1);
refreshConversations();
@@ -105,23 +134,15 @@ export default function Nav({ navVisible, setNavVisible }) {
}
}, [containerRef, scrollPositionRef]);

const nextPage = async () => {
moveToTop();
setPageNumber(pageNumber + 1);
};

const previousPage = async () => {
moveToTop();
setPageNumber(pageNumber - 1);
};

useEffect(() => {
if (getConversationsQuery.data) {
if (data) {
if (isSearching) {
return;
}
let { conversations, pages } = getConversationsQuery.data;
let { conversations } = data.pages[0];
let { pages } = data.pages[0];
pages = Number(pages);

if (pageNumber > pages) {
setPageNumber(pages);
} else {
@@ -130,18 +151,19 @@ export default function Nav({ navVisible, setNavVisible }) {
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
}

setConversations(conversations);
setPages(pages);
// setPages(pages);
}
}
}, [getConversationsQuery.isSuccess, getConversationsQuery.data, isSearching, pageNumber]);
}, [data, pageNumber, isSearching]);

useEffect(() => {
if (!isSearching) {
getConversationsQuery.refetch();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageNumber, conversationId, refreshConversationsHint]);
}, [pageNumber, conversationId, refreshConversationsHint, conversation, data]);

const toggleNavVisible = () => {
setNavVisible((prev: boolean) => !prev);
@@ -195,25 +217,18 @@ export default function Nav({ navVisible, setNavVisible }) {
onMouseLeave={() => setIsHovering(false)}
ref={containerRef}
>
<div className="my-2 ml-2 h-px w-7 bg-white/20"></div>
<div className="my-1 ml-1 h-px w-7"></div>
<div className={containerClasses}>
{(getConversationsQuery.isLoading && pageNumber === 1) || isFetching ? (
<Spinner />
) : (
<Conversations
conversations={conversations}
moveToTop={moveToTop}
toggleNav={itemToggleNav}
/>
)}
<Pages
pageNumber={pageNumber}
pages={pages}
nextPage={nextPage}
previousPage={previousPage}
setPageNumber={setPageNumber}
<Conversations
conversations={conversations}
moveToTop={moveToTop}
toggleNav={itemToggleNav}
/>
</div>
</div>

{isFetchingNextPage && <Spinner />}
<NavLinks />
</nav>
</div>
2 changes: 1 addition & 1 deletion client/src/components/svg/Spinner.tsx
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@ import { cn } from '~/utils/';
export default function Spinner({ className = 'm-auto' }) {
return (
<svg
stroke="currentColor"
stroke="#ffffff"
fill="none"
strokeWidth="2"
viewBox="0 0 24 24"
Loading