Merge branch 'chat-search-pagination' into 'develop'
Improve Chats search See merge request soapbox-pub/soapbox!1993
This commit is contained in:
commit
90513f1807
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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 }[]) => {
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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'>
|
||||||
|
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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 {
|
||||||
|
|
|
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue