Add support for pagination in Chat Search
This commit is contained in:
parent
5998400a75
commit
5a30509fa6
|
@ -1,11 +1,9 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import AccountSearch from 'soapbox/components/account-search';
|
||||
import { CardTitle, HStack, IconButton, Stack, Text } from 'soapbox/components/ui';
|
||||
import { ChatKeys, useChats } from 'soapbox/queries/chats';
|
||||
import { queryClient } from 'soapbox/queries/client';
|
||||
import { CardTitle, HStack, IconButton, Stack } from 'soapbox/components/ui';
|
||||
|
||||
import ChatSearch from '../../chat-search/chat-search';
|
||||
|
||||
interface IChatPageNew {
|
||||
}
|
||||
|
@ -13,17 +11,10 @@ interface IChatPageNew {
|
|||
/** New message form to create a chat. */
|
||||
const ChatPageNew: React.FC<IChatPageNew> = () => {
|
||||
const history = useHistory();
|
||||
const { getOrCreateChatByAccountId } = useChats();
|
||||
|
||||
const handleAccountSelected = async (accountId: string) => {
|
||||
const { data } = await getOrCreateChatByAccountId(accountId);
|
||||
history.push(`/chats/${data.id}`);
|
||||
queryClient.invalidateQueries(ChatKeys.chatSearch());
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack className='h-full'>
|
||||
<Stack className='flex-grow py-6 px-4 sm:p-6 space-y-4'>
|
||||
<Stack className='h-full space-y-4'>
|
||||
<Stack className='flex-grow pt-6 px-4 sm:px-6'>
|
||||
<HStack alignItems='center'>
|
||||
<IconButton
|
||||
src={require('@tabler/icons/arrow-left.svg')}
|
||||
|
@ -33,26 +24,9 @@ const ChatPageNew: React.FC<IChatPageNew> = () => {
|
|||
|
||||
<CardTitle title='New Message' />
|
||||
</HStack>
|
||||
|
||||
<HStack space={2} alignItems='center'>
|
||||
<Text>
|
||||
<FormattedMessage
|
||||
id='chats.new.to'
|
||||
defaultMessage='To:'
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<AccountSearch
|
||||
onSelected={handleAccountSelected}
|
||||
placeholder='Type a name'
|
||||
theme='search'
|
||||
showButtons={false}
|
||||
autoFocus
|
||||
className='mb-0.5'
|
||||
followers
|
||||
/>
|
||||
</HStack>
|
||||
</Stack>
|
||||
|
||||
<ChatSearch isMainPage />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -13,6 +13,7 @@ import ChatSearch from '../chat-search/chat-search';
|
|||
import EmptyResultsBlankslate from '../chat-search/empty-results-blankslate';
|
||||
import ChatPaneHeader from '../chat-widget/chat-pane-header';
|
||||
import ChatWindow from '../chat-widget/chat-window';
|
||||
import ChatSearchHeader from '../chat-widget/headers/chat-search-header';
|
||||
import { Pane } from '../ui';
|
||||
|
||||
import Blankslate from './blankslate';
|
||||
|
@ -86,7 +87,13 @@ const ChatPane = () => {
|
|||
}
|
||||
|
||||
if (screen === ChatWidgetScreens.SEARCH) {
|
||||
return <ChatSearch />;
|
||||
return (
|
||||
<Pane isOpen={isOpen} index={0} main>
|
||||
<ChatSearchHeader />
|
||||
|
||||
{isOpen ? <ChatSearch /> : null}
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -29,6 +29,7 @@ const ChatSearchInput: React.FC<IChatSearchInput> = ({ value, onChange, onClear
|
|||
className='rounded-full'
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
outerClassName='mt-0'
|
||||
theme='search'
|
||||
append={
|
||||
<button onClick={onClear}>
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { useMutation } from '@tanstack/react-query';
|
||||
import { AxiosError } from 'axios';
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import snackbar from 'soapbox/actions/snackbar';
|
||||
import { HStack, Icon, Input, Stack, Text } from 'soapbox/components/ui';
|
||||
import { Icon, Input, Stack } from 'soapbox/components/ui';
|
||||
import { ChatWidgetScreens, useChatContext } from 'soapbox/contexts/chat-context';
|
||||
import { useAppDispatch, useDebounce } from 'soapbox/hooks';
|
||||
import { useChats } from 'soapbox/queries/chats';
|
||||
|
@ -12,29 +12,30 @@ import { queryClient } from 'soapbox/queries/client';
|
|||
import useAccountSearch from 'soapbox/queries/search';
|
||||
|
||||
import { ChatKeys } from '../../../../queries/chats';
|
||||
import ChatPaneHeader from '../chat-widget/chat-pane-header';
|
||||
import { Pane } from '../ui';
|
||||
|
||||
import Blankslate from './blankslate';
|
||||
import EmptyResultsBlankslate from './empty-results-blankslate';
|
||||
import Results from './results';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'chat_search.title', defaultMessage: 'Messages' },
|
||||
});
|
||||
interface IChatSearch {
|
||||
isMainPage?: boolean
|
||||
}
|
||||
|
||||
const ChatSearch = (props: IChatSearch) => {
|
||||
const { isMainPage = false } = props;
|
||||
|
||||
const ChatSearch = () => {
|
||||
const debounce = useDebounce;
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
|
||||
const { isOpen, changeScreen, toggleChatPane } = useChatContext();
|
||||
const { changeScreen } = useChatContext();
|
||||
const { getOrCreateChatByAccountId } = useChats();
|
||||
|
||||
const [value, setValue] = useState<string>('');
|
||||
const debouncedValue = debounce(value as string, 300);
|
||||
|
||||
const { data: accounts, isFetching } = useAccountSearch(debouncedValue);
|
||||
const accountSearchResult = useAccountSearch(debouncedValue);
|
||||
const { data: accounts, isFetching } = accountSearchResult;
|
||||
|
||||
const hasSearchValue = debouncedValue && debouncedValue.length > 0;
|
||||
const hasSearchResults = (accounts || []).length > 0;
|
||||
|
@ -47,7 +48,12 @@ const ChatSearch = () => {
|
|||
dispatch(snackbar.error(data?.error));
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
if (isMainPage) {
|
||||
history.push(`/chats/${response.data.id}`);
|
||||
} else {
|
||||
changeScreen(ChatWidgetScreens.CHAT, response.data.id);
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries(ChatKeys.chatSearch());
|
||||
},
|
||||
});
|
||||
|
@ -56,7 +62,7 @@ const ChatSearch = () => {
|
|||
if (hasSearchResults) {
|
||||
return (
|
||||
<Results
|
||||
accounts={accounts}
|
||||
accountSearchResult={accountSearchResult}
|
||||
onSelect={(id) => {
|
||||
handleClickOnSearchResult.mutate(id);
|
||||
clearValue();
|
||||
|
@ -77,33 +83,6 @@ const ChatSearch = () => {
|
|||
};
|
||||
|
||||
return (
|
||||
<Pane isOpen={isOpen} index={0} main>
|
||||
<ChatPaneHeader
|
||||
data-testid='pane-header'
|
||||
title={
|
||||
<HStack alignItems='center' space={2}>
|
||||
<button
|
||||
onClick={() => {
|
||||
changeScreen(ChatWidgetScreens.INBOX);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
src={require('@tabler/icons/arrow-left.svg')}
|
||||
className='h-6 w-6 text-gray-600 dark:text-gray-400'
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Text size='sm' weight='bold' truncate>
|
||||
{intl.formatMessage(messages.title)}
|
||||
</Text>
|
||||
</HStack>
|
||||
}
|
||||
isOpen={isOpen}
|
||||
isToggleable={false}
|
||||
onToggle={toggleChatPane}
|
||||
/>
|
||||
|
||||
{isOpen ? (
|
||||
<Stack space={4} className='flex-grow h-full'>
|
||||
<div className='px-4'>
|
||||
<Input
|
||||
|
@ -111,9 +90,9 @@ const ChatSearch = () => {
|
|||
type='text'
|
||||
autoFocus
|
||||
placeholder='Type a name'
|
||||
className='rounded-full'
|
||||
value={value || ''}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
outerClassName='mt-0'
|
||||
theme='search'
|
||||
append={
|
||||
<button onClick={clearValue}>
|
||||
|
@ -127,12 +106,10 @@ const ChatSearch = () => {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<Stack className='overflow-y-scroll flex-grow h-full' space={2}>
|
||||
<Stack className='flex-grow'>
|
||||
{renderBody()}
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Pane>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,26 +1,33 @@
|
|||
import React from 'react';
|
||||
import classNames from 'clsx';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
|
||||
import { Avatar, HStack, Stack, Text } from 'soapbox/components/ui';
|
||||
import VerificationBadge from 'soapbox/components/verification-badge';
|
||||
import useAccountSearch from 'soapbox/queries/search';
|
||||
|
||||
interface IResults {
|
||||
accounts: {
|
||||
display_name: string
|
||||
acct: string
|
||||
id: string
|
||||
avatar: string
|
||||
verified: boolean
|
||||
}[]
|
||||
accountSearchResult: ReturnType<typeof useAccountSearch>
|
||||
onSelect(id: string): void
|
||||
}
|
||||
|
||||
const Results = ({ accounts, onSelect }: IResults) => (
|
||||
<>
|
||||
{(accounts || []).map((account: any) => (
|
||||
const Results = ({ accountSearchResult, onSelect }: IResults) => {
|
||||
const { data: accounts, isFetching, hasNextPage, fetchNextPage } = accountSearchResult;
|
||||
|
||||
const [isNearBottom, setNearBottom] = useState<boolean>(false);
|
||||
const [isNearTop, setNearTop] = useState<boolean>(true);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (hasNextPage && !isFetching) {
|
||||
fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
const renderAccount = useCallback((_index, account) => (
|
||||
<button
|
||||
key={account.id}
|
||||
type='button'
|
||||
className='px-4 py-2 w-full flex flex-col hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
className='px-2 py-3 w-full rounded-lg flex flex-col hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
onClick={() => onSelect(account.id)}
|
||||
data-testid='account'
|
||||
>
|
||||
|
@ -36,8 +43,38 @@ const Results = ({ accounts, onSelect }: IResults) => (
|
|||
</Stack>
|
||||
</HStack>
|
||||
</button>
|
||||
))}
|
||||
), []);
|
||||
|
||||
return (
|
||||
<div className='relative flex-grow'>
|
||||
<Virtuoso
|
||||
data={accounts}
|
||||
itemContent={(index, chat) => (
|
||||
<div className='px-2'>
|
||||
{renderAccount(index, chat)}
|
||||
</div>
|
||||
)}
|
||||
endReached={handleLoadMore}
|
||||
atTopStateChange={(atTop) => setNearTop(atTop)}
|
||||
atBottomStateChange={(atBottom) => setNearBottom(atBottom)}
|
||||
/>
|
||||
|
||||
<>
|
||||
<div
|
||||
className={classNames('inset-x-0 top-0 flex rounded-t-lg justify-center bg-gradient-to-b from-white to-transparent pb-12 pt-8 pointer-events-none dark:from-gray-900 absolute transition-opacity duration-500', {
|
||||
'opacity-0': isNearTop,
|
||||
'opacity-100': !isNearTop,
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={classNames('inset-x-0 bottom-0 flex rounded-b-lg justify-center bg-gradient-to-t from-white to-transparent pt-12 pb-8 pointer-events-none dark:from-gray-900 absolute transition-opacity duration-500', {
|
||||
'opacity-0': isNearBottom,
|
||||
'opacity-100': !isNearBottom,
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Results;
|
|
@ -0,0 +1,46 @@
|
|||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { HStack, Icon, Text } from 'soapbox/components/ui';
|
||||
import { ChatWidgetScreens, useChatContext } from 'soapbox/contexts/chat-context';
|
||||
|
||||
import ChatPaneHeader from '../chat-pane-header';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'chat_search.title', defaultMessage: 'Messages' },
|
||||
});
|
||||
|
||||
const ChatSearchHeader = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const { changeScreen, isOpen, toggleChatPane } = useChatContext();
|
||||
|
||||
return (
|
||||
<ChatPaneHeader
|
||||
data-testid='pane-header'
|
||||
title={
|
||||
<HStack alignItems='center' space={2}>
|
||||
<button
|
||||
onClick={() => {
|
||||
changeScreen(ChatWidgetScreens.INBOX);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
src={require('@tabler/icons/arrow-left.svg')}
|
||||
className='h-6 w-6 text-gray-600 dark:text-gray-400'
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Text size='sm' weight='bold' truncate>
|
||||
{intl.formatMessage(messages.title)}
|
||||
</Text>
|
||||
</HStack>
|
||||
}
|
||||
isOpen={isOpen}
|
||||
isToggleable={false}
|
||||
onToggle={toggleChatPane}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatSearchHeader;
|
|
@ -1,27 +1,54 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
|
||||
import { getNextLink } from 'soapbox/api';
|
||||
import { useApi } from 'soapbox/hooks';
|
||||
import { Account } from 'soapbox/types/entities';
|
||||
import { PaginatedResult } from 'soapbox/utils/queries';
|
||||
|
||||
export default function useAccountSearch(q: string) {
|
||||
const api = useApi();
|
||||
|
||||
const getAccountSearch = async(q: string) => {
|
||||
if (typeof q === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const getAccountSearch = async(q: string, pageParam: { link?: string }): Promise<PaginatedResult<Account>> => {
|
||||
const nextPageLink = pageParam?.link;
|
||||
const uri = nextPageLink || '/api/v1/accounts/search';
|
||||
|
||||
const { data } = await api.get('/api/v1/accounts/search', {
|
||||
const response = await api.get(uri, {
|
||||
params: {
|
||||
q,
|
||||
limit: 10,
|
||||
followers: true,
|
||||
},
|
||||
});
|
||||
const { data } = response;
|
||||
|
||||
return data;
|
||||
const link = getNextLink(response);
|
||||
const hasMore = !!link;
|
||||
|
||||
return {
|
||||
result: data,
|
||||
link,
|
||||
hasMore,
|
||||
};
|
||||
};
|
||||
|
||||
return useQuery(['search', 'accounts', q], () => getAccountSearch(q), {
|
||||
const queryInfo = useInfiniteQuery(['search', 'accounts', q], ({ pageParam }) => getAccountSearch(q, pageParam), {
|
||||
keepPreviousData: true,
|
||||
placeholderData: [],
|
||||
});
|
||||
getNextPageParam: (config) => {
|
||||
if (config.hasMore) {
|
||||
return { link: config.link };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const data = queryInfo.data?.pages.reduce<Account[]>(
|
||||
(prev: Account[], curr) => [...prev, ...curr.result],
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
...queryInfo,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue