Merge branch 'chat-search-pagination' into 'develop'

Improve Chats search

See merge request soapbox-pub/soapbox!1993
This commit is contained in:
Alex Gleason 2022-12-12 19:27:17 +00:00
commit 90513f1807
18 changed files with 286 additions and 237 deletions

View File

@ -5,7 +5,6 @@ import { defineMessages, useIntl } from 'react-intl';
import AutosuggestAccountInput from 'soapbox/components/autosuggest-account-input'; import AutosuggestAccountInput from 'soapbox/components/autosuggest-account-input';
import SvgIcon from './ui/icon/svg-icon'; import SvgIcon from './ui/icon/svg-icon';
import { InputThemes } from './ui/input/input';
const messages = defineMessages({ const messages = defineMessages({
placeholder: { id: 'account_search.placeholder', defaultMessage: 'Search for an account' }, placeholder: { id: 'account_search.placeholder', defaultMessage: 'Search for an account' },
@ -16,20 +15,10 @@ interface IAccountSearch {
onSelected: (accountId: string) => void, onSelected: (accountId: string) => void,
/** Override the default placeholder of the input. */ /** Override the default placeholder of the input. */
placeholder?: string, placeholder?: string,
/** Position of results relative to the input. */
resultsPosition?: 'above' | 'below',
/** Optional class for the input */
className?: string,
autoFocus?: boolean,
hidePortal?: boolean,
theme?: InputThemes,
showButtons?: boolean,
/** Search only among people who follow you (TruthSocial). */
followers?: boolean,
} }
/** Input to search for accounts. */ /** Input to search for accounts. */
const AccountSearch: React.FC<IAccountSearch> = ({ onSelected, className, showButtons = true, ...rest }) => { const AccountSearch: React.FC<IAccountSearch> = ({ onSelected, ...rest }) => {
const intl = useIntl(); const intl = useIntl();
const [value, setValue] = useState(''); const [value, setValue] = useState('');
@ -71,7 +60,7 @@ const AccountSearch: React.FC<IAccountSearch> = ({ onSelected, className, showBu
<div className='relative'> <div className='relative'>
<AutosuggestAccountInput <AutosuggestAccountInput
className={classNames('rounded-full', className)} className='rounded-full'
placeholder={intl.formatMessage(messages.placeholder)} placeholder={intl.formatMessage(messages.placeholder)}
value={value} value={value}
onChange={handleChange} onChange={handleChange}
@ -80,7 +69,6 @@ const AccountSearch: React.FC<IAccountSearch> = ({ onSelected, className, showBu
{...rest} {...rest}
/> />
{showButtons && (
<div <div
role='button' role='button'
tabIndex={0} tabIndex={0}
@ -98,7 +86,6 @@ const AccountSearch: React.FC<IAccountSearch> = ({ onSelected, className, showBu
aria-label={intl.formatMessage(messages.placeholder)} aria-label={intl.formatMessage(messages.placeholder)}
/> />
</div> </div>
)}
</div> </div>
</div> </div>
); );

View File

@ -22,8 +22,6 @@ interface IAutosuggestAccountInput {
menu?: Menu, menu?: Menu,
onKeyDown?: React.KeyboardEventHandler, onKeyDown?: React.KeyboardEventHandler,
theme?: InputThemes, theme?: InputThemes,
/** Search only among people who follow you (TruthSocial). */
followers?: boolean,
} }
const AutosuggestAccountInput: React.FC<IAutosuggestAccountInput> = ({ const AutosuggestAccountInput: React.FC<IAutosuggestAccountInput> = ({
@ -31,7 +29,6 @@ const AutosuggestAccountInput: React.FC<IAutosuggestAccountInput> = ({
onSelected, onSelected,
value = '', value = '',
limit = 4, limit = 4,
followers = false,
...rest ...rest
}) => { }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -48,7 +45,7 @@ const AutosuggestAccountInput: React.FC<IAutosuggestAccountInput> = ({
}; };
const handleAccountSearch = useCallback(throttle(q => { const handleAccountSearch = useCallback(throttle(q => {
const params = { q, limit, followers, resolve: false }; const params = { q, limit, resolve: false };
dispatch(accountSearch(params, controller.current.signal)) dispatch(accountSearch(params, controller.current.signal))
.then((accounts: { id: string }[]) => { .then((accounts: { id: string }[]) => {

View File

@ -31,7 +31,6 @@ export interface IAutosuggestInput extends Pick<React.HTMLAttributes<HTMLInputEl
searchTokens: string[], searchTokens: string[],
maxLength?: number, maxLength?: number,
menu?: Menu, menu?: Menu,
resultsPosition: string,
renderSuggestion?: React.FC<{ id: string }>, renderSuggestion?: React.FC<{ id: string }>,
hidePortal?: boolean, hidePortal?: boolean,
theme?: InputThemes, theme?: InputThemes,
@ -43,7 +42,6 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
autoFocus: false, autoFocus: false,
autoSelect: true, autoSelect: true,
searchTokens: ImmutableList(['@', ':', '#']), searchTokens: ImmutableList(['@', ':', '#']),
resultsPosition: 'below',
}; };
getFirstIndex = () => { getFirstIndex = () => {
@ -260,19 +258,15 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
const { top, height, left, width } = this.input.getBoundingClientRect(); const { top, height, left, width } = this.input.getBoundingClientRect();
if (this.props.resultsPosition === 'below') {
return { left, width, top: top + height }; return { left, width, top: top + height };
} }
return { left, width, top, transform: 'translate(0, -100%)' };
}
render() { render() {
const { hidePortal, value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength, menu, theme } = this.props; const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength, menu, theme } = this.props;
const { suggestionsHidden } = this.state; const { suggestionsHidden } = this.state;
const style: React.CSSProperties = { direction: 'ltr' }; const style: React.CSSProperties = { direction: 'ltr' };
const visible = !hidePortal && !suggestionsHidden && (!suggestions.isEmpty() || (menu && value)); const visible = !suggestionsHidden && (!suggestions.isEmpty() || (menu && value));
if (isRtl(value)) { if (isRtl(value)) {
style.direction = 'rtl'; style.direction = 'rtl';

View File

@ -151,6 +151,9 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
return ( return (
<div className='mt-auto px-4 shadow-3xl'> <div className='mt-auto px-4 shadow-3xl'>
{/* Spacer */}
<div className='h-5' />
<HStack alignItems='stretch' justifyContent='between' space={4}> <HStack alignItems='stretch' justifyContent='between' space={4}>
{features.chatsMedia && ( {features.chatsMedia && (
<Stack justifyContent='end' alignItems='center' className='w-10 mb-1.5'> <Stack justifyContent='end' alignItems='center' className='w-10 mb-1.5'>

View File

@ -4,7 +4,7 @@ import { useHistory } from 'react-router-dom';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import RelativeTimestamp from 'soapbox/components/relative-timestamp'; import RelativeTimestamp from 'soapbox/components/relative-timestamp';
import { Avatar, HStack, Stack, Text } from 'soapbox/components/ui'; import { Avatar, HStack, IconButton, Stack, Text } from 'soapbox/components/ui';
import VerificationBadge from 'soapbox/components/verification-badge'; import VerificationBadge from 'soapbox/components/verification-badge';
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container'; import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
import { useChatContext } from 'soapbox/contexts/chat-context'; import { useChatContext } from 'soapbox/contexts/chat-context';
@ -115,12 +115,14 @@ const ChatListItem: React.FC<IChatListItemInterface> = ({ chat, onClick }) => {
<HStack alignItems='center' space={2}> <HStack alignItems='center' space={2}>
{features.chatsDelete && ( {features.chatsDelete && (
<div className='text-gray-600 hidden group-hover:block hover:text-gray-100'> <div className='text-gray-600 hidden group-hover:block hover:text-gray-100'>
{/* TODO: fix nested buttons here */} <DropdownMenuContainer items={menu}>
<DropdownMenuContainer <IconButton
items={menu}
src={require('@tabler/icons/dots.svg')} src={require('@tabler/icons/dots.svg')}
title='Settings' title='Settings'
className='text-gray-600 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-500'
iconClassName='w-4 h-4'
/> />
</DropdownMenuContainer>
</div> </div>
)} )}

View File

@ -63,8 +63,7 @@ const ChatList: React.FC<IChatList> = ({ onClickChat, useWindowScroll = false, s
<div className='px-2'> <div className='px-2'>
<ChatListItem chat={chat} onClick={onClickChat} /> <ChatListItem chat={chat} onClick={onClickChat} />
</div> </div>
) )}
}
components={{ components={{
ScrollSeekPlaceholder: () => <PlaceholderChat />, ScrollSeekPlaceholder: () => <PlaceholderChat />,
Footer: () => hasNextPage ? <Spinner withText={false} /> : null, Footer: () => hasNextPage ? <Spinner withText={false} /> : null,

View File

@ -8,7 +8,7 @@ import { Components, Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { initReport } from 'soapbox/actions/reports'; import { initReport } from 'soapbox/actions/reports';
import { Avatar, Button, Divider, HStack, Icon, Spinner, Stack, Text } from 'soapbox/components/ui'; import { Avatar, Button, Divider, HStack, Icon, IconButton, Spinner, Stack, Text } from 'soapbox/components/ui';
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container'; import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
import emojify from 'soapbox/features/emoji/emoji'; import emojify from 'soapbox/features/emoji/emoji';
import PlaceholderChatMessage from 'soapbox/features/placeholder/components/placeholder-chat-message'; import PlaceholderChatMessage from 'soapbox/features/placeholder/components/placeholder-chat-message';
@ -286,11 +286,14 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
})} })}
data-testid='chat-message-menu' data-testid='chat-message-menu'
> >
<DropdownMenuContainer <DropdownMenuContainer items={menu}>
items={menu} <IconButton
src={require('@tabler/icons/dots.svg')} src={require('@tabler/icons/dots.svg')}
title={intl.formatMessage(messages.more)} title={intl.formatMessage(messages.more)}
className='text-gray-600 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-500'
iconClassName='w-4 h-4'
/> />
</DropdownMenuContainer>
</div> </div>
)} )}
@ -447,7 +450,7 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
return ( return (
<div className='h-full flex flex-col flex-grow space-y-6'> <div className='h-full flex flex-col flex-grow space-y-6'>
<div className='flex-grow flex flex-col justify-end pb-2'> <div className='flex-grow flex flex-col justify-end'>
<Virtuoso <Virtuoso
ref={node} ref={node}
alignToBottom alignToBottom

View File

@ -183,11 +183,6 @@ const ChatPageMain = () => {
label={intl.formatMessage(messages.autoDeleteLabel)} label={intl.formatMessage(messages.autoDeleteLabel)}
hint={intl.formatMessage(messages.autoDeleteHint)} hint={intl.formatMessage(messages.autoDeleteHint)}
/> />
<ListItem
label={intl.formatMessage(messages.autoDelete2Minutes)}
onSelect={() => handleUpdateChat(MessageExpirationValues.TWO_MINUTES)}
isSelected={chat.message_expiration === MessageExpirationValues.TWO_MINUTES}
/>
<ListItem <ListItem
label={intl.formatMessage(messages.autoDelete7Days)} label={intl.formatMessage(messages.autoDelete7Days)}
onSelect={() => handleUpdateChat(MessageExpirationValues.SEVEN)} onSelect={() => handleUpdateChat(MessageExpirationValues.SEVEN)}

View File

@ -1,11 +1,9 @@
import React from 'react'; import React from 'react';
import { FormattedMessage } from 'react-intl';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import AccountSearch from 'soapbox/components/account-search'; import { CardTitle, HStack, IconButton, Stack } from 'soapbox/components/ui';
import { CardTitle, HStack, IconButton, Stack, Text } from 'soapbox/components/ui';
import { ChatKeys, useChats } from 'soapbox/queries/chats'; import ChatSearch from '../../chat-search/chat-search';
import { queryClient } from 'soapbox/queries/client';
interface IChatPageNew { interface IChatPageNew {
} }
@ -13,17 +11,10 @@ interface IChatPageNew {
/** New message form to create a chat. */ /** New message form to create a chat. */
const ChatPageNew: React.FC<IChatPageNew> = () => { const ChatPageNew: React.FC<IChatPageNew> = () => {
const history = useHistory(); 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 ( return (
<Stack className='h-full'> <Stack className='h-full space-y-4'>
<Stack className='flex-grow py-6 px-4 sm:p-6 space-y-4'> <Stack className='flex-grow pt-6 px-4 sm:px-6'>
<HStack alignItems='center'> <HStack alignItems='center'>
<IconButton <IconButton
src={require('@tabler/icons/arrow-left.svg')} src={require('@tabler/icons/arrow-left.svg')}
@ -33,26 +24,9 @@ const ChatPageNew: React.FC<IChatPageNew> = () => {
<CardTitle title='New Message' /> <CardTitle title='New Message' />
</HStack> </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> </Stack>
<ChatSearch isMainPage />
</Stack> </Stack>
); );
}; };

View File

@ -13,6 +13,7 @@ import ChatSearch from '../chat-search/chat-search';
import EmptyResultsBlankslate from '../chat-search/empty-results-blankslate'; import EmptyResultsBlankslate from '../chat-search/empty-results-blankslate';
import ChatPaneHeader from '../chat-widget/chat-pane-header'; import ChatPaneHeader from '../chat-widget/chat-pane-header';
import ChatWindow from '../chat-widget/chat-window'; import ChatWindow from '../chat-widget/chat-window';
import ChatSearchHeader from '../chat-widget/headers/chat-search-header';
import { Pane } from '../ui'; import { Pane } from '../ui';
import Blankslate from './blankslate'; import Blankslate from './blankslate';
@ -86,7 +87,13 @@ const ChatPane = () => {
} }
if (screen === ChatWidgetScreens.SEARCH) { if (screen === ChatWidgetScreens.SEARCH) {
return <ChatSearch />; return (
<Pane isOpen={isOpen} index={0} main>
<ChatSearchHeader />
{isOpen ? <ChatSearch /> : null}
</Pane>
);
} }
return ( return (

View File

@ -29,6 +29,7 @@ const ChatSearchInput: React.FC<IChatSearchInput> = ({ value, onChange, onClear
className='rounded-full' className='rounded-full'
value={value} value={value}
onChange={onChange} onChange={onChange}
outerClassName='mt-0'
theme='search' theme='search'
append={ append={
<button onClick={onClear}> <button onClick={onClear}>

View File

@ -1,5 +1,6 @@
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import React from 'react'; import React from 'react';
import { VirtuosoMockContext } from 'react-virtuoso';
import { __stub } from 'soapbox/api'; import { __stub } from 'soapbox/api';
import { ChatProvider } from 'soapbox/contexts/chat-context'; import { ChatProvider } from 'soapbox/contexts/chat-context';
@ -8,29 +9,16 @@ import { render, screen, waitFor } from '../../../../../jest/test-helpers';
import ChatSearch from '../chat-search'; import ChatSearch from '../chat-search';
const renderComponent = () => render( const renderComponent = () => render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 100 }}>
<ChatProvider> <ChatProvider>
<ChatSearch /> <ChatSearch />
</ChatProvider>, </ChatProvider>,
</VirtuosoMockContext.Provider>,
); );
describe('<ChatSearch />', () => { describe('<ChatSearch />', () => {
it('renders correctly', () => {
renderComponent();
expect(screen.getByTestId('pane-header')).toHaveTextContent('Messages');
});
describe('when the pane is closed', () => {
it('does not render the search input', () => {
renderComponent();
expect(screen.queryAllByTestId('search')).toHaveLength(0);
});
});
describe('when the pane is open', () => {
beforeEach(async() => { beforeEach(async() => {
renderComponent(); renderComponent();
await userEvent.click(screen.getByTestId('icon-button'));
}); });
it('renders the search input', () => { it('renders the search input', () => {
@ -38,6 +26,7 @@ describe('<ChatSearch />', () => {
}); });
describe('when searching', () => { describe('when searching', () => {
describe('with results', () => {
beforeEach(() => { beforeEach(() => {
__stub((mock) => { __stub((mock) => {
mock.onGet('/api/v1/accounts/search').reply(200, [{ mock.onGet('/api/v1/accounts/search').reply(200, [{
@ -51,8 +40,6 @@ describe('<ChatSearch />', () => {
}); });
it('renders accounts', async() => { it('renders accounts', async() => {
renderComponent();
const user = userEvent.setup(); const user = userEvent.setup();
await user.type(screen.getByTestId('search'), 'ste'); await user.type(screen.getByTestId('search'), 'ste');
@ -61,5 +48,22 @@ describe('<ChatSearch />', () => {
}); });
}); });
}); });
describe('without results', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/accounts/search').reply(200, []);
});
});
it('renders accounts', async() => {
const user = userEvent.setup();
await user.type(screen.getByTestId('search'), 'ste');
await waitFor(() => {
expect(screen.getByTestId('no-results')).toBeInTheDocument();
});
});
});
}); });
}); });

View File

@ -1,10 +1,10 @@
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { useHistory } from 'react-router-dom';
import snackbar from 'soapbox/actions/snackbar'; 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 { ChatWidgetScreens, useChatContext } from 'soapbox/contexts/chat-context';
import { useAppDispatch, useDebounce } from 'soapbox/hooks'; import { useAppDispatch, useDebounce } from 'soapbox/hooks';
import { useChats } from 'soapbox/queries/chats'; import { useChats } from 'soapbox/queries/chats';
@ -12,29 +12,30 @@ import { queryClient } from 'soapbox/queries/client';
import useAccountSearch from 'soapbox/queries/search'; import useAccountSearch from 'soapbox/queries/search';
import { ChatKeys } from '../../../../queries/chats'; import { ChatKeys } from '../../../../queries/chats';
import ChatPaneHeader from '../chat-widget/chat-pane-header';
import { Pane } from '../ui';
import Blankslate from './blankslate'; import Blankslate from './blankslate';
import EmptyResultsBlankslate from './empty-results-blankslate'; import EmptyResultsBlankslate from './empty-results-blankslate';
import Results from './results'; import Results from './results';
const messages = defineMessages({ interface IChatSearch {
title: { id: 'chat_search.title', defaultMessage: 'Messages' }, isMainPage?: boolean
}); }
const ChatSearch = (props: IChatSearch) => {
const { isMainPage = false } = props;
const ChatSearch = () => {
const debounce = useDebounce; const debounce = useDebounce;
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const intl = useIntl(); const history = useHistory();
const { isOpen, changeScreen, toggleChatPane } = useChatContext(); const { changeScreen } = useChatContext();
const { getOrCreateChatByAccountId } = useChats(); const { getOrCreateChatByAccountId } = useChats();
const [value, setValue] = useState<string>(''); const [value, setValue] = useState<string>('');
const debouncedValue = debounce(value as string, 300); 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 hasSearchValue = debouncedValue && debouncedValue.length > 0;
const hasSearchResults = (accounts || []).length > 0; const hasSearchResults = (accounts || []).length > 0;
@ -47,7 +48,12 @@ const ChatSearch = () => {
dispatch(snackbar.error(data?.error)); dispatch(snackbar.error(data?.error));
}, },
onSuccess: (response) => { onSuccess: (response) => {
if (isMainPage) {
history.push(`/chats/${response.data.id}`);
} else {
changeScreen(ChatWidgetScreens.CHAT, response.data.id); changeScreen(ChatWidgetScreens.CHAT, response.data.id);
}
queryClient.invalidateQueries(ChatKeys.chatSearch()); queryClient.invalidateQueries(ChatKeys.chatSearch());
}, },
}); });
@ -56,7 +62,7 @@ const ChatSearch = () => {
if (hasSearchResults) { if (hasSearchResults) {
return ( return (
<Results <Results
accounts={accounts} accountSearchResult={accountSearchResult}
onSelect={(id) => { onSelect={(id) => {
handleClickOnSearchResult.mutate(id); handleClickOnSearchResult.mutate(id);
clearValue(); clearValue();
@ -77,33 +83,6 @@ const ChatSearch = () => {
}; };
return ( 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'> <Stack space={4} className='flex-grow h-full'>
<div className='px-4'> <div className='px-4'>
<Input <Input
@ -111,9 +90,9 @@ const ChatSearch = () => {
type='text' type='text'
autoFocus autoFocus
placeholder='Type a name' placeholder='Type a name'
className='rounded-full'
value={value || ''} value={value || ''}
onChange={(event) => setValue(event.target.value)} onChange={(event) => setValue(event.target.value)}
outerClassName='mt-0'
theme='search' theme='search'
append={ append={
<button onClick={clearValue}> <button onClick={clearValue}>
@ -127,12 +106,10 @@ const ChatSearch = () => {
/> />
</div> </div>
<Stack className='overflow-y-scroll flex-grow h-full' space={2}> <Stack className='flex-grow'>
{renderBody()} {renderBody()}
</Stack> </Stack>
</Stack> </Stack>
) : null}
</Pane>
); );
}; };

View File

@ -13,7 +13,7 @@ const EmptyResultsBlankslate = () => {
return ( return (
<Stack justifyContent='center' alignItems='center' space={2} className='h-full w-2/3 mx-auto'> <Stack justifyContent='center' alignItems='center' space={2} className='h-full w-2/3 mx-auto'>
<Text weight='bold' size='lg' align='center'> <Text weight='bold' size='lg' align='center' data-testid='no-results'>
{intl.formatMessage(messages.title)} {intl.formatMessage(messages.title)}
</Text> </Text>

View File

@ -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 { Avatar, HStack, Stack, Text } from 'soapbox/components/ui';
import VerificationBadge from 'soapbox/components/verification-badge'; import VerificationBadge from 'soapbox/components/verification-badge';
import useAccountSearch from 'soapbox/queries/search';
interface IResults { interface IResults {
accounts: { accountSearchResult: ReturnType<typeof useAccountSearch>
display_name: string
acct: string
id: string
avatar: string
verified: boolean
}[]
onSelect(id: string): void onSelect(id: string): void
} }
const Results = ({ accounts, onSelect }: IResults) => ( const Results = ({ accountSearchResult, onSelect }: IResults) => {
<> const { data: accounts, isFetching, hasNextPage, fetchNextPage } = accountSearchResult;
{(accounts || []).map((account: any) => (
const [isNearBottom, setNearBottom] = useState<boolean>(false);
const [isNearTop, setNearTop] = useState<boolean>(true);
const handleLoadMore = () => {
if (hasNextPage && !isFetching) {
fetchNextPage();
}
};
const renderAccount = useCallback((_index, account) => (
<button <button
key={account.id} key={account.id}
type='button' 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)} onClick={() => onSelect(account.id)}
data-testid='account' data-testid='account'
> >
@ -36,8 +43,38 @@ const Results = ({ accounts, onSelect }: IResults) => (
</Stack> </Stack>
</HStack> </HStack>
</button> </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; export default Results;

View File

@ -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;

View File

@ -15,14 +15,13 @@ import { useFetchRelationships } from './relationships';
import type { IAccount } from './accounts'; import type { IAccount } from './accounts';
export const messageExpirationOptions = [120, 604800, 1209600, 2592000, 7776000]; export const messageExpirationOptions = [604800, 1209600, 2592000, 7776000];
export enum MessageExpirationValues { export enum MessageExpirationValues {
'TWO_MINUTES' = messageExpirationOptions[0], 'SEVEN' = messageExpirationOptions[0],
'SEVEN' = messageExpirationOptions[1], 'FOURTEEN' = messageExpirationOptions[1],
'FOURTEEN' = messageExpirationOptions[2], 'THIRTY' = messageExpirationOptions[2],
'THIRTY' = messageExpirationOptions[3], 'NINETY' = messageExpirationOptions[3]
'NINETY' = messageExpirationOptions[4]
} }
export interface IChat { export interface IChat {

View File

@ -1,27 +1,51 @@
import { useQuery } from '@tanstack/react-query'; import { useInfiniteQuery } from '@tanstack/react-query';
import { getNextLink } from 'soapbox/api';
import { useApi } from 'soapbox/hooks'; import { useApi } from 'soapbox/hooks';
import { Account } from 'soapbox/types/entities';
import { flattenPages, PaginatedResult } from 'soapbox/utils/queries';
export default function useAccountSearch(q: string) { export default function useAccountSearch(q: string) {
const api = useApi(); const api = useApi();
const getAccountSearch = async(q: string) => { const getAccountSearch = async(q: string, pageParam: { link?: string }): Promise<PaginatedResult<Account>> => {
if (typeof q === 'undefined') { const nextPageLink = pageParam?.link;
return null; const uri = nextPageLink || '/api/v1/accounts/search';
}
const { data } = await api.get('/api/v1/accounts/search', { const response = await api.get(uri, {
params: { params: {
q, q,
limit: 10,
followers: true, 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, keepPreviousData: true,
placeholderData: [], getNextPageParam: (config) => {
}); if (config.hasMore) {
return { link: config.link };
}
return undefined;
},
});
const data = flattenPages(queryInfo.data);
return {
...queryInfo,
data,
};
} }