From faecfc5028667fc48ee6f061ab3217ef9f9dc568 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sun, 9 Feb 2025 23:13:58 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20fix:=20search=20functionality=20and?= =?UTF-8?q?=20bugs=20with=20loadMoreConversations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/models/Conversation.js | 44 ++++---- api/server/routes/search.js | 62 +++++++---- client/src/components/Nav/Nav.tsx | 105 +++++++++++------- client/src/components/Nav/SearchBar.tsx | 10 +- client/src/data-provider/queries.ts | 27 +++-- client/src/hooks/Conversations/useSearch.ts | 13 +-- client/src/hooks/Nav/useNavScrolling.ts | 39 ++++--- client/src/routes/Search.tsx | 25 +++-- packages/data-provider/src/api-endpoints.ts | 10 +- packages/data-provider/src/data-service.ts | 20 ++-- .../src/react-query/react-query-service.ts | 17 --- packages/data-provider/src/types/queries.ts | 14 +++ 12 files changed, 227 insertions(+), 159 deletions(-) diff --git a/api/models/Conversation.js b/api/models/Conversation.js index ea7315fa285..a9f2e02eb80 100644 --- a/api/models/Conversation.js +++ b/api/models/Conversation.js @@ -182,13 +182,12 @@ module.exports = { return { message: 'Error getting conversations' }; } }, - getConvosQueried: async (user, convoIds, pageNumber = 1, pageSize = 25) => { + getConvosQueried: async (user, convoIds, cursor = null, limit = 25) => { try { if (!convoIds || convoIds.length === 0) { - return { conversations: [], pages: 1, pageNumber, pageSize }; + return { conversations: [], nextCursor: null, convoMap: {} }; } - const cache = {}; const convoMap = {}; const promises = []; @@ -202,28 +201,33 @@ module.exports = { ), ); + // Fetch all matching conversations and filter out any falsy results const results = (await Promise.all(promises)).filter(Boolean); - results.forEach((convo, i) => { - const page = Math.floor(i / pageSize) + 1; - if (!cache[page]) { - cache[page] = []; - } - cache[page].push(convo); + // Sort conversations by updatedAt descending (most recent first) + results.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); + + // If a cursor is provided and not "start", filter out recrods newer or equal to the cursor date + let filtered = results; + if (cursor && cursor !== 'start') { + const cursorDate = new Date(cursor); + filtered = results.filter((convo) => new Date(convo.updatedAt) < cursorDate); + } + + // Retrieve limit + 1 results to determine if there's a next page. + const limited = filtered.slice(0, limit + 1); + let nextCursor = null; + if (limited.length > limit) { + const lastConvo = limited.pop(); + nextCursor = lastConvo.updatedAt.toISOString(); + } + + // Build convoMap for ease of access if required by caller + limited.forEach((convo) => { convoMap[convo.conversationId] = convo; }); - const totalPages = Math.ceil(results.length / pageSize); - cache.pages = totalPages; - cache.pageSize = pageSize; - return { - cache, - conversations: cache[pageNumber] || [], - pages: totalPages || 1, - pageNumber, - pageSize, - convoMap, - }; + return { conversations: limited, nextCursor, convoMap }; } catch (error) { logger.error('[getConvosQueried] Error getting conversations', error); return { message: 'Error fetching conversations' }; diff --git a/api/server/routes/search.js b/api/server/routes/search.js index 68cff7532b8..abdd66a1fa0 100644 --- a/api/server/routes/search.js +++ b/api/server/routes/search.js @@ -27,48 +27,72 @@ router.get('/sync', async function (req, res) { router.get('/', async function (req, res) { try { - let user = req.user.id ?? ''; - const { q } = req.query; - const pageNumber = req.query.pageNumber || 1; - const key = `${user}:search:${q}`; + const user = req.user.id ?? ''; + const { q, cursor = 'start' } = req.query; + const key = `${user}:search:${q}:${cursor}`; const cached = await cache.get(key); if (cached) { logger.debug('[/search] cache hit: ' + key); - const { pages, pageSize, messages } = cached; - res - .status(200) - .send({ conversations: cached[pageNumber], pages, pageNumber, pageSize, messages }); - return; + return res.status(200).send(cached); } - const messages = (await Message.meiliSearch(q, undefined, true)).hits; - const titles = (await Conversation.meiliSearch(q)).hits; + const [messageResults, titleResults] = await Promise.all([ + Message.meiliSearch(q, undefined, true), + Conversation.meiliSearch(q), + ]); + const messages = messageResults.hits; + const titles = titleResults.hits; const sortedHits = reduceHits(messages, titles); - const result = await getConvosQueried(user, sortedHits, pageNumber); + const result = await getConvosQueried(user, sortedHits, cursor); const activeMessages = []; for (let i = 0; i < messages.length; i++) { let message = messages[i]; + if (message.conversationId.includes('--')) { message.conversationId = cleanUpPrimaryKeyValue(message.conversationId); } + if (result.convoMap[message.conversationId]) { const convo = result.convoMap[message.conversationId]; - const { title, chatGptLabel, model } = convo; - message = { ...message, ...{ title, chatGptLabel, model } }; - activeMessages.push(message); + activeMessages.push({ + ...message, + title: convo.title, + conversationId: message.conversationId, + cleanedConversationId: cleanUpPrimaryKeyValue(message.conversationId), + searchMetadata: { + matchScore: message._score, + highlightedContent: message._formatted?.text, + matchedTerms: message._matchesInfo, + }, + }); } } - result.messages = activeMessages; + + const activeConversations = []; + for (const convId in result.convoMap) { + const convo = result.convoMap[convId]; + activeConversations.push({ + title: convo.title, + user: convo.user, + conversationId: convo.conversationId, + }); + } + if (result.cache) { result.cache.messages = activeMessages; + result.cache.conversations = activeConversations; cache.set(key, result.cache, expiration); - delete result.cache; } - delete result.convoMap; - res.status(200).send(result); + const response = { + nextCursor: result.nextCursor ?? null, + messages: activeMessages, + conversations: activeConversations, + }; + + res.status(200).send(response); } catch (error) { logger.error('[/search] Error while searching messages & conversations', error); res.status(500).send({ message: 'Error searching' }); diff --git a/client/src/components/Nav/Nav.tsx b/client/src/components/Nav/Nav.tsx index a25e2d2fc43..a1e04927a7b 100644 --- a/client/src/components/Nav/Nav.tsx +++ b/client/src/components/Nav/Nav.tsx @@ -1,7 +1,11 @@ import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense } from 'react'; import { useRecoilValue } from 'recoil'; import { PermissionTypes, Permissions } from 'librechat-data-provider'; -import type { ConversationListResponse } from 'librechat-data-provider'; +import type { + TConversation, + ConversationListResponse, + SearchConversationListResponse, +} from 'librechat-data-provider'; import { useLocalize, useHasAccess, @@ -69,37 +73,67 @@ const Nav = memo( }); const isSearchEnabled = useRecoilValue(store.isSearchEnabled); - const { searchQuery, setPageNumber, searchQueryRes } = useSearchContext(); - - const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch } = - useConversationsInfiniteQuery( - { - cursor: null, - isArchived: false, - tags: tags.length === 0 ? undefined : tags, - }, - { - enabled: isAuthenticated, - staleTime: 30000, - cacheTime: 300000, - }, - ); - - const { containerRef, moveToTop } = useNavScrolling({ + const { searchQuery, searchQueryRes } = useSearchContext(); + + const { data, fetchNextPage, isFetchingNextPage, refetch } = useConversationsInfiniteQuery( + { + cursor: null, + isArchived: false, + tags: tags.length === 0 ? undefined : tags, + }, + { + enabled: isAuthenticated, + staleTime: 30000, + cacheTime: 300000, + }, + ); + + const computedHasNextPage = useMemo(() => { + if (searchQuery && searchQueryRes?.data) { + const pages = searchQueryRes.data.pages; + return pages[pages.length - 1]?.nextCursor !== null; + } else if (data?.pages && data.pages.length > 0) { + const lastPage: ConversationListResponse = data.pages[data.pages.length - 1]; + return lastPage.nextCursor !== null; + } + return false; + }, [searchQuery, searchQueryRes?.data, data?.pages]); + + const { containerRef, moveToTop } = useNavScrolling< + ConversationListResponse | SearchConversationListResponse + >({ setShowLoading, - hasNextPage: searchQuery ? searchQueryRes?.hasNextPage : hasNextPage, - fetchNextPage: searchQuery ? searchQueryRes?.fetchNextPage : fetchNextPage, - isFetchingNextPage: searchQuery + fetchNextPage: async (options?) => { + if (computedHasNextPage) { + if (searchQuery && searchQueryRes) { + const pages = searchQueryRes.data?.pages; + if (pages && pages.length > 0 && pages[pages.length - 1]?.nextCursor !== null) { + return searchQueryRes.fetchNextPage(options); + } + } else { + return fetchNextPage(options); + } + } + return Promise.resolve( + {} as InfiniteQueryObserverResult< + SearchConversationListResponse | ConversationListResponse, + unknown + >, + ); + }, + isFetchingNext: searchQuery ? searchQueryRes?.isFetchingNextPage ?? false : isFetchingNextPage, }); - const conversations = useMemo( - () => - (searchQuery ? searchQueryRes?.data : data)?.pages.flatMap((page) => page.conversations) || - [], - [data, searchQuery, searchQueryRes?.data], - ); + const conversations = useMemo(() => { + if (searchQuery && searchQueryRes?.data) { + return searchQueryRes.data.pages.flatMap( + (page) => page.conversations ?? [], + ) as TConversation[]; + } + return data ? data.pages.flatMap((page) => page.conversations) : []; + }, [data, searchQuery, searchQueryRes?.data]); const toggleNavVisible = useCallback(() => { setNavVisible((prev: boolean) => { @@ -134,22 +168,17 @@ const Nav = memo( }, [tags, refetch]); const loadMoreConversations = useCallback(() => { - if (isFetchingNextPage) { + if (isFetchingNextPage || !computedHasNextPage) { return; } - if (searchQuery && searchQueryRes?.hasNextPage) { - searchQueryRes.fetchNextPage(); - } else if (hasNextPage) { - fetchNextPage(); - } - }, [isFetchingNextPage, searchQuery, searchQueryRes, hasNextPage, fetchNextPage]); + + fetchNextPage(); + }, [isFetchingNextPage, computedHasNextPage, fetchNextPage]); const subHeaders = useMemo( () => ( <> - {isSearchEnabled && ( - - )} + {isSearchEnabled === true && } {hasAccessToBookmarks && ( <>
@@ -160,7 +189,7 @@ const Nav = memo( )} ), - [isSearchEnabled, hasAccessToBookmarks, setPageNumber, isSmallScreen, tags, setTags], + [isSearchEnabled, hasAccessToBookmarks, isSmallScreen, tags, setTags], ); return ( diff --git a/client/src/components/Nav/SearchBar.tsx b/client/src/components/Nav/SearchBar.tsx index 10ebca3ff86..80c43baa67b 100644 --- a/client/src/components/Nav/SearchBar.tsx +++ b/client/src/components/Nav/SearchBar.tsx @@ -11,14 +11,13 @@ import store from '~/store'; type SearchBarProps = { isSmallScreen?: boolean; - setPageNumber: React.Dispatch>; }; const SearchBar = forwardRef((props: SearchBarProps, ref: Ref) => { const localize = useLocalize(); const location = useLocation(); const queryClient = useQueryClient(); - const { setPageNumber, isSmallScreen } = props; + const { isSmallScreen } = props; const [text, setText] = useState(''); const [showClearIcon, setShowClearIcon] = useState(false); @@ -29,11 +28,10 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref) = const setIsSearching = useSetRecoilState(store.isSearching); const clearSearch = useCallback(() => { - setPageNumber(1); if (location.pathname.includes('/search')) { newConversation({ disableFocus: true }); } - }, [newConversation, setPageNumber, location.pathname]); + }, [newConversation, location.pathname]); const clearText = useCallback(() => { setShowClearIcon(false); @@ -80,9 +78,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref) = isSmallScreen === true ? 'mb-2 h-14 rounded-2xl' : '', )} > - { - - } + , + params?: SearchConversationListParams, + config?: UseInfiniteQueryOptions, ) => { - return useInfiniteQuery( - [QueryKeys.searchConversations, params], // Include the searchQuery in the query key - ({ pageParam = '1' }) => - dataService.listConversationsByQuery({ ...params, pageNumber: pageParam }), + return useInfiniteQuery( + [QueryKeys.searchConversations, params], + ({ pageParam = null }) => + dataService + .listConversationsByQuery({ + ...params, + nextCursor: pageParam, + pageSize: params?.pageSize ?? 20, + search: params?.search ?? '', + }) + .then((res) => ({ ...res })) as Promise, { - getNextPageParam: (lastPage) => { - const currentPageNumber = Number(lastPage.pageNumber); - const totalPages = Number(lastPage.pages); - return currentPageNumber < totalPages ? currentPageNumber + 1 : undefined; - }, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, refetchOnWindowFocus: false, refetchOnReconnect: false, refetchOnMount: false, diff --git a/client/src/hooks/Conversations/useSearch.ts b/client/src/hooks/Conversations/useSearch.ts index 753f9d9822b..99b5ad37238 100644 --- a/client/src/hooks/Conversations/useSearch.ts +++ b/client/src/hooks/Conversations/useSearch.ts @@ -1,8 +1,8 @@ -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useCallback } from 'react'; import { useRecoilValue, useSetRecoilState } from 'recoil'; import { useNavigate, useLocation } from 'react-router-dom'; import type { UseInfiniteQueryResult } from '@tanstack/react-query'; -import type { ConversationListResponse } from 'librechat-data-provider'; +import type { SearchConversationListResponse } from 'librechat-data-provider'; import { useSearchInfiniteQuery, useGetSearchEnabledQuery } from '~/data-provider'; import useNewConvo from '~/hooks/useNewConvo'; import store from '~/store'; @@ -10,7 +10,6 @@ import store from '~/store'; export default function useSearchMessages({ isAuthenticated }: { isAuthenticated: boolean }) { const navigate = useNavigate(); const location = useLocation(); - const [pageNumber, setPageNumber] = useState(1); const { switchToConversation } = useNewConvo(); const searchPlaceholderConversation = useCallback(() => { switchToConversation({ @@ -27,9 +26,9 @@ export default function useSearchMessages({ isAuthenticated }: { isAuthenticated const searchEnabledQuery = useGetSearchEnabledQuery({ enabled: isAuthenticated }); const searchQueryRes = useSearchInfiniteQuery( - { pageNumber: pageNumber.toString(), searchQuery: searchQuery, isArchived: false }, + { nextCursor: null, search: searchQuery, pageSize: 20 }, { enabled: isAuthenticated && !!searchQuery.length }, - ) as UseInfiniteQueryResult | undefined; + ) as UseInfiniteQueryResult | undefined; useEffect(() => { if (searchQuery && searchQuery.length > 0) { @@ -64,16 +63,14 @@ export default function useSearchMessages({ isAuthenticated }: { isAuthenticated ); useEffect(() => { - //we use isInitialLoading here instead of isLoading because query is disabled by default + // we use isInitialLoading here instead of isLoading because query is disabled by default if (searchQueryRes?.data) { onSearchSuccess(); } }, [searchQueryRes?.data, searchQueryRes?.isInitialLoading, onSearchSuccess]); return { - pageNumber, searchQuery, - setPageNumber, searchQueryRes, }; } diff --git a/client/src/hooks/Nav/useNavScrolling.ts b/client/src/hooks/Nav/useNavScrolling.ts index f32154cb088..6c7621c73a2 100644 --- a/client/src/hooks/Nav/useNavScrolling.ts +++ b/client/src/hooks/Nav/useNavScrolling.ts @@ -3,26 +3,33 @@ import React, { useCallback, useEffect, useRef } from 'react'; import type { FetchNextPageOptions, InfiniteQueryObserverResult } from '@tanstack/react-query'; export default function useNavScrolling({ - hasNextPage, - isFetchingNextPage, + nextCursor, + isFetchingNext, setShowLoading, fetchNextPage, }: { - hasNextPage?: boolean; - isFetchingNextPage: boolean; + nextCursor?: string | null; + isFetchingNext: boolean; setShowLoading: React.Dispatch>; - fetchNextPage: - | (( - options?: FetchNextPageOptions | undefined, - ) => Promise>) - | undefined; + fetchNextPage?: ( + options?: FetchNextPageOptions | undefined, + ) => Promise>; }) { const scrollPositionRef = useRef(null); const containerRef = useRef(null); // eslint-disable-next-line react-hooks/exhaustive-deps const fetchNext = useCallback( - throttle(() => (fetchNextPage != null ? fetchNextPage() : () => ({})), 750, { leading: true }), + throttle( + () => { + if (fetchNextPage) { + return fetchNextPage(); + } + return Promise.resolve(); + }, + 750, + { leading: true }, + ), [fetchNextPage], ); @@ -31,14 +38,14 @@ export default function useNavScrolling({ const { scrollTop, clientHeight, scrollHeight } = containerRef.current; const nearBottomOfList = scrollTop + clientHeight >= scrollHeight * 0.97; - if (nearBottomOfList && hasNextPage === true && !isFetchingNextPage) { + if (nearBottomOfList && nextCursor != null && !isFetchingNext) { setShowLoading(true); fetchNext(); } else { setShowLoading(false); } } - }, [hasNextPage, isFetchingNextPage, fetchNext, setShowLoading]); + }, [nextCursor, isFetchingNext, fetchNext, setShowLoading]); useEffect(() => { const container = containerRef.current; @@ -47,16 +54,18 @@ export default function useNavScrolling({ } return () => { - container?.removeEventListener('scroll', handleScroll); + if (container) { + container.removeEventListener('scroll', handleScroll); + } }; - }, [handleScroll, fetchNext]); + }, [handleScroll]); const moveToTop = useCallback(() => { const container = containerRef.current; if (container) { scrollPositionRef.current = container.scrollTop; } - }, [containerRef, scrollPositionRef]); + }, []); return { containerRef, diff --git a/client/src/routes/Search.tsx b/client/src/routes/Search.tsx index 5d944a6fe35..ea9a3facb6c 100644 --- a/client/src/routes/Search.tsx +++ b/client/src/routes/Search.tsx @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import type { FetchNextPageOptions } from '@tanstack/react-query'; import MinimalMessagesWrapper from '~/components/Chat/Messages/MinimalMessages'; import SearchMessage from '~/components/Chat/Messages/SearchMessage'; import { useSearchContext, useFileMapContext } from '~/Providers'; @@ -11,10 +12,12 @@ export default function Search() { const { searchQuery, searchQueryRes } = useSearchContext(); const { containerRef } = useNavScrolling({ + nextCursor: searchQueryRes?.data?.pages[searchQueryRes.data.pages.length - 1]?.nextCursor, setShowLoading: () => ({}), - hasNextPage: searchQueryRes?.hasNextPage, - fetchNextPage: searchQueryRes?.fetchNextPage, - isFetchingNextPage: searchQueryRes?.isFetchingNextPage ?? false, + fetchNextPage: searchQueryRes?.fetchNextPage + ? (options?: FetchNextPageOptions) => searchQueryRes.fetchNextPage(options) + : undefined, + isFetchingNext: searchQueryRes?.isFetchingNextPage ?? false, }); const messages = useMemo(() => { @@ -27,14 +30,22 @@ export default function Search() { return null; } + if (searchQueryRes.isInitialLoading) { + return null; + } + + console.log('Search -> messages', searchQueryRes); + return ( - + {(messages && messages.length == 0) || messages == null ? ( -
- {localize('com_ui_nothing_found')} +
+
+ {localize('com_ui_nothing_found')} +
) : ( - messages.map((message) => ) + messages.map((msg) => ) )}
diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index c6a94ca4ab3..72c107ce174 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -44,12 +44,12 @@ export const abortRequest = (endpoint: string) => `/api/ask/${endpoint}/abort`; export const conversationsRoot = '/api/convos'; export const conversations = ( - cursor: string | null, + nextCursor: string | null, isArchived?: boolean, order?: string, tags?: string[], ) => - `${conversationsRoot}?${cursor ? `cursor=${cursor}` : ''}${ + `${conversationsRoot}?${nextCursor ? `cursor=${nextCursor}` : ''}${ isArchived === true ? '&isArchived=true' : '' }${order ? `&order=${order}` : ''}${tags?.map((tag) => `&tags=${tag}`).join('')}`; @@ -67,8 +67,10 @@ export const forkConversation = () => `${conversationsRoot}/fork`; export const duplicateConversation = () => `${conversationsRoot}/duplicate`; -export const search = (q: string, cursor?: string | null) => - `/api/search?q=${q}${cursor ? `&cursor=${cursor}` : ''}`; +export const search = (q: string, pageSize?: number, nextCursor?: string | null) => + `/api/search?q=${q}${pageSize ? `&pageSize=${pageSize}` : ''}${ + nextCursor ? `&cursor=${nextCursor}` : '' + }`; export const searchEnabled = () => '/api/search/enable'; diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index c2ddc8215f4..dc6d8c9eea9 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -608,24 +608,18 @@ export const listConversations = ( }; export const listConversationsByQuery = ( - params?: q.ConversationListParams & { searchQuery?: string }, -): Promise => { - const cursor = params?.cursor ?? null; - const searchQuery = params?.searchQuery ?? ''; + params?: q.SearchConversationListParams, +): Promise => { + const nextCursor = params?.nextCursor ?? null; + const pageSize = params?.pageSize ?? 10; + const searchQuery = params?.search ?? ''; if (searchQuery !== '') { - return request.get(endpoints.search(searchQuery, cursor ?? '')); + return request.get(endpoints.search(searchQuery, pageSize, nextCursor)); } else { - return request.get(endpoints.conversations(cursor ?? '')); + return request.get(endpoints.conversations(nextCursor ?? '')); } }; -export const searchConversations = async ( - q: string, - pageNumber: string, -): Promise => { - return request.get(endpoints.search(q, pageNumber)); -}; - export function getConversations(cursor: string | null): Promise { return request.get(endpoints.conversations(cursor)); } diff --git a/packages/data-provider/src/react-query/react-query-service.ts b/packages/data-provider/src/react-query/react-query-service.ts index 03a37d99a7f..949ddf8eef5 100644 --- a/packages/data-provider/src/react-query/react-query-service.ts +++ b/packages/data-provider/src/react-query/react-query-service.ts @@ -242,23 +242,6 @@ export const useDeletePresetMutation = (): UseMutationResult< }); }; -export const useSearchQuery = ( - searchQuery: string, - pageNumber: string, - config?: UseQueryOptions, -): QueryObserverResult => { - return useQuery( - [QueryKeys.searchResults, pageNumber, searchQuery], - () => dataService.searchConversations(searchQuery, pageNumber), - { - refetchOnWindowFocus: false, - refetchOnReconnect: false, - refetchOnMount: false, - ...config, - }, - ); -}; - export const useUpdateTokenCountMutation = (): UseMutationResult< t.TUpdateTokenCountResponse, unknown, diff --git a/packages/data-provider/src/types/queries.ts b/packages/data-provider/src/types/queries.ts index 22847ddeed2..1e3d3bea519 100644 --- a/packages/data-provider/src/types/queries.ts +++ b/packages/data-provider/src/types/queries.ts @@ -28,6 +28,20 @@ export type ConversationListResponse = { nextCursor: string | null; }; +export type SearchConversationListParams = { + nextCursor?: string | null; + pageSize?: number; + search: string; +}; + +export type SearchConversation = Pick; + +export type SearchConversationListResponse = { + conversations: SearchConversation[]; + messages: s.TMessage[]; + nextCursor: string | null; +}; + export type ConversationData = InfiniteData; export type ConversationUpdater = ( data: ConversationData,