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

@ -1,188 +0,0 @@
import { useSettingsStore } from 'pl-fe/stores/settings';
import { getClient } from '../api';
import { fetchRelationships } from './accounts';
import { importEntities } from './importer';
import type { Search } from 'pl-api';
import type { SearchFilter } from 'pl-fe/reducers/search';
import type { AppDispatch, RootState } from 'pl-fe/store';
const SEARCH_CLEAR = 'SEARCH_CLEAR' as const;
const SEARCH_SHOW = 'SEARCH_SHOW' as const;
const SEARCH_RESULTS_CLEAR = 'SEARCH_RESULTS_CLEAR' as const;
const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST' as const;
const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS' as const;
const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL' as const;
const SEARCH_FILTER_SET = 'SEARCH_FILTER_SET' as const;
const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST' as const;
const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS' as const;
const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL' as const;
const SEARCH_ACCOUNT_SET = 'SEARCH_ACCOUNT_SET' as const;
const clearSearch = () => ({
type: SEARCH_CLEAR,
});
const clearSearchResults = () => ({
type: SEARCH_RESULTS_CLEAR,
});
const submitSearch = (value: string, filter?: SearchFilter) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const type = filter || getState().search.filter || 'accounts';
const accountId = getState().search.accountId;
// An empty search doesn't return any results
if (value.length === 0) {
return dispatch(clearSearchResults());
}
dispatch(fetchSearchRequest(value));
const params: Record<string, any> = {
resolve: true,
limit: 20,
type: type as any,
};
if (accountId) params.account_id = accountId;
return getClient(getState()).search.search(value, params).then(response => {
dispatch(importEntities({ accounts: response.accounts, statuses: response.statuses }));
dispatch(fetchSearchSuccess(response, value, type));
dispatch(fetchRelationships(response.accounts.map((item) => item.id)));
}).catch(error => {
dispatch(fetchSearchFail(error));
});
};
const fetchSearchRequest = (value: string) => ({
type: SEARCH_FETCH_REQUEST,
value,
});
const fetchSearchSuccess = (results: Search, searchTerm: string, searchType: SearchFilter) => ({
type: SEARCH_FETCH_SUCCESS,
results,
searchTerm,
searchType,
});
const fetchSearchFail = (error: unknown) => ({
type: SEARCH_FETCH_FAIL,
error,
});
const setFilter = (value: string, filterType: SearchFilter) =>
(dispatch: AppDispatch) => {
dispatch(submitSearch(value, filterType));
useSettingsStore.getState().changeSetting(['search', 'filter'], filterType);
return dispatch({
type: SEARCH_FILTER_SET,
value: filterType,
});
};
const expandSearch = (type: SearchFilter) => (dispatch: AppDispatch, getState: () => RootState) => {
if (type === 'links') return;
const value = getState().search.submittedValue;
const offset = getState().search.results[type].length;
const accountId = getState().search.accountId;
dispatch(expandSearchRequest(type));
const params: Record<string, any> = {
type,
offset,
};
if (accountId) params.account_id = accountId;
return getClient(getState()).search.search(value, params).then(response => {
dispatch(importEntities({ accounts: response.accounts, statuses: response.statuses }));
dispatch(expandSearchSuccess(response, value, type));
dispatch(fetchRelationships(response.accounts.map((item) => item.id)));
}).catch(error => {
dispatch(expandSearchFail(error));
});
};
const expandSearchRequest = (searchType: SearchFilter) => ({
type: SEARCH_EXPAND_REQUEST,
searchType,
});
const expandSearchSuccess = (results: Search, searchTerm: string, searchType: Exclude<SearchFilter, 'links'>) => ({
type: SEARCH_EXPAND_SUCCESS,
results,
searchTerm,
searchType,
});
const expandSearchFail = (error: unknown) => ({
type: SEARCH_EXPAND_FAIL,
error,
});
const showSearch = () => ({
type: SEARCH_SHOW,
});
const setSearchAccount = (accountId: string | null) => ({
type: SEARCH_ACCOUNT_SET,
accountId,
});
type SearchAction =
| ReturnType<typeof clearSearch>
| ReturnType<typeof clearSearchResults>
| ReturnType<typeof fetchSearchRequest>
| ReturnType<typeof fetchSearchSuccess>
| ReturnType<typeof fetchSearchFail>
| ReturnType<typeof expandSearchRequest>
| ReturnType<typeof expandSearchSuccess>
| ReturnType<typeof expandSearchFail>
| {
type: typeof SEARCH_FILTER_SET;
path: (['search', 'filter']);
value: SearchFilter;
}
| ReturnType<typeof showSearch>
| ReturnType<typeof setSearchAccount>
export {
SEARCH_CLEAR,
SEARCH_SHOW,
SEARCH_RESULTS_CLEAR,
SEARCH_FETCH_REQUEST,
SEARCH_FETCH_SUCCESS,
SEARCH_FETCH_FAIL,
SEARCH_FILTER_SET,
SEARCH_EXPAND_REQUEST,
SEARCH_EXPAND_SUCCESS,
SEARCH_EXPAND_FAIL,
SEARCH_ACCOUNT_SET,
clearSearch,
clearSearchResults,
submitSearch,
fetchSearchRequest,
fetchSearchSuccess,
fetchSearchFail,
setFilter,
expandSearch,
expandSearchRequest,
expandSearchSuccess,
expandSearchFail,
showSearch,
setSearchAccount,
type SearchAction,
};

View file

@ -0,0 +1,108 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { importEntities } from 'pl-fe/actions/importer';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useClient } from 'pl-fe/hooks/use-client';
import type { SearchParams } from 'pl-api';
import type { PaginationParams } from 'pl-api/dist/params/common';
const useSearchAccounts = (
query: string,
params?: Omit<SearchParams, keyof PaginationParams | 'type' | 'offset'>,
) => {
const client = useClient();
const dispatch = useAppDispatch();
const searchQuery = useInfiniteQuery({
queryKey: ['search', 'accounts', query, params],
queryFn: ({ pageParam, signal }) => client.search.search(query!, {
with_relationships: true,
...params,
offset: pageParam ? data?.length : 0,
type: 'accounts',
}, { signal }).then(({ accounts }) => {
dispatch(importEntities({ accounts }));
return accounts.map(({ id }) => id);
}),
enabled: !!query?.trim(),
initialPageParam: [''],
getNextPageParam: (page) => page.length ? page : undefined,
select: (data => data.pages.flat()),
});
const data: Array<string> | undefined = searchQuery.data;
return searchQuery;
};
const useSearchStatuses = (
query: string,
params?: Omit<SearchParams, keyof PaginationParams | 'type' | 'offset'>,
) => {
const client = useClient();
const dispatch = useAppDispatch();
return useInfiniteQuery({
queryKey: ['search', 'statuses', query, params],
queryFn: ({ pageParam: offset, signal }) => client.search.search(query, {
with_relationships: true,
...params,
offset,
type: 'statuses',
}, { signal }).then(({ statuses }) => {
dispatch(importEntities({ statuses }));
return statuses.map(({ id }) => id);
}),
enabled: !!query?.trim(),
initialPageParam: 0,
getNextPageParam: (_, allPages) => allPages.flat().length,
select: (data => data.pages.flat()),
});
};
const useSearchHashtags = (
query: string,
params?: Omit<SearchParams, keyof PaginationParams | 'type' | 'offset'>,
) => {
const client = useClient();
return useInfiniteQuery({
queryKey: ['search', 'hashtags', query, params],
queryFn: ({ pageParam: offset, signal }) => client.search.search(query, {
...params,
offset,
type: 'hashtags',
}, { signal }).then(({ hashtags }) => hashtags),
enabled: !!query?.trim(),
initialPageParam: 0,
getNextPageParam: (_, allPages) => allPages.flat().length,
select: (data => data.pages.flat()),
});
};
const useSearchGroups = (
query: string,
params?: Omit<SearchParams, keyof PaginationParams | 'type' | 'offset'>,
) => {
const client = useClient();
const dispatch = useAppDispatch();
return useInfiniteQuery({
queryKey: ['search', 'groups', query, params],
queryFn: ({ pageParam: offset, signal }) => client.search.search(query, {
...params,
offset,
type: 'groups',
}, { signal }).then(({ groups }) => {
dispatch(importEntities({ groups }));
return groups.map(({ id }) => id);
}),
enabled: !!query?.trim(),
initialPageParam: 0,
getNextPageParam: (_, allPages) => allPages.flat().length,
select: (data => data.pages.flat()),
});
};
export { useSearchAccounts, useSearchStatuses, useSearchHashtags, useSearchGroups };

View file

@ -20,6 +20,7 @@ interface IAutosuggestAccountInput {
menu?: Menu;
onKeyDown?: React.KeyboardEventHandler;
theme?: InputThemes;
placeholder?: string;
}
const AutosuggestAccountInput: React.FC<IAutosuggestAccountInput> = ({

View file

@ -0,0 +1,112 @@
import clsx from 'clsx';
import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import AutosuggestAccountInput from 'pl-fe/components/autosuggest-account-input';
import SvgIcon from 'pl-fe/components/ui/svg-icon';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
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;
if (acct && routerHistory) {
routerHistory.push(`/@${acct}`);
}
};
const SearchInput = () => {
const [value, setValue] = useState('');
const dispatch = useAppDispatch();
const history = useHistory();
const intl = useIntl();
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
setValue(value);
};
const handleClear = (event: React.MouseEvent<HTMLDivElement>) => {
setValue('');
};
const handleSubmit = () => {
setValue('');
history.push('/search?' + new URLSearchParams({ q: value }));
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault();
handleSubmit();
} else if (event.key === 'Escape') {
document.querySelector('.ui')?.parentElement?.focus();
}
};
const handleSelected = (accountId: string) => {
setValue('');
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;
return (
<div className='w-full'>
<label htmlFor='search' className='sr-only'>{intl.formatMessage(messages.placeholder)}</label>
<div className='relative'>
<AutosuggestAccountInput
placeholder={intl.formatMessage(messages.placeholder)}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onSelected={handleSelected}
menu={makeMenu()}
autoSelect={false}
theme='search'
className='pr-10 rtl:pl-10 rtl:pr-3'
/>
<div
role='button'
tabIndex={0}
className='absolute inset-y-0 right-0 flex cursor-pointer items-center px-3 rtl:left-0 rtl:right-auto'
onClick={handleClear}
>
<SvgIcon
src={require('@tabler/icons/outline/search.svg')}
className={clsx('size-4 text-gray-600', { hidden: hasValue })}
/>
<SvgIcon
src={require('@tabler/icons/outline/x.svg')}
className={clsx('size-4 text-gray-600', { hidden: !hasValue })}
aria-label={intl.formatMessage(messages.placeholder)}
/>
</div>
</div>
</div>
);
};
export { SearchInput as default };

View file

@ -5,7 +5,6 @@ import { useInteractionRequestsCount } from 'pl-fe/api/hooks/statuses/use-intera
import Icon from 'pl-fe/components/ui/icon';
import Stack from 'pl-fe/components/ui/stack';
import { useStatContext } from 'pl-fe/contexts/stat-context';
import Search from 'pl-fe/features/search/components/search';
import ComposeButton from 'pl-fe/features/ui/components/compose-button';
import ProfileDropdown from 'pl-fe/features/ui/components/profile-dropdown';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
@ -18,6 +17,7 @@ import { useSettings } from 'pl-fe/hooks/use-settings';
import Account from './account';
import DropdownMenu, { Menu } from './dropdown-menu';
import SearchInput from './search-input';
import SidebarNavigationLink from './sidebar-navigation-link';
import SiteLogo from './site-logo';
@ -176,7 +176,7 @@ const SidebarNavigation = () => {
</ProfileDropdown>
</div>
<div className='block w-full max-w-xs'>
<Search openInRoute autosuggest />
<SearchInput />
</div>
</Stack>
)}

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>

View file

