pl-fe: migrate search to react-query

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak
2024-11-09 19:48:02 +01:00
parent 7609a7e2a7
commit 42f7226594
13 changed files with 309 additions and 509 deletions

View File

@@ -9,7 +9,6 @@ import { biteAccount, blockAccount, pinAccount, removeFromFollowers, unblockAcco
import { mentionCompose, directCompose } from 'pl-fe/actions/compose';
import { blockDomain, unblockDomain } from 'pl-fe/actions/domain-blocks';
import { initReport, ReportableEntities } from 'pl-fe/actions/reports';
import { setSearchAccount } from 'pl-fe/actions/search';
import { useFollow } from 'pl-fe/api/hooks/accounts/use-follow';
import Badge from 'pl-fe/components/badge';
import DropdownMenu, { Menu } from 'pl-fe/components/dropdown-menu';
@@ -240,11 +239,6 @@ const Header: React.FC<IHeader> = ({ account }) => {
}
};
const onSearch = () => {
dispatch(setSearchAccount(account.id));
history.push('/search');
};
const onAvatarClick = () => {
const avatar = v.parse(mediaAttachmentSchema, {
id: '',
@@ -334,7 +328,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
if (features.searchFromAccount) {
menu.push({
text: intl.formatMessage(account.id === ownAccount.id ? messages.searchSelf : messages.search, { name: account.username }),
action: onSearch,
to: '/search?' + new URLSearchParams({ type: 'statuses', accountId: account.id }).toString(),
icon: require('@tabler/icons/outline/search.svg'),
});
}

View File

@@ -18,7 +18,6 @@ import Stack from 'pl-fe/components/ui/stack';
import EmojiPickerDropdown from 'pl-fe/features/emoji/containers/emoji-picker-dropdown-container';
import { ComposeEditor } from 'pl-fe/features/ui/util/async-components';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { useCompose } from 'pl-fe/hooks/use-compose';
import { useDraggedFiles } from 'pl-fe/hooks/use-dragged-files';
import { useFeatures } from 'pl-fe/hooks/use-features';
@@ -79,7 +78,6 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
const { configuration } = useInstance();
const compose = useCompose(id);
const showSearch = useAppSelector((state) => state.search.submitted && !state.search.hidden);
const maxTootChars = configuration.statuses.max_characters;
const features = useFeatures();
@@ -111,7 +109,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
const isEmpty = !(fulltext.trim() || anyMedia);
const condensed = shouldCondense && !isDraggedOver && !composeFocused && isEmpty && !isUploading;
const shouldAutoFocus = autoFocus && !showSearch;
const shouldAutoFocus = autoFocus;
const canSubmit = !!editorRef.current && !isSubmitting && !isUploading && !isChangingUpload && !isEmpty && length(fulltext) <= maxTootChars;
const getClickableArea = () => clickableAreaRef ? clickableAreaRef.current : formRef.current;

View File

@@ -1,9 +1,10 @@
import clsx from 'clsx';
import React, { useState } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { expandSearch, setFilter, setSearchAccount } from 'pl-fe/actions/search';
import { useAccount } from 'pl-fe/api/hooks/accounts/use-account';
import { useSearchAccounts, useSearchHashtags, useSearchStatuses } from 'pl-fe/api/hooks/search/use-search';
import { useTrendingLinks } from 'pl-fe/api/hooks/trends/use-trending-links';
import { useTrendingStatuses } from 'pl-fe/api/hooks/trends/use-trending-statuses';
import Hashtag from 'pl-fe/components/hashtag';
@@ -18,12 +19,11 @@ import StatusContainer from 'pl-fe/containers/status-container';
import PlaceholderAccount from 'pl-fe/features/placeholder/components/placeholder-account';
import PlaceholderHashtag from 'pl-fe/features/placeholder/components/placeholder-hashtag';
import PlaceholderStatus from 'pl-fe/features/placeholder/components/placeholder-status';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { useFeatures } from 'pl-fe/hooks/use-features';
import useTrends from 'pl-fe/queries/trends';
import type { SearchFilter } from 'pl-fe/reducers/search';
type SearchFilter = 'accounts' | 'hashtags' | 'statuses' | 'links';
const messages = defineMessages({
accounts: { id: 'search_results.accounts', defaultMessage: 'People' },
@@ -34,27 +34,47 @@ const messages = defineMessages({
const SearchResults = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const features = useFeatures();
const [tabKey, setTabKey] = useState(1);
const value = useAppSelector((state) => state.search.submittedValue);
const results = useAppSelector((state) => state.search.results);
const [params, setParams] = useSearchParams();
const value = params.get('q') || '';
const submitted = !!value.trim();
const selectedFilter = (params.get('type') || 'accounts') as SearchFilter;
const accountId = params.get('accountId') || undefined;
const searchAccountsQuery = useSearchAccounts(selectedFilter === 'accounts' && value || '');
const searchStatusesQuery = useSearchStatuses(selectedFilter === 'statuses' && value || '', {
account_id: accountId,
});
const searchHashtagsQuery = useSearchHashtags(selectedFilter === 'hashtags' && value || '');
const activeQuery = ({
accounts: searchAccountsQuery,
statuses: searchStatusesQuery,
hashtags: searchHashtagsQuery,
links: searchStatusesQuery,
})[selectedFilter]!;
const handleLoadMore = () => activeQuery.fetchNextPage({ cancelRefetch: false });
const selectFilter = (newActiveFilter: SearchFilter) => {
if (newActiveFilter === selectedFilter) activeQuery.refetch();
else setParams(params => ({ ...Object.fromEntries(params.entries()), type: newActiveFilter }));
};
const suggestions = useAppSelector((state) => state.suggestions.items);
const submitted = useAppSelector((state) => state.search.submitted);
const selectedFilter = useAppSelector((state) => state.search.filter);
const filterByAccount = useAppSelector((state) => state.search.accountId || undefined);
const { data: trendingTags } = useTrends();
const { data: trendingStatuses } = useTrendingStatuses();
const { trendingLinks } = useTrendingLinks();
const { account } = useAccount(filterByAccount);
const { account } = useAccount(accountId);
const handleLoadMore = () => dispatch(expandSearch(selectedFilter));
const handleUnsetAccount = () => dispatch(setSearchAccount(null));
const selectFilter = (newActiveFilter: SearchFilter) => dispatch(setFilter(value, newActiveFilter));
const handleUnsetAccount = () => {
params.delete('accountId');
setParams(params => Object.fromEntries(params.entries()));
};
const renderFilterBar = () => {
const items = [];
@@ -108,23 +128,21 @@ const SearchResults = () => {
if (element) element.focus();
};
let searchResults;
let hasMore = false;
let loaded;
let noResultsMessage;
let searchResults: Array<JSX.Element> | undefined;
const hasMore = activeQuery.hasNextPage;
const isLoading = activeQuery.isFetching;
let noResultsMessage: JSX.Element | undefined;
let placeholderComponent = PlaceholderStatus as React.ComponentType;
let resultsIds: Array<string>;
if (selectedFilter === 'accounts') {
hasMore = results.accountsHasMore;
loaded = results.accountsLoaded;
placeholderComponent = PlaceholderAccount;
if (results.accounts && results.accounts.length > 0) {
searchResults = results.accounts.map(accountId => <AccountContainer key={accountId} id={accountId} />);
} else if (!submitted && suggestions && suggestions.length !== 0) {
if (searchAccountsQuery.data && searchAccountsQuery.data.length > 0) {
searchResults = searchAccountsQuery.data.map(accountId => <AccountContainer key={accountId} id={accountId} />);
} else if (suggestions && suggestions.length > 0) {
searchResults = suggestions.map(suggestion => <AccountContainer key={suggestion.account_id} id={suggestion.account_id} />);
} else if (loaded) {
} else if (submitted && !isLoading) {
noResultsMessage = (
<div className='empty-column-indicator'>
<FormattedMessage
@@ -138,11 +156,8 @@ const SearchResults = () => {
}
if (selectedFilter === 'statuses') {
hasMore = results.statusesHasMore;
loaded = results.statusesLoaded;
if (results.statuses && results.statuses.length > 0) {
searchResults = results.statuses.map((statusId: string) => (
if (searchStatusesQuery.data && searchStatusesQuery.data.length > 0) {
searchResults = searchStatusesQuery.data.map((statusId: string) => (
// @ts-ignore
<StatusContainer
key={statusId}
@@ -151,8 +166,8 @@ const SearchResults = () => {
onMoveDown={handleMoveDown}
/>
));
resultsIds = results.statuses;
} else if (!submitted && !filterByAccount && trendingStatuses && trendingStatuses.length !== 0) {
resultsIds = searchStatusesQuery.data;
} else if (!submitted && !accountId && trendingStatuses && trendingStatuses.length !== 0) {
searchResults = trendingStatuses.map((statusId: string) => (
// @ts-ignore
<StatusContainer
@@ -163,7 +178,7 @@ const SearchResults = () => {
/>
));
resultsIds = trendingStatuses;
} else if (loaded) {
} else if (submitted && !isLoading) {
noResultsMessage = (
<div className='empty-column-indicator'>
<FormattedMessage
@@ -177,15 +192,13 @@ const SearchResults = () => {
}
if (selectedFilter === 'hashtags') {
hasMore = results.hashtagsHasMore;
loaded = results.hashtagsLoaded;
placeholderComponent = PlaceholderHashtag;
if (results.hashtags && results.hashtags.length > 0) {
searchResults = results.hashtags.map(hashtag => <Hashtag key={hashtag.name} hashtag={hashtag} />);
if (searchHashtagsQuery.data && searchHashtagsQuery.data.length > 0) {
searchResults = searchHashtagsQuery.data.map(hashtag => <Hashtag key={hashtag.name} hashtag={hashtag} />);
} else if (!submitted && suggestions && suggestions.length !== 0) {
searchResults = trendingTags?.map(hashtag => <Hashtag key={hashtag.name} hashtag={hashtag} />);
} else if (loaded) {
} else if (submitted && !isLoading) {
noResultsMessage = (
<div className='empty-column-indicator'>
<FormattedMessage
@@ -199,19 +212,17 @@ const SearchResults = () => {
}
if (selectedFilter === 'links') {
loaded = true;
if (submitted) {
selectFilter('accounts');
setTabKey(key => ++key);
} else if (!submitted && trendingLinks) {
} else if (trendingLinks) {
searchResults = trendingLinks.map(trendingLink => <TrendingLink trendingLink={trendingLink} />);
}
}
return (
<>
{filterByAccount ? (
{accountId ? (
<HStack className='border-b border-solid border-gray-200 p-2 pb-4 dark:border-gray-800' space={2}>
<IconButton iconClassName='h-5 w-5' src={require('@tabler/icons/outline/x.svg')} onClick={handleUnsetAccount} />
<Text truncate>
@@ -228,8 +239,8 @@ const SearchResults = () => {
<ScrollableList
id='search-results'
key={selectedFilter}
isLoading={submitted && !loaded}
showLoading={submitted && !loaded && (!searchResults || searchResults?.length === 0)}
isLoading={submitted && isLoading}
showLoading={submitted && isLoading && (searchResults?.length === 0 || activeQuery.isRefetching)}
hasMore={hasMore}
onLoadMore={handleLoadMore}
placeholderComponent={placeholderComponent}

View File

@@ -1,91 +1,43 @@
import clsx from 'clsx';
import debounce from 'lodash/debounce';
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { useSearchParams } from 'react-router-dom-v5-compat';
import {
clearSearch,
clearSearchResults,
setSearchAccount,
showSearch,
submitSearch,
} from 'pl-fe/actions/search';
import AutosuggestAccountInput from 'pl-fe/components/autosuggest-account-input';
import Input from 'pl-fe/components/ui/input';
import SvgIcon from 'pl-fe/components/ui/svg-icon';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { selectAccount } from 'pl-fe/selectors';
import { AppDispatch, RootState } from 'pl-fe/store';
const messages = defineMessages({
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
action: { id: 'search.action', defaultMessage: 'Search for “{query}”' },
});
const redirectToAccount = (accountId: string, routerHistory: any) =>
(_dispatch: AppDispatch, getState: () => RootState) => {
const acct = selectAccount(getState(), accountId)!.acct;
const Search = () => {
const [params, setParams] = useSearchParams();
const [value, setValue] = useState(params.get('q') || '');
if (acct && routerHistory) {
routerHistory.push(`/@${acct}`);
}
};
interface ISearch {
autoFocus?: boolean;
autoSubmit?: boolean;
autosuggest?: boolean;
openInRoute?: boolean;
}
const Search = (props: ISearch) => {
const submittedValue = useAppSelector((state) => state.search.submittedValue);
const [value, setValue] = useState(submittedValue);
const {
autoFocus = false,
autoSubmit = false,
autosuggest = false,
openInRoute = false,
} = props;
const dispatch = useAppDispatch();
const history = useHistory();
const intl = useIntl();
const submitted = useAppSelector((state) => state.search.submitted);
const setQuery = (value: string) => {
setParams(params => ({ ...Object.fromEntries(params.entries()), q: value }));
};
const debouncedSubmit = useCallback(debounce((value: string) => {
dispatch(submitSearch(value));
setQuery(value);
}, 900), []);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
setValue(value);
if (autoSubmit) {
debouncedSubmit(value);
}
debouncedSubmit(value);
};
const handleClear = (event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault();
if (value.length > 0 || submitted) {
dispatch(clearSearchResults());
}
};
const handleSubmit = () => {
if (openInRoute) {
dispatch(setSearchAccount(null));
dispatch(submitSearch(value));
history.push('/search');
} else {
dispatch(submitSearch(value));
if (value.length > 0) {
setValue('');
setQuery('');
}
};
@@ -93,67 +45,32 @@ const Search = (props: ISearch) => {
if (event.key === 'Enter') {
event.preventDefault();
handleSubmit();
setQuery(value);
} else if (event.key === 'Escape') {
document.querySelector('.ui')?.parentElement?.focus();
}
};
const handleFocus = () => {
dispatch(showSearch());
};
const handleSelected = (accountId: string) => {
dispatch(clearSearch());
dispatch(redirectToAccount(accountId, history));
};
const makeMenu = () => [
{
text: intl.formatMessage(messages.action, { query: value }),
icon: require('@tabler/icons/outline/search.svg'),
action: handleSubmit,
},
];
const hasValue = value.length > 0 || submitted;
const componentProps: any = {
type: 'text',
id: 'search',
placeholder: intl.formatMessage(messages.placeholder),
value,
onChange: handleChange,
onKeyDown: handleKeyDown,
onFocus: handleFocus,
autoFocus: autoFocus,
theme: 'search',
className: 'pr-10 rtl:pl-10 rtl:pr-3',
};
useEffect(() => {
if (value !== submittedValue) setValue(submittedValue);
}, [submittedValue]);
if (autosuggest) {
componentProps.onSelected = handleSelected;
componentProps.menu = makeMenu();
componentProps.autoSelect = false;
}
const hasValue = value.length > 0;
return (
<div
className={clsx('w-full', {
'sticky top-[76px] z-10 bg-white/90 backdrop-blur black:bg-black/80 dark:bg-primary-900/90': !openInRoute,
})}
className='sticky top-[76px] z-10 w-full bg-white/90 backdrop-blur black:bg-black/80 dark:bg-primary-900/90'
>
<label htmlFor='search' className='sr-only'>{intl.formatMessage(messages.placeholder)}</label>
<div className='relative'>
{autosuggest ? (
<AutosuggestAccountInput {...componentProps} />
) : (
<Input {...componentProps} />
)}
<Input
type='text'
id='search'
placeholder={intl.formatMessage(messages.placeholder)}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
autoFocus
theme='search'
className='pr-10 rtl:pl-10 rtl:pr-3'
/>
<div
role='button'

View File

@@ -15,7 +15,7 @@ const SearchPage = () => {
return (
<Column label={intl.formatMessage(messages.heading)}>
<div className='space-y-4'>
<Search autoFocus autoSubmit />
<Search />
<SearchResults />
</div>
</Column>

View File

@@ -2,12 +2,10 @@ import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { setFilter } from 'pl-fe/actions/search';
import Hashtag from 'pl-fe/components/hashtag';
import Text from 'pl-fe/components/ui/text';
import Widget from 'pl-fe/components/ui/widget';
import PlaceholderSidebarTrends from 'pl-fe/features/placeholder/components/placeholder-sidebar-trends';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import useTrends from 'pl-fe/queries/trends';
interface ITrendsPanel {
@@ -22,15 +20,10 @@ const messages = defineMessages({
});
const TrendsPanel = ({ limit }: ITrendsPanel) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const { data: trends, isFetching } = useTrends();
const setHashtagsFilter = () => {
dispatch(setFilter('', 'hashtags'));
};
if (!isFetching && !trends?.length) {
return null;
}
@@ -39,7 +32,7 @@ const TrendsPanel = ({ limit }: ITrendsPanel) => {
<Widget
title={<FormattedMessage id='trends.title' defaultMessage='Trends' />}
action={
<Link className='text-right' to='/search' onClick={setHashtagsFilter}>
<Link className='text-right' to='/search?type=hashtags'>
<Text tag='span' theme='primary' size='sm' className='hover:underline'>
{intl.formatMessage(messages.viewAll)}
</Text>