@ -32,7 +32,6 @@ import plfe from './pl-fe';
import polls from './polls';
import push_notifications from './push-notifications';
import scheduled_statuses from './scheduled-statuses';
import search from './search';
import security from './security';
import status_lists from './status-lists';
import statuses from './statuses';
@ -72,7 +71,6 @@ const reducers = {
polls,
push_notifications,
scheduled_statuses,
search,
security,
status_lists,
statuses,

View file

@ -1,144 +0,0 @@
import { Record as ImmutableRecord } from 'immutable';
import {
COMPOSE_MENTION,
COMPOSE_REPLY,
COMPOSE_DIRECT,
COMPOSE_QUOTE,
type ComposeAction,
} from '../actions/compose';
import {
SEARCH_CLEAR,
SEARCH_FETCH_REQUEST,
SEARCH_FETCH_SUCCESS,
SEARCH_SHOW,
SEARCH_FILTER_SET,
SEARCH_EXPAND_REQUEST,
SEARCH_EXPAND_SUCCESS,
SEARCH_ACCOUNT_SET,
SEARCH_RESULTS_CLEAR,
type SearchAction,
} from '../actions/search';
import type { Search, Tag } from 'pl-api';
const ResultsRecord = ImmutableRecord({
accounts: Array<string>(),
statuses: Array<string>(),
groups: Array<string>(),
hashtags: Array<Tag>(), // it's a list of maps
accountsHasMore: false,
statusesHasMore: false,
groupsHasMore: false,
hashtagsHasMore: false,
accountsLoaded: false,
statusesLoaded: false,
groupsLoaded: false,
hashtagsLoaded: false,
});
const ReducerRecord = ImmutableRecord({
submitted: false,
submittedValue: '',
hidden: false,
results: ResultsRecord(),
filter: 'accounts' as SearchFilter,
accountId: null as string | null,
});
type State = ReturnType<typeof ReducerRecord>;
type SearchFilter = 'accounts' | 'statuses' | 'groups' | 'hashtags' | 'links';
const toIds = (items: Array<{ id: string }> = []) => items.map(item => item.id);
const importResults = (state: State, results: Search, searchTerm: string, searchType: SearchFilter) =>
state.withMutations(state => {
if (state.submittedValue === searchTerm && state.filter === searchType) {
state.set('results', ResultsRecord({
accounts: toIds(results.accounts),
statuses: toIds(results.statuses),
groups: toIds(results.groups),
hashtags: results.hashtags, // it's a list of records
accountsHasMore: results.accounts.length !== 0,
statusesHasMore: results.statuses.length !== 0,
groupsHasMore: results.groups?.length !== 0,
hashtagsHasMore: results.hashtags.length !== 0,
accountsLoaded: true,
statusesLoaded: true,
groupsLoaded: true,
hashtagsLoaded: true,
}));
state.set('submitted', true);
}
});
const paginateResults = (state: State, searchType: Exclude<SearchFilter, 'links'>, results: Search, searchTerm: string) =>
state.withMutations(state => {
if (state.submittedValue === searchTerm) {
state.setIn(['results', `${searchType}HasMore`], results[searchType].length >= 20);
state.setIn(['results', `${searchType}Loaded`], true);
state.updateIn(['results', searchType], items => {
const data = results[searchType];
// Hashtags are a list of maps. Others are IDs.
if (searchType === 'hashtags') {
return (items as Array<Tag>).concat(data as Search['hashtags']);
} else {
return (items as Array<string>).concat(toIds(data as Search['accounts']));
}
});
}
});
const handleSubmitted = (state: State, value: string) =>
state.withMutations(state => {
state.set('results', ResultsRecord());
state.set('submitted', true);
state.set('submittedValue', value);
});
const search = (state = ReducerRecord(), action: SearchAction | ComposeAction) => {
switch (action.type) {
case SEARCH_CLEAR:
return ReducerRecord();
case SEARCH_RESULTS_CLEAR:
return state.merge({
results: ResultsRecord(),
submitted: false,
submittedValue: '',
});
case SEARCH_SHOW:
return state.set('hidden', false);
case COMPOSE_REPLY:
case COMPOSE_MENTION:
case COMPOSE_DIRECT:
case COMPOSE_QUOTE:
return state.set('hidden', true);
case SEARCH_FETCH_REQUEST:
return handleSubmitted(state, action.value);
case SEARCH_FETCH_SUCCESS:
return importResults(state, action.results, action.searchTerm, action.searchType);
case SEARCH_FILTER_SET:
return state.set('filter', action.value);
case SEARCH_EXPAND_REQUEST:
return state.setIn(['results', `${action.searchType}Loaded`], false);
case SEARCH_EXPAND_SUCCESS:
return paginateResults(state, action.searchType, action.results, action.searchTerm);
case SEARCH_ACCOUNT_SET:
if (!action.accountId) return state.merge({
results: ResultsRecord(),
submitted: false,
submittedValue: '',
filter: 'accounts',
accountId: null,
});
return ReducerRecord({ accountId: action.accountId, filter: 'statuses' });
default:
return state;
}
};
export {
type SearchFilter,
search as default,
};