Change search to use query params in web UI (#32949)

This commit is contained in:
Eugen Rochko 2024-12-12 18:12:33 +01:00 committed by GitHub
parent 708919ee93
commit 0636bcdbe1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1396 additions and 1270 deletions

View File

@ -2,6 +2,8 @@ import { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { isFulfilled, isRejected } from '@reduxjs/toolkit';
import { openURL } from 'mastodon/actions/search';
import { useAppDispatch } from 'mastodon/store';
@ -28,12 +30,22 @@ export const useLinks = () => {
);
const handleMentionClick = useCallback(
(element: HTMLAnchorElement) => {
dispatch(
openURL(element.href, history, () => {
async (element: HTMLAnchorElement) => {
const result = await dispatch(openURL({ url: element.href }));
if (isFulfilled(result)) {
if (result.payload.accounts[0]) {
history.push(`/@${result.payload.accounts[0].acct}`);
} else if (result.payload.statuses[0]) {
history.push(
`/@${result.payload.statuses[0].account.acct}/${result.payload.statuses[0].id}`,
);
} else {
window.location.href = element.href;
}),
);
}
} else if (isRejected(result)) {
window.location.href = element.href;
}
},
[dispatch, history],
);
@ -48,7 +60,7 @@ export const useLinks = () => {
if (isMentionClick(target)) {
e.preventDefault();
handleMentionClick(target);
void handleMentionClick(target);
} else if (isHashtagClick(target)) {
e.preventDefault();
handleHashtagClick(target);

View File

@ -1,215 +0,0 @@
import { fromJS } from 'immutable';
import { searchHistory } from 'mastodon/settings';
import api from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts, importFetchedStatuses } from './importer';
export const SEARCH_CHANGE = 'SEARCH_CHANGE';
export const SEARCH_CLEAR = 'SEARCH_CLEAR';
export const SEARCH_SHOW = 'SEARCH_SHOW';
export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST';
export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS';
export const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL';
export const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST';
export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS';
export const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL';
export const SEARCH_HISTORY_UPDATE = 'SEARCH_HISTORY_UPDATE';
export function changeSearch(value) {
return {
type: SEARCH_CHANGE,
value,
};
}
export function clearSearch() {
return {
type: SEARCH_CLEAR,
};
}
export function submitSearch(type) {
return (dispatch, getState) => {
const value = getState().getIn(['search', 'value']);
const signedIn = !!getState().getIn(['meta', 'me']);
if (value.length === 0) {
dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, '', type));
return;
}
dispatch(fetchSearchRequest(type));
api().get('/api/v2/search', {
params: {
q: value,
resolve: signedIn,
limit: 11,
type,
},
}).then(response => {
if (response.data.accounts) {
dispatch(importFetchedAccounts(response.data.accounts));
}
if (response.data.statuses) {
dispatch(importFetchedStatuses(response.data.statuses));
}
dispatch(fetchSearchSuccess(response.data, value, type));
dispatch(fetchRelationships(response.data.accounts.map(item => item.id)));
}).catch(error => {
dispatch(fetchSearchFail(error));
});
};
}
export function fetchSearchRequest(searchType) {
return {
type: SEARCH_FETCH_REQUEST,
searchType,
};
}
export function fetchSearchSuccess(results, searchTerm, searchType) {
return {
type: SEARCH_FETCH_SUCCESS,
results,
searchType,
searchTerm,
};
}
export function fetchSearchFail(error) {
return {
type: SEARCH_FETCH_FAIL,
error,
};
}
export const expandSearch = type => (dispatch, getState) => {
const value = getState().getIn(['search', 'value']);
const offset = getState().getIn(['search', 'results', type]).size - 1;
dispatch(expandSearchRequest(type));
api().get('/api/v2/search', {
params: {
q: value,
type,
offset,
limit: 11,
},
}).then(({ data }) => {
if (data.accounts) {
dispatch(importFetchedAccounts(data.accounts));
}
if (data.statuses) {
dispatch(importFetchedStatuses(data.statuses));
}
dispatch(expandSearchSuccess(data, value, type));
dispatch(fetchRelationships(data.accounts.map(item => item.id)));
}).catch(error => {
dispatch(expandSearchFail(error));
});
};
export const expandSearchRequest = (searchType) => ({
type: SEARCH_EXPAND_REQUEST,
searchType,
});
export const expandSearchSuccess = (results, searchTerm, searchType) => ({
type: SEARCH_EXPAND_SUCCESS,
results,
searchTerm,
searchType,
});
export const expandSearchFail = error => ({
type: SEARCH_EXPAND_FAIL,
error,
});
export const showSearch = () => ({
type: SEARCH_SHOW,
});
export const openURL = (value, history, onFailure) => (dispatch, getState) => {
const signedIn = !!getState().getIn(['meta', 'me']);
if (!signedIn) {
if (onFailure) {
onFailure();
}
return;
}
dispatch(fetchSearchRequest());
api().get('/api/v2/search', { params: { q: value, resolve: true } }).then(response => {
if (response.data.accounts?.length > 0) {
dispatch(importFetchedAccounts(response.data.accounts));
history.push(`/@${response.data.accounts[0].acct}`);
} else if (response.data.statuses?.length > 0) {
dispatch(importFetchedStatuses(response.data.statuses));
history.push(`/@${response.data.statuses[0].account.acct}/${response.data.statuses[0].id}`);
} else if (onFailure) {
onFailure();
}
dispatch(fetchSearchSuccess(response.data, value));
}).catch(err => {
dispatch(fetchSearchFail(err));
if (onFailure) {
onFailure();
}
});
};
export const clickSearchResult = (q, type) => (dispatch, getState) => {
const previous = getState().getIn(['search', 'recent']);
if (previous.some(x => x.get('q') === q && x.get('type') === type)) {
return;
}
const me = getState().getIn(['meta', 'me']);
const current = previous.add(fromJS({ type, q })).takeLast(4);
searchHistory.set(me, current.toJS());
dispatch(updateSearchHistory(current));
};
export const forgetSearchResult = q => (dispatch, getState) => {
const previous = getState().getIn(['search', 'recent']);
const me = getState().getIn(['meta', 'me']);
const current = previous.filterNot(result => result.get('q') === q);
searchHistory.set(me, current.toJS());
dispatch(updateSearchHistory(current));
};
export const updateSearchHistory = recent => ({
type: SEARCH_HISTORY_UPDATE,
recent,
});
export const hydrateSearch = () => (dispatch, getState) => {
const me = getState().getIn(['meta', 'me']);
const history = searchHistory.get(me);
if (history !== null) {
dispatch(updateSearchHistory(history));
}
};

View File

@ -0,0 +1,151 @@
import { createAction } from '@reduxjs/toolkit';
import { apiGetSearch } from 'mastodon/api/search';
import type { ApiSearchType } from 'mastodon/api_types/search';
import type {
RecentSearch,
SearchType as RecentSearchType,
} from 'mastodon/models/search';
import { searchHistory } from 'mastodon/settings';
import {
createDataLoadingThunk,
createAppAsyncThunk,
} from 'mastodon/store/typed_functions';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts, importFetchedStatuses } from './importer';
export const SEARCH_HISTORY_UPDATE = 'SEARCH_HISTORY_UPDATE';
export const submitSearch = createDataLoadingThunk(
'search/submit',
async ({ q, type }: { q: string; type?: ApiSearchType }, { getState }) => {
const signedIn = !!getState().meta.get('me');
return apiGetSearch({
q,
type,
resolve: signedIn,
limit: 11,
});
},
(data, { dispatch }) => {
if (data.accounts.length > 0) {
dispatch(importFetchedAccounts(data.accounts));
dispatch(fetchRelationships(data.accounts.map((account) => account.id)));
}
if (data.statuses.length > 0) {
dispatch(importFetchedStatuses(data.statuses));
}
return data;
},
{
useLoadingBar: false,
},
);
export const expandSearch = createDataLoadingThunk(
'search/expand',
async ({ type }: { type: ApiSearchType }, { getState }) => {
const q = getState().search.q;
const results = getState().search.results;
const offset = results?.[type].length;
return apiGetSearch({
q,
type,
limit: 11,
offset,
});
},
(data, { dispatch }) => {
if (data.accounts.length > 0) {
dispatch(importFetchedAccounts(data.accounts));
dispatch(fetchRelationships(data.accounts.map((account) => account.id)));
}
if (data.statuses.length > 0) {
dispatch(importFetchedStatuses(data.statuses));
}
return data;
},
{
useLoadingBar: true,
},
);
export const openURL = createDataLoadingThunk(
'search/openURL',
({ url }: { url: string }, { getState }) => {
const signedIn = !!getState().meta.get('me');
return apiGetSearch({
q: url,
resolve: signedIn,
limit: 1,
});
},
(data, { dispatch }) => {
if (data.accounts.length > 0) {
dispatch(importFetchedAccounts(data.accounts));
} else if (data.statuses.length > 0) {
dispatch(importFetchedStatuses(data.statuses));
}
return data;
},
{
useLoadingBar: true,
},
);
export const clickSearchResult = createAppAsyncThunk(
'search/clickResult',
(
{ q, type }: { q: string; type?: RecentSearchType },
{ dispatch, getState },
) => {
const previous = getState().search.recent;
if (previous.some((x) => x.q === q && x.type === type)) {
return;
}
const me = getState().meta.get('me') as string;
const current = [{ type, q }, ...previous].slice(0, 4);
searchHistory.set(me, current);
dispatch(updateSearchHistory(current));
},
);
export const forgetSearchResult = createAppAsyncThunk(
'search/forgetResult',
(q: string, { dispatch, getState }) => {
const previous = getState().search.recent;
const me = getState().meta.get('me') as string;
const current = previous.filter((result) => result.q !== q);
searchHistory.set(me, current);
dispatch(updateSearchHistory(current));
},
);
export const updateSearchHistory = createAction<RecentSearch[]>(
'search/updateHistory',
);
export const hydrateSearch = createAppAsyncThunk(
'search/hydrate',
(_args, { dispatch, getState }) => {
const me = getState().meta.get('me') as string;
const history = searchHistory.get(me) as RecentSearch[] | null;
if (history !== null) {
dispatch(updateSearchHistory(history));
}
},
);

View File

@ -0,0 +1,16 @@
import { apiRequestGet } from 'mastodon/api';
import type {
ApiSearchType,
ApiSearchResultsJSON,
} from 'mastodon/api_types/search';
export const apiGetSearch = (params: {
q: string;
resolve?: boolean;
type?: ApiSearchType;
limit?: number;
offset?: number;
}) =>
apiRequestGet<ApiSearchResultsJSON>('v2/search', {
...params,
});

View File

@ -0,0 +1,11 @@
import type { ApiAccountJSON } from './accounts';
import type { ApiStatusJSON } from './statuses';
import type { ApiHashtagJSON } from './tags';
export type ApiSearchType = 'accounts' | 'statuses' | 'hashtags';
export interface ApiSearchResultsJSON {
accounts: ApiAccountJSON[];
statuses: ApiStatusJSON[];
hashtags: ApiHashtagJSON[];
}

View File

@ -12,6 +12,7 @@ import { Sparklines, SparklinesCurve } from 'react-sparklines';
import { ShortNumber } from 'mastodon/components/short_number';
import { Skeleton } from 'mastodon/components/skeleton';
import type { Hashtag as HashtagType } from 'mastodon/models/tags';
interface SilentErrorBoundaryProps {
children: React.ReactNode;
@ -80,6 +81,22 @@ export const ImmutableHashtag = ({ hashtag }: ImmutableHashtagProps) => (
/>
);
export const CompatibilityHashtag: React.FC<{
hashtag: HashtagType;
}> = ({ hashtag }) => (
<Hashtag
name={hashtag.name}
to={`/tags/${hashtag.name}`}
people={
(hashtag.history[0].accounts as unknown as number) * 1 +
((hashtag.history[1]?.accounts ?? 0) as unknown as number) * 1
}
history={hashtag.history
.map((day) => (day.uses as unknown as number) * 1)
.reverse()}
/>
);
export interface HashtagProps {
className?: string;
description?: React.ReactNode;

View File

@ -6,6 +6,7 @@ import classNames from 'classnames';
import { Helmet } from 'react-helmet';
import { NavLink, withRouter } from 'react-router-dom';
import { isFulfilled, isRejected } from '@reduxjs/toolkit';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
@ -215,8 +216,20 @@ class Header extends ImmutablePureComponent {
const link = e.currentTarget;
onOpenURL(link.href, history, () => {
window.location = link.href;
onOpenURL(link.href).then((result) => {
if (isFulfilled(result)) {
if (result.payload.accounts[0]) {
history.push(`/@${result.payload.accounts[0].acct}`);
} else if (result.payload.statuses[0]) {
history.push(`/@${result.payload.statuses[0].account.acct}/${result.payload.statuses[0].id}`);
} else {
window.location = link.href;
}
} else if (isRejected(result)) {
window.location = link.href;
}
}).catch(() => {
// Nothing
});
}
};

View File

@ -144,8 +144,8 @@ const mapDispatchToProps = (dispatch) => ({
}));
},
onOpenURL (url, routerHistory, onFailure) {
dispatch(openURL(url, routerHistory, onFailure));
onOpenURL (url) {
return dispatch(openURL({ url }));
},
});

View File

@ -1,402 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl, FormattedMessage, FormattedList } from 'react-intl';
import classNames from 'classnames';
import { withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
import { Icon } from 'mastodon/components/icon';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { domain, searchEnabled } from 'mastodon/initial_state';
import { HASHTAG_REGEX } from 'mastodon/utils/hashtags';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
const messages = defineMessages({
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
placeholderSignedIn: { id: 'search.search_or_paste', defaultMessage: 'Search or paste URL' },
});
const labelForRecentSearch = search => {
switch(search.get('type')) {
case 'account':
return `@${search.get('q')}`;
case 'hashtag':
return `#${search.get('q')}`;
default:
return search.get('q');
}
};
class Search extends PureComponent {
static propTypes = {
identity: identityContextPropShape,
value: PropTypes.string.isRequired,
recent: ImmutablePropTypes.orderedSet,
submitted: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onOpenURL: PropTypes.func.isRequired,
onClickSearchResult: PropTypes.func.isRequired,
onForgetSearchResult: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired,
onShow: PropTypes.func.isRequired,
openInRoute: PropTypes.bool,
intl: PropTypes.object.isRequired,
singleColumn: PropTypes.bool,
...WithRouterPropTypes,
};
state = {
expanded: false,
selectedOption: -1,
options: [],
};
defaultOptions = [
{ key: 'prompt-has', label: <><mark>has:</mark> <FormattedList type='disjunction' value={['media', 'poll', 'embed']} /></>, action: e => { e.preventDefault(); this._insertText('has:'); } },
{ key: 'prompt-is', label: <><mark>is:</mark> <FormattedList type='disjunction' value={['reply', 'sensitive']} /></>, action: e => { e.preventDefault(); this._insertText('is:'); } },
{ key: 'prompt-language', label: <><mark>language:</mark> <FormattedMessage id='search_popout.language_code' defaultMessage='ISO language code' /></>, action: e => { e.preventDefault(); this._insertText('language:'); } },
{ key: 'prompt-from', label: <><mark>from:</mark> <FormattedMessage id='search_popout.user' defaultMessage='user' /></>, action: e => { e.preventDefault(); this._insertText('from:'); } },
{ key: 'prompt-before', label: <><mark>before:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('before:'); } },
{ key: 'prompt-during', label: <><mark>during:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('during:'); } },
{ key: 'prompt-after', label: <><mark>after:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('after:'); } },
{ key: 'prompt-in', label: <><mark>in:</mark> <FormattedList type='disjunction' value={['all', 'library', 'public']} /></>, action: e => { e.preventDefault(); this._insertText('in:'); } }
];
setRef = c => {
this.searchForm = c;
};
handleChange = ({ target }) => {
const { onChange } = this.props;
onChange(target.value);
this._calculateOptions(target.value);
};
handleClear = e => {
const { value, submitted, onClear } = this.props;
e.preventDefault();
if (value.length > 0 || submitted) {
onClear();
this.setState({ options: [], selectedOption: -1 });
}
};
handleKeyDown = (e) => {
const { selectedOption } = this.state;
const options = searchEnabled ? this._getOptions().concat(this.defaultOptions) : this._getOptions();
switch(e.key) {
case 'Escape':
e.preventDefault();
this._unfocus();
break;
case 'ArrowDown':
e.preventDefault();
if (options.length > 0) {
this.setState({ selectedOption: Math.min(selectedOption + 1, options.length - 1) });
}
break;
case 'ArrowUp':
e.preventDefault();
if (options.length > 0) {
this.setState({ selectedOption: Math.max(selectedOption - 1, -1) });
}
break;
case 'Enter':
e.preventDefault();
if (selectedOption === -1) {
this._submit();
} else if (options.length > 0) {
options[selectedOption].action(e);
}
break;
case 'Delete':
if (selectedOption > -1 && options.length > 0) {
const search = options[selectedOption];
if (typeof search.forget === 'function') {
e.preventDefault();
search.forget(e);
}
}
break;
}
};
handleFocus = () => {
const { onShow, singleColumn } = this.props;
this.setState({ expanded: true, selectedOption: -1 });
onShow();
if (this.searchForm && !singleColumn) {
const { left, right } = this.searchForm.getBoundingClientRect();
if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
this.searchForm.scrollIntoView();
}
}
};
handleBlur = () => {
this.setState({ expanded: false, selectedOption: -1 });
};
handleHashtagClick = () => {
const { value, onClickSearchResult, history } = this.props;
const query = value.trim().replace(/^#/, '');
history.push(`/tags/${query}`);
onClickSearchResult(query, 'hashtag');
this._unfocus();
};
handleAccountClick = () => {
const { value, onClickSearchResult, history } = this.props;
const query = value.trim().replace(/^@/, '');
history.push(`/@${query}`);
onClickSearchResult(query, 'account');
this._unfocus();
};
handleURLClick = () => {
const { value, onOpenURL, history } = this.props;
onOpenURL(value, history);
this._unfocus();
};
handleStatusSearch = () => {
this._submit('statuses');
};
handleAccountSearch = () => {
this._submit('accounts');
};
handleRecentSearchClick = search => {
const { onChange, history } = this.props;
if (search.get('type') === 'account') {
history.push(`/@${search.get('q')}`);
} else if (search.get('type') === 'hashtag') {
history.push(`/tags/${search.get('q')}`);
} else {
onChange(search.get('q'));
this._submit(search.get('type'));
}
this._unfocus();
};
handleForgetRecentSearchClick = search => {
const { onForgetSearchResult } = this.props;
onForgetSearchResult(search.get('q'));
};
_unfocus () {
document.querySelector('.ui').parentElement.focus();
}
_insertText (text) {
const { value, onChange } = this.props;
if (value === '') {
onChange(text);
} else if (value[value.length - 1] === ' ') {
onChange(`${value}${text}`);
} else {
onChange(`${value} ${text}`);
}
}
_submit (type) {
const { onSubmit, openInRoute, value, onClickSearchResult, history } = this.props;
onSubmit(type);
if (value) {
onClickSearchResult(value, type);
}
if (openInRoute) {
history.push('/search');
}
this._unfocus();
}
_getOptions () {
const { options } = this.state;
if (options.length > 0) {
return options;
}
const { recent } = this.props;
return recent.toArray().map(search => ({
key: `${search.get('type')}/${search.get('q')}`,
label: labelForRecentSearch(search),
action: () => this.handleRecentSearchClick(search),
forget: e => {
e.stopPropagation();
this.handleForgetRecentSearchClick(search);
},
}));
}
_calculateOptions (value) {
const { signedIn } = this.props.identity;
const trimmedValue = value.trim();
const options = [];
if (trimmedValue.length > 0) {
const couldBeURL = trimmedValue.startsWith('https://') && !trimmedValue.includes(' ');
if (couldBeURL) {
options.push({ key: 'open-url', label: <FormattedMessage id='search.quick_action.open_url' defaultMessage='Open URL in Mastodon' />, action: this.handleURLClick });
}
const couldBeHashtag = (trimmedValue.startsWith('#') && trimmedValue.length > 1) || trimmedValue.match(HASHTAG_REGEX);
if (couldBeHashtag) {
options.push({ key: 'go-to-hashtag', label: <FormattedMessage id='search.quick_action.go_to_hashtag' defaultMessage='Go to hashtag {x}' values={{ x: <mark>#{trimmedValue.replace(/^#/, '')}</mark> }} />, action: this.handleHashtagClick });
}
const couldBeUsername = trimmedValue.match(/^@?[a-z0-9_-]+(@[^\s]+)?$/i);
if (couldBeUsername) {
options.push({ key: 'go-to-account', label: <FormattedMessage id='search.quick_action.go_to_account' defaultMessage='Go to profile {x}' values={{ x: <mark>@{trimmedValue.replace(/^@/, '')}</mark> }} />, action: this.handleAccountClick });
}
const couldBeStatusSearch = searchEnabled;
if (couldBeStatusSearch && signedIn) {
options.push({ key: 'status-search', label: <FormattedMessage id='search.quick_action.status_search' defaultMessage='Posts matching {x}' values={{ x: <mark>{trimmedValue}</mark> }} />, action: this.handleStatusSearch });
}
const couldBeUserSearch = true;
if (couldBeUserSearch) {
options.push({ key: 'account-search', label: <FormattedMessage id='search.quick_action.account_search' defaultMessage='Profiles matching {x}' values={{ x: <mark>{trimmedValue}</mark> }} />, action: this.handleAccountSearch });
}
}
this.setState({ options });
}
render () {
const { intl, value, submitted, recent } = this.props;
const { expanded, options, selectedOption } = this.state;
const { signedIn } = this.props.identity;
const hasValue = value.length > 0 || submitted;
return (
<div className={classNames('search', { active: expanded })}>
<input
ref={this.setRef}
className='search__input'
type='text'
placeholder={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
aria-label={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
value={value}
onChange={this.handleChange}
onKeyDown={this.handleKeyDown}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
/>
<div role='button' tabIndex={0} className='search__icon' onClick={this.handleClear}>
<Icon id='search' icon={SearchIcon} className={hasValue ? '' : 'active'} />
<Icon id='times-circle' icon={CancelIcon} className={hasValue ? 'active' : ''} aria-label={intl.formatMessage(messages.placeholder)} />
</div>
<div className='search__popout'>
{options.length === 0 && (
<>
<h4><FormattedMessage id='search_popout.recent' defaultMessage='Recent searches' /></h4>
<div className='search__popout__menu'>
{recent.size > 0 ? this._getOptions().map(({ label, key, action, forget }, i) => (
<button key={key} onMouseDown={action} className={classNames('search__popout__menu__item search__popout__menu__item--flex', { selected: selectedOption === i })}>
<span>{label}</span>
<button className='icon-button' onMouseDown={forget}><Icon id='times' icon={CloseIcon} /></button>
</button>
)) : (
<div className='search__popout__menu__message'>
<FormattedMessage id='search.no_recent_searches' defaultMessage='No recent searches' />
</div>
)}
</div>
</>
)}
{options.length > 0 && (
<>
<h4><FormattedMessage id='search_popout.quick_actions' defaultMessage='Quick actions' /></h4>
<div className='search__popout__menu'>
{options.map(({ key, label, action }, i) => (
<button key={key} onMouseDown={action} className={classNames('search__popout__menu__item', { selected: selectedOption === i })}>
{label}
</button>
))}
</div>
</>
)}
<h4><FormattedMessage id='search_popout.options' defaultMessage='Search options' /></h4>
{searchEnabled && signedIn ? (
<div className='search__popout__menu'>
{this.defaultOptions.map(({ key, label, action }, i) => (
<button key={key} onMouseDown={action} className={classNames('search__popout__menu__item', { selected: selectedOption === ((options.length || recent.size) + i) })}>
{label}
</button>
))}
</div>
) : (
<div className='search__popout__menu__message'>
{searchEnabled ? (
<FormattedMessage id='search_popout.full_text_search_logged_out_message' defaultMessage='Only available when logged in.' />
) : (
<FormattedMessage id='search_popout.full_text_search_disabled_message' defaultMessage='Not available on {domain}.' values={{ domain }} />
)}
</div>
)}
</div>
</div>
);
}
}
export default withRouter(withIdentity(injectIntl(Search)));

View File

@ -0,0 +1,593 @@
import { useCallback, useState, useRef } from 'react';
import {
defineMessages,
useIntl,
FormattedMessage,
FormattedList,
} from 'react-intl';
import classNames from 'classnames';
import { useHistory } from 'react-router-dom';
import { isFulfilled } from '@reduxjs/toolkit';
import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
import {
clickSearchResult,
forgetSearchResult,
openURL,
} from 'mastodon/actions/search';
import { Icon } from 'mastodon/components/icon';
import { useIdentity } from 'mastodon/identity_context';
import { domain, searchEnabled } from 'mastodon/initial_state';
import type { RecentSearch, SearchType } from 'mastodon/models/search';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { HASHTAG_REGEX } from 'mastodon/utils/hashtags';
const messages = defineMessages({
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
placeholderSignedIn: {
id: 'search.search_or_paste',
defaultMessage: 'Search or paste URL',
},
});
const labelForRecentSearch = (search: RecentSearch) => {
switch (search.type) {
case 'account':
return `@${search.q}`;
case 'hashtag':
return `#${search.q}`;
default:
return search.q;
}
};
const unfocus = () => {
document.querySelector('.ui')?.parentElement?.focus();
};
interface SearchOption {
key: string;
label: React.ReactNode;
action: (e: React.MouseEvent | React.KeyboardEvent) => void;
forget?: (e: React.MouseEvent | React.KeyboardEvent) => void;
}
export const Search: React.FC<{
singleColumn: boolean;
initialValue?: string;
}> = ({ singleColumn, initialValue }) => {
const intl = useIntl();
const recent = useAppSelector((state) => state.search.recent);
const { signedIn } = useIdentity();
const dispatch = useAppDispatch();
const history = useHistory();
const searchInputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState(initialValue ?? '');
const hasValue = value.length > 0;
const [expanded, setExpanded] = useState(false);
const [selectedOption, setSelectedOption] = useState(-1);
const [quickActions, setQuickActions] = useState<SearchOption[]>([]);
const searchOptions: SearchOption[] = [];
if (searchEnabled) {
searchOptions.push(
{
key: 'prompt-has',
label: (
<>
<mark>has:</mark>{' '}
<FormattedList
type='disjunction'
value={['media', 'poll', 'embed']}
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('has:');
},
},
{
key: 'prompt-is',
label: (
<>
<mark>is:</mark>{' '}
<FormattedList type='disjunction' value={['reply', 'sensitive']} />
</>
),
action: (e) => {
e.preventDefault();
insertText('is:');
},
},
{
key: 'prompt-language',
label: (
<>
<mark>language:</mark>{' '}
<FormattedMessage
id='search_popout.language_code'
defaultMessage='ISO language code'
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('language:');
},
},
{
key: 'prompt-from',
label: (
<>
<mark>from:</mark>{' '}
<FormattedMessage id='search_popout.user' defaultMessage='user' />
</>
),
action: (e) => {
e.preventDefault();
insertText('from:');
},
},
{
key: 'prompt-before',
label: (
<>
<mark>before:</mark>{' '}
<FormattedMessage
id='search_popout.specific_date'
defaultMessage='specific date'
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('before:');
},
},
{
key: 'prompt-during',
label: (
<>
<mark>during:</mark>{' '}
<FormattedMessage
id='search_popout.specific_date'
defaultMessage='specific date'
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('during:');
},
},
{
key: 'prompt-after',
label: (
<>
<mark>after:</mark>{' '}
<FormattedMessage
id='search_popout.specific_date'
defaultMessage='specific date'
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('after:');
},
},
{
key: 'prompt-in',
label: (
<>
<mark>in:</mark>{' '}
<FormattedList
type='disjunction'
value={['all', 'library', 'public']}
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('in:');
},
},
);
}
const recentOptions: SearchOption[] = recent.map((search) => ({
key: `${search.type}/${search.q}`,
label: labelForRecentSearch(search),
action: () => {
setValue(search.q);
if (search.type === 'account') {
history.push(`/@${search.q}`);
} else if (search.type === 'hashtag') {
history.push(`/tags/${search.q}`);
} else {
const queryParams = new URLSearchParams({ q: search.q });
if (search.type) queryParams.set('type', search.type);
history.push({ pathname: '/search', search: queryParams.toString() });
}
unfocus();
},
forget: (e) => {
e.stopPropagation();
void dispatch(forgetSearchResult(search.q));
},
}));
const navigableOptions = hasValue
? quickActions.concat(searchOptions)
: recentOptions.concat(quickActions, searchOptions);
const insertText = (text: string) => {
setValue((currentValue) => {
if (currentValue === '') {
return text;
} else if (currentValue.endsWith(' ')) {
return `${currentValue}${text}`;
} else {
return `${currentValue} ${text}`;
}
});
};
const submit = useCallback(
(q: string, type?: SearchType) => {
void dispatch(clickSearchResult({ q, type }));
const queryParams = new URLSearchParams({ q });
if (type) queryParams.set('type', type);
history.push({ pathname: '/search', search: queryParams.toString() });
unfocus();
},
[dispatch, history],
);
const handleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setValue(value);
const trimmedValue = value.trim();
const newQuickActions = [];
if (trimmedValue.length > 0) {
const couldBeURL =
trimmedValue.startsWith('https://') && !trimmedValue.includes(' ');
if (couldBeURL) {
newQuickActions.push({
key: 'open-url',
label: (
<FormattedMessage
id='search.quick_action.open_url'
defaultMessage='Open URL in Mastodon'
/>
),
action: async () => {
const result = await dispatch(openURL({ url: trimmedValue }));
if (isFulfilled(result)) {
if (result.payload.accounts[0]) {
history.push(`/@${result.payload.accounts[0].acct}`);
} else if (result.payload.statuses[0]) {
history.push(
`/@${result.payload.statuses[0].account.acct}/${result.payload.statuses[0].id}`,
);
}
}
unfocus();
},
});
}
const couldBeHashtag =
(trimmedValue.startsWith('#') && trimmedValue.length > 1) ||
trimmedValue.match(HASHTAG_REGEX);
if (couldBeHashtag) {
newQuickActions.push({
key: 'go-to-hashtag',
label: (
<FormattedMessage
id='search.quick_action.go_to_hashtag'
defaultMessage='Go to hashtag {x}'
values={{ x: <mark>#{trimmedValue.replace(/^#/, '')}</mark> }}
/>
),
action: () => {
const query = trimmedValue.replace(/^#/, '');
history.push(`/tags/${query}`);
void dispatch(clickSearchResult({ q: query, type: 'hashtag' }));
unfocus();
},
});
}
const couldBeUsername = /^@?[a-z0-9_-]+(@[^\s]+)?$/i.exec(trimmedValue);
if (couldBeUsername) {
newQuickActions.push({
key: 'go-to-account',
label: (
<FormattedMessage
id='search.quick_action.go_to_account'
defaultMessage='Go to profile {x}'
values={{ x: <mark>@{trimmedValue.replace(/^@/, '')}</mark> }}
/>
),
action: () => {
const query = trimmedValue.replace(/^@/, '');
history.push(`/@${query}`);
void dispatch(clickSearchResult({ q: query, type: 'account' }));
unfocus();
},
});
}
const couldBeStatusSearch = searchEnabled;
if (couldBeStatusSearch && signedIn) {
newQuickActions.push({
key: 'status-search',
label: (
<FormattedMessage
id='search.quick_action.status_search'
defaultMessage='Posts matching {x}'
values={{ x: <mark>{trimmedValue}</mark> }}
/>
),
action: () => {
submit(trimmedValue, 'statuses');
},
});
}
newQuickActions.push({
key: 'account-search',
label: (
<FormattedMessage
id='search.quick_action.account_search'
defaultMessage='Profiles matching {x}'
values={{ x: <mark>{trimmedValue}</mark> }}
/>
),
action: () => {
submit(trimmedValue, 'accounts');
},
});
}
setQuickActions(newQuickActions);
},
[dispatch, history, signedIn, setValue, setQuickActions, submit],
);
const handleClear = useCallback(() => {
setValue('');
setQuickActions([]);
setSelectedOption(-1);
}, [setValue, setQuickActions, setSelectedOption]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case 'Escape':
e.preventDefault();
unfocus();
break;
case 'ArrowDown':
e.preventDefault();
if (navigableOptions.length > 0) {
setSelectedOption(
Math.min(selectedOption + 1, navigableOptions.length - 1),
);
}
break;
case 'ArrowUp':
e.preventDefault();
if (navigableOptions.length > 0) {
setSelectedOption(Math.max(selectedOption - 1, -1));
}
break;
case 'Enter':
e.preventDefault();
if (selectedOption === -1) {
submit(value);
} else if (navigableOptions.length > 0) {
navigableOptions[selectedOption]?.action(e);
}
break;
case 'Delete':
if (selectedOption > -1 && navigableOptions.length > 0) {
const search = navigableOptions[selectedOption];
if (typeof search?.forget === 'function') {
e.preventDefault();
search.forget(e);
}
}
break;
}
},
[navigableOptions, value, selectedOption, setSelectedOption, submit],
);
const handleFocus = useCallback(() => {
setExpanded(true);
setSelectedOption(-1);
if (searchInputRef.current && !singleColumn) {
const { left, right } = searchInputRef.current.getBoundingClientRect();
if (
left < 0 ||
right > (window.innerWidth || document.documentElement.clientWidth)
) {
searchInputRef.current.scrollIntoView();
}
}
}, [setExpanded, setSelectedOption, singleColumn]);
const handleBlur = useCallback(() => {
setExpanded(false);
setSelectedOption(-1);
}, [setExpanded, setSelectedOption]);
return (
<form className={classNames('search', { active: expanded })}>
<input
ref={searchInputRef}
className='search__input'
type='text'
placeholder={intl.formatMessage(
signedIn ? messages.placeholderSignedIn : messages.placeholder,
)}
aria-label={intl.formatMessage(
signedIn ? messages.placeholderSignedIn : messages.placeholder,
)}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
/>
<button type='button' className='search__icon' onClick={handleClear}>
<Icon
id='search'
icon={SearchIcon}
className={hasValue ? '' : 'active'}
/>
<Icon
id='times-circle'
icon={CancelIcon}
className={hasValue ? 'active' : ''}
aria-label={intl.formatMessage(messages.placeholder)}
/>
</button>
<div className='search__popout'>
{!hasValue && (
<>
<h4>
<FormattedMessage
id='search_popout.recent'
defaultMessage='Recent searches'
/>
</h4>
<div className='search__popout__menu'>
{recentOptions.length > 0 ? (
recentOptions.map(({ label, key, action, forget }, i) => (
<button
key={key}
onMouseDown={action}
className={classNames(
'search__popout__menu__item search__popout__menu__item--flex',
{ selected: selectedOption === i },
)}
>
<span>{label}</span>
<button className='icon-button' onMouseDown={forget}>
<Icon id='times' icon={CloseIcon} />
</button>
</button>
))
) : (
<div className='search__popout__menu__message'>
<FormattedMessage
id='search.no_recent_searches'
defaultMessage='No recent searches'
/>
</div>
)}
</div>
</>
)}
{quickActions.length > 0 && (
<>
<h4>
<FormattedMessage
id='search_popout.quick_actions'
defaultMessage='Quick actions'
/>
</h4>
<div className='search__popout__menu'>
{quickActions.map(({ key, label, action }, i) => (
<button
key={key}
onMouseDown={action}
className={classNames('search__popout__menu__item', {
selected: selectedOption === i,
})}
>
{label}
</button>
))}
</div>
</>
)}
<h4>
<FormattedMessage
id='search_popout.options'
defaultMessage='Search options'
/>
</h4>
{searchEnabled && signedIn ? (
<div className='search__popout__menu'>
{searchOptions.map(({ key, label, action }, i) => (
<button
key={key}
onMouseDown={action}
className={classNames('search__popout__menu__item', {
selected:
selectedOption ===
(quickActions.length || recent.length) + i,
})}
>
{label}
</button>
))}
</div>
) : (
<div className='search__popout__menu__message'>
{searchEnabled ? (
<FormattedMessage
id='search_popout.full_text_search_logged_out_message'
defaultMessage='Only available when logged in.'
/>
) : (
<FormattedMessage
id='search_popout.full_text_search_disabled_message'
defaultMessage='Not available on {domain}.'
values={{ domain }}
/>
)}
</div>
)}
</div>
</form>
);
};

View File

@ -1,93 +0,0 @@
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import FindInPageIcon from '@/material-icons/400-24px/find_in_page.svg?react';
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
import { expandSearch } from 'mastodon/actions/search';
import { Account } from 'mastodon/components/account';
import { Icon } from 'mastodon/components/icon';
import { LoadMore } from 'mastodon/components/load_more';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { SearchSection } from 'mastodon/features/explore/components/search_section';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { ImmutableHashtag as Hashtag } from '../../../components/hashtag';
import StatusContainer from '../../../containers/status_container';
const INITIAL_PAGE_LIMIT = 10;
const withoutLastResult = list => {
if (list.size > INITIAL_PAGE_LIMIT && list.size % INITIAL_PAGE_LIMIT === 1) {
return list.skipLast(1);
} else {
return list;
}
};
export const SearchResults = () => {
const results = useAppSelector((state) => state.getIn(['search', 'results']));
const isLoading = useAppSelector((state) => state.getIn(['search', 'isLoading']));
const dispatch = useAppDispatch();
const handleLoadMoreAccounts = useCallback(() => {
dispatch(expandSearch('accounts'));
}, [dispatch]);
const handleLoadMoreStatuses = useCallback(() => {
dispatch(expandSearch('statuses'));
}, [dispatch]);
const handleLoadMoreHashtags = useCallback(() => {
dispatch(expandSearch('hashtags'));
}, [dispatch]);
let accounts, statuses, hashtags;
if (results.get('accounts') && results.get('accounts').size > 0) {
accounts = (
<SearchSection title={<><Icon id='users' icon={PeopleIcon} /><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></>}>
{withoutLastResult(results.get('accounts')).map(accountId => <Account key={accountId} id={accountId} />)}
{(results.get('accounts').size > INITIAL_PAGE_LIMIT && results.get('accounts').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={handleLoadMoreAccounts} />}
</SearchSection>
);
}
if (results.get('hashtags') && results.get('hashtags').size > 0) {
hashtags = (
<SearchSection title={<><Icon id='hashtag' icon={TagIcon} /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></>}>
{withoutLastResult(results.get('hashtags')).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
{(results.get('hashtags').size > INITIAL_PAGE_LIMIT && results.get('hashtags').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={handleLoadMoreHashtags} />}
</SearchSection>
);
}
if (results.get('statuses') && results.get('statuses').size > 0) {
statuses = (
<SearchSection title={<><Icon id='quote-right' icon={FindInPageIcon} /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></>}>
{withoutLastResult(results.get('statuses')).map(statusId => <StatusContainer key={statusId} id={statusId} />)}
{(results.get('statuses').size > INITIAL_PAGE_LIMIT && results.get('statuses').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={handleLoadMoreStatuses} />}
</SearchSection>
);
}
return (
<div className='search-results'>
{!accounts && !hashtags && !statuses && (
isLoading ? (
<LoadingIndicator />
) : (
<div className='empty-column-indicator'>
<FormattedMessage id='search_results.nothing_found' defaultMessage='Could not find anything for these search terms' />
</div>
)
)}
{accounts}
{hashtags}
{statuses}
</div>
);
};

View File

@ -1,59 +0,0 @@
import { createSelector } from '@reduxjs/toolkit';
import { connect } from 'react-redux';
import {
changeSearch,
clearSearch,
submitSearch,
showSearch,
openURL,
clickSearchResult,
forgetSearchResult,
} from 'mastodon/actions/search';
import Search from '../components/search';
const getRecentSearches = createSelector(
state => state.getIn(['search', 'recent']),
recent => recent.reverse(),
);
const mapStateToProps = state => ({
value: state.getIn(['search', 'value']),
submitted: state.getIn(['search', 'submitted']),
recent: getRecentSearches(state),
});
const mapDispatchToProps = dispatch => ({
onChange (value) {
dispatch(changeSearch(value));
},
onClear () {
dispatch(clearSearch());
},
onSubmit (type) {
dispatch(submitSearch(type));
},
onShow () {
dispatch(showSearch());
},
onOpenURL (q, routerHistory) {
dispatch(openURL(q, routerHistory));
},
onClickSearchResult (q, type) {
dispatch(clickSearchResult(q, type));
},
onForgetSearchResult (q) {
dispatch(forgetSearchResult(q));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(Search);

View File

@ -9,8 +9,6 @@ import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import spring from 'react-motion/lib/spring';
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
import LogoutIcon from '@/material-icons/400-24px/logout.svg?react';
@ -26,11 +24,9 @@ import elephantUIPlane from '../../../images/elephant_ui_plane.svg';
import { changeComposing, mountCompose, unmountCompose } from '../../actions/compose';
import { mascot } from '../../initial_state';
import { isMobile } from '../../is_mobile';
import Motion from '../ui/util/optional_motion';
import { SearchResults } from './components/search_results';
import { Search } from './components/search';
import ComposeFormContainer from './containers/compose_form_container';
import SearchContainer from './containers/search_container';
const messages = defineMessages({
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
@ -43,9 +39,8 @@ const messages = defineMessages({
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
});
const mapStateToProps = (state, ownProps) => ({
const mapStateToProps = (state) => ({
columns: state.getIn(['settings', 'columns']),
showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : false,
});
class Compose extends PureComponent {
@ -54,7 +49,6 @@ class Compose extends PureComponent {
dispatch: PropTypes.func.isRequired,
columns: ImmutablePropTypes.list.isRequired,
multiColumn: PropTypes.bool,
showSearch: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
@ -88,7 +82,7 @@ class Compose extends PureComponent {
};
render () {
const { multiColumn, showSearch, intl } = this.props;
const { multiColumn, intl } = this.props;
if (multiColumn) {
const { columns } = this.props;
@ -113,7 +107,7 @@ class Compose extends PureComponent {
<a href='/auth/sign_out' className='drawer__tab' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)} onClick={this.handleLogoutClick}><Icon id='sign-out' icon={LogoutIcon} /></a>
</nav>
{multiColumn && <SearchContainer /> }
{multiColumn && <Search /> }
<div className='drawer__pager'>
<div className='drawer__inner' onFocus={this.onFocus}>
@ -123,14 +117,6 @@ class Compose extends PureComponent {
<img alt='' draggable='false' src={mascot || elephantUIPlane} />
</div>
</div>
<Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
{({ x }) => (
<div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
<SearchResults />
</div>
)}
</Motion>
</div>
</div>
);

View File

@ -1,20 +0,0 @@
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
export const SearchSection = ({ title, onClickMore, children }) => (
<div className='search-results__section'>
<div className='search-results__section__header'>
<h3>{title}</h3>
{onClickMore && <button onClick={onClickMore}><FormattedMessage id='search_results.see_all' defaultMessage='See all' /></button>}
</div>
{children}
</div>
);
SearchSection.propTypes = {
title: PropTypes.node.isRequired,
onClickMore: PropTypes.func,
children: PropTypes.children,
};

View File

@ -1,114 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { NavLink, Switch, Route } from 'react-router-dom';
import { connect } from 'react-redux';
import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import Search from 'mastodon/features/compose/containers/search_container';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { trendsEnabled } from 'mastodon/initial_state';
import Links from './links';
import SearchResults from './results';
import Statuses from './statuses';
import Suggestions from './suggestions';
import Tags from './tags';
const messages = defineMessages({
title: { id: 'explore.title', defaultMessage: 'Explore' },
searchResults: { id: 'explore.search_results', defaultMessage: 'Search results' },
});
const mapStateToProps = state => ({
layout: state.getIn(['meta', 'layout']),
isSearching: state.getIn(['search', 'submitted']) || !trendsEnabled,
});
class Explore extends PureComponent {
static propTypes = {
identity: identityContextPropShape,
intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool,
isSearching: PropTypes.bool,
};
handleHeaderClick = () => {
this.column.scrollTop();
};
setRef = c => {
this.column = c;
};
render() {
const { intl, multiColumn, isSearching } = this.props;
const { signedIn } = this.props.identity;
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader
icon={isSearching ? 'search' : 'explore'}
iconComponent={isSearching ? SearchIcon : ExploreIcon}
title={intl.formatMessage(isSearching ? messages.searchResults : messages.title)}
onClick={this.handleHeaderClick}
multiColumn={multiColumn}
/>
<div className='explore__search-header'>
<Search />
</div>
{isSearching ? (
<SearchResults />
) : (
<>
<div className='account__section-headline'>
<NavLink exact to='/explore'>
<FormattedMessage tagName='div' id='explore.trending_statuses' defaultMessage='Posts' />
</NavLink>
<NavLink exact to='/explore/tags'>
<FormattedMessage tagName='div' id='explore.trending_tags' defaultMessage='Hashtags' />
</NavLink>
{signedIn && (
<NavLink exact to='/explore/suggestions'>
<FormattedMessage tagName='div' id='explore.suggested_follows' defaultMessage='People' />
</NavLink>
)}
<NavLink exact to='/explore/links'>
<FormattedMessage tagName='div' id='explore.trending_links' defaultMessage='News' />
</NavLink>
</div>
<Switch>
<Route path='/explore/tags' component={Tags} />
<Route path='/explore/links' component={Links} />
<Route path='/explore/suggestions' component={Suggestions} />
<Route exact path={['/explore', '/explore/posts', '/search']}>
<Statuses multiColumn={multiColumn} />
</Route>
</Switch>
<Helmet>
<title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content={isSearching ? 'noindex' : 'all'} />
</Helmet>
</>
)}
</Column>
);
}
}
export default withIdentity(connect(mapStateToProps)(injectIntl(Explore)));

View File

@ -0,0 +1,105 @@
import { useCallback, useRef } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { NavLink, Switch, Route } from 'react-router-dom';
import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
import { Column } from 'mastodon/components/column';
import type { ColumnRef } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { Search } from 'mastodon/features/compose/components/search';
import { useIdentity } from 'mastodon/identity_context';
import Links from './links';
import Statuses from './statuses';
import Suggestions from './suggestions';
import Tags from './tags';
const messages = defineMessages({
title: { id: 'explore.title', defaultMessage: 'Explore' },
});
const Explore: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
const { signedIn } = useIdentity();
const intl = useIntl();
const columnRef = useRef<ColumnRef>(null);
const handleHeaderClick = useCallback(() => {
columnRef.current?.scrollTop();
}, []);
return (
<Column
bindToDocument={!multiColumn}
ref={columnRef}
label={intl.formatMessage(messages.title)}
>
<ColumnHeader
icon={'explore'}
iconComponent={ExploreIcon}
title={intl.formatMessage(messages.title)}
onClick={handleHeaderClick}
multiColumn={multiColumn}
/>
<div className='explore__search-header'>
<Search singleColumn />
</div>
<div className='account__section-headline'>
<NavLink exact to='/explore'>
<FormattedMessage
tagName='div'
id='explore.trending_statuses'
defaultMessage='Posts'
/>
</NavLink>
<NavLink exact to='/explore/tags'>
<FormattedMessage
tagName='div'
id='explore.trending_tags'
defaultMessage='Hashtags'
/>
</NavLink>
{signedIn && (
<NavLink exact to='/explore/suggestions'>
<FormattedMessage
tagName='div'
id='explore.suggested_follows'
defaultMessage='People'
/>
</NavLink>
)}
<NavLink exact to='/explore/links'>
<FormattedMessage
tagName='div'
id='explore.trending_links'
defaultMessage='News'
/>
</NavLink>
</div>
<Switch>
<Route path='/explore/tags' component={Tags} />
<Route path='/explore/links' component={Links} />
<Route path='/explore/suggestions' component={Suggestions} />
<Route exact path={['/explore', '/explore/posts']}>
<Statuses multiColumn={multiColumn} />
</Route>
</Switch>
<Helmet>
<title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content='all' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default Explore;

View File

@ -1,232 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { List as ImmutableList } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import FindInPageIcon from '@/material-icons/400-24px/find_in_page.svg?react';
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
import { submitSearch, expandSearch } from 'mastodon/actions/search';
import { Account } from 'mastodon/components/account';
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import Status from 'mastodon/containers/status_container';
import { SearchSection } from './components/search_section';
const messages = defineMessages({
title: { id: 'search_results.title', defaultMessage: 'Search for {q}' },
});
const mapStateToProps = state => ({
isLoading: state.getIn(['search', 'isLoading']),
results: state.getIn(['search', 'results']),
q: state.getIn(['search', 'searchTerm']),
submittedType: state.getIn(['search', 'type']),
});
const INITIAL_PAGE_LIMIT = 10;
const INITIAL_DISPLAY = 4;
const hidePeek = list => {
if (list.size > INITIAL_PAGE_LIMIT && list.size % INITIAL_PAGE_LIMIT === 1) {
return list.skipLast(1);
} else {
return list;
}
};
const renderAccounts = accounts => hidePeek(accounts).map(id => (
<Account key={id} id={id} />
));
const renderHashtags = hashtags => hidePeek(hashtags).map(hashtag => (
<Hashtag key={hashtag.get('name')} hashtag={hashtag} />
));
const renderStatuses = statuses => hidePeek(statuses).map(id => (
<Status key={id} id={id} />
));
class Results extends PureComponent {
static propTypes = {
results: ImmutablePropTypes.contains({
accounts: ImmutablePropTypes.orderedSet,
statuses: ImmutablePropTypes.orderedSet,
hashtags: ImmutablePropTypes.orderedSet,
}),
isLoading: PropTypes.bool,
multiColumn: PropTypes.bool,
dispatch: PropTypes.func.isRequired,
q: PropTypes.string,
intl: PropTypes.object,
submittedType: PropTypes.oneOf(['accounts', 'statuses', 'hashtags']),
};
state = {
type: this.props.submittedType || 'all',
};
static getDerivedStateFromProps(props, state) {
if (props.submittedType !== state.type) {
return {
type: props.submittedType || 'all',
};
}
return null;
}
handleSelectAll = () => {
const { submittedType, dispatch } = this.props;
// If we originally searched for a specific type, we need to resubmit
// the query to get all types of results
if (submittedType) {
dispatch(submitSearch());
}
this.setState({ type: 'all' });
};
handleSelectAccounts = () => {
const { submittedType, dispatch } = this.props;
// If we originally searched for something else (but not everything),
// we need to resubmit the query for this specific type
if (submittedType !== 'accounts') {
dispatch(submitSearch('accounts'));
}
this.setState({ type: 'accounts' });
};
handleSelectHashtags = () => {
const { submittedType, dispatch } = this.props;
// If we originally searched for something else (but not everything),
// we need to resubmit the query for this specific type
if (submittedType !== 'hashtags') {
dispatch(submitSearch('hashtags'));
}
this.setState({ type: 'hashtags' });
};
handleSelectStatuses = () => {
const { submittedType, dispatch } = this.props;
// If we originally searched for something else (but not everything),
// we need to resubmit the query for this specific type
if (submittedType !== 'statuses') {
dispatch(submitSearch('statuses'));
}
this.setState({ type: 'statuses' });
};
handleLoadMoreAccounts = () => this._loadMore('accounts');
handleLoadMoreStatuses = () => this._loadMore('statuses');
handleLoadMoreHashtags = () => this._loadMore('hashtags');
_loadMore (type) {
const { dispatch } = this.props;
dispatch(expandSearch(type));
}
handleLoadMore = () => {
const { type } = this.state;
if (type !== 'all') {
this._loadMore(type);
}
};
render () {
const { intl, isLoading, q, results } = this.props;
const { type } = this.state;
// We request 1 more result than we display so we can tell if there'd be a next page
const hasMore = type !== 'all' ? results.get(type, ImmutableList()).size > INITIAL_PAGE_LIMIT && results.get(type).size % INITIAL_PAGE_LIMIT === 1 : false;
let filteredResults;
const accounts = results.get('accounts', ImmutableList());
const hashtags = results.get('hashtags', ImmutableList());
const statuses = results.get('statuses', ImmutableList());
switch(type) {
case 'all':
filteredResults = (accounts.size + hashtags.size + statuses.size) > 0 ? (
<>
{accounts.size > 0 && (
<SearchSection key='accounts' title={<><Icon id='users' icon={PeopleIcon} /><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></>} onClickMore={this.handleLoadMoreAccounts}>
{accounts.take(INITIAL_DISPLAY).map(id => <Account key={id} id={id} />)}
</SearchSection>
)}
{hashtags.size > 0 && (
<SearchSection key='hashtags' title={<><Icon id='hashtag' icon={TagIcon} /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></>} onClickMore={this.handleLoadMoreHashtags}>
{hashtags.take(INITIAL_DISPLAY).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
</SearchSection>
)}
{statuses.size > 0 && (
<SearchSection key='statuses' title={<><Icon id='quote-right' icon={FindInPageIcon} /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></>} onClickMore={this.handleLoadMoreStatuses}>
{statuses.take(INITIAL_DISPLAY).map(id => <Status key={id} id={id} />)}
</SearchSection>
)}
</>
) : [];
break;
case 'accounts':
filteredResults = renderAccounts(accounts);
break;
case 'hashtags':
filteredResults = renderHashtags(hashtags);
break;
case 'statuses':
filteredResults = renderStatuses(statuses);
break;
}
return (
<>
<div className='account__section-headline'>
<button onClick={this.handleSelectAll} className={type === 'all' ? 'active' : undefined}><FormattedMessage id='search_results.all' defaultMessage='All' /></button>
<button onClick={this.handleSelectAccounts} className={type === 'accounts' ? 'active' : undefined}><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></button>
<button onClick={this.handleSelectHashtags} className={type === 'hashtags' ? 'active' : undefined}><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></button>
<button onClick={this.handleSelectStatuses} className={type === 'statuses' ? 'active' : undefined}><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></button>
</div>
<div className='explore__search-results' data-nosnippet>
<ScrollableList
scrollKey='search-results'
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
hasMore={hasMore}
emptyMessage={<FormattedMessage id='search_results.nothing_found' defaultMessage='Could not find anything for these search terms' />}
bindToDocument
>
{filteredResults}
</ScrollableList>
</div>
<Helmet>
<title>{intl.formatMessage(messages.title, { q })}</title>
</Helmet>
</>
);
}
}
export default connect(mapStateToProps)(injectIntl(Results));

View File

@ -0,0 +1,23 @@
import { FormattedMessage } from 'react-intl';
export const SearchSection: React.FC<{
title: React.ReactNode;
onClickMore?: () => void;
children: React.ReactNode;
}> = ({ title, onClickMore, children }) => (
<div className='search-results__section'>
<div className='search-results__section__header'>
<h3>{title}</h3>
{onClickMore && (
<button onClick={onClickMore}>
<FormattedMessage
id='search_results.see_all'
defaultMessage='See all'
/>
</button>
)}
</div>
{children}
</div>
);

View File

@ -0,0 +1,304 @@
import { useCallback, useEffect, useRef } from 'react';
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { useSearchParam } from '@/hooks/useSearchParam';
import FindInPageIcon from '@/material-icons/400-24px/find_in_page.svg?react';
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
import { submitSearch, expandSearch } from 'mastodon/actions/search';
import type { ApiSearchType } from 'mastodon/api_types/search';
import { Account } from 'mastodon/components/account';
import { Column } from 'mastodon/components/column';
import type { ColumnRef } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { CompatibilityHashtag as Hashtag } from 'mastodon/components/hashtag';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import Status from 'mastodon/containers/status_container';
import { Search } from 'mastodon/features/compose/components/search';
import type { Hashtag as HashtagType } from 'mastodon/models/tags';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { SearchSection } from './components/search_section';
const messages = defineMessages({
title: { id: 'search_results.title', defaultMessage: 'Search for "{q}"' },
});
const INITIAL_PAGE_LIMIT = 10;
const INITIAL_DISPLAY = 4;
const hidePeek = <T,>(list: T[]) => {
if (
list.length > INITIAL_PAGE_LIMIT &&
list.length % INITIAL_PAGE_LIMIT === 1
) {
return list.slice(0, -2);
} else {
return list;
}
};
const renderAccounts = (accountIds: string[]) =>
hidePeek<string>(accountIds).map((id) => <Account key={id} id={id} />);
const renderHashtags = (hashtags: HashtagType[]) =>
hidePeek<HashtagType>(hashtags).map((hashtag) => (
<Hashtag key={hashtag.name} hashtag={hashtag} />
));
const renderStatuses = (statusIds: string[]) =>
hidePeek<string>(statusIds).map((id) => (
// @ts-expect-error inferred props are wrong
<Status key={id} id={id} />
));
type SearchType = 'all' | ApiSearchType;
const typeFromParam = (param?: string): SearchType => {
if (param && ['all', 'accounts', 'statuses', 'hashtags'].includes(param)) {
return param as SearchType;
} else {
return 'all';
}
};
export const SearchResults: React.FC<{ multiColumn: boolean }> = ({
multiColumn,
}) => {
const columnRef = useRef<ColumnRef>(null);
const intl = useIntl();
const [q] = useSearchParam('q');
const [type, setType] = useSearchParam('type');
const isLoading = useAppSelector((state) => state.search.loading);
const results = useAppSelector((state) => state.search.results);
const dispatch = useAppDispatch();
const mappedType = typeFromParam(type);
const trimmedValue = q?.trim() ?? '';
useEffect(() => {
if (trimmedValue.length > 0) {
void dispatch(
submitSearch({
q: trimmedValue,
type: mappedType === 'all' ? undefined : mappedType,
}),
);
}
}, [dispatch, trimmedValue, mappedType]);
const handleHeaderClick = useCallback(() => {
columnRef.current?.scrollTop();
}, []);
const handleSelectAll = useCallback(() => {
setType(null);
}, [setType]);
const handleSelectAccounts = useCallback(() => {
setType('accounts');
}, [setType]);
const handleSelectHashtags = useCallback(() => {
setType('hashtags');
}, [setType]);
const handleSelectStatuses = useCallback(() => {
setType('statuses');
}, [setType]);
const handleLoadMore = useCallback(() => {
if (mappedType !== 'all') {
void dispatch(expandSearch({ type: mappedType }));
}
}, [dispatch, mappedType]);
// We request 1 more result than we display so we can tell if there'd be a next page
const hasMore =
mappedType !== 'all' && results
? results[mappedType].length > INITIAL_PAGE_LIMIT &&
results[mappedType].length % INITIAL_PAGE_LIMIT === 1
: false;
let filteredResults;
if (results) {
switch (mappedType) {
case 'all':
filteredResults =
results.accounts.length +
results.hashtags.length +
results.statuses.length >
0 ? (
<>
{results.accounts.length > 0 && (
<SearchSection
key='accounts'
title={
<>
<Icon id='users' icon={PeopleIcon} />
<FormattedMessage
id='search_results.accounts'
defaultMessage='Profiles'
/>
</>
}
onClickMore={handleSelectAccounts}
>
{results.accounts.slice(0, INITIAL_DISPLAY).map((id) => (
<Account key={id} id={id} />
))}
</SearchSection>
)}
{results.hashtags.length > 0 && (
<SearchSection
key='hashtags'
title={
<>
<Icon id='hashtag' icon={TagIcon} />
<FormattedMessage
id='search_results.hashtags'
defaultMessage='Hashtags'
/>
</>
}
onClickMore={handleSelectHashtags}
>
{results.hashtags.slice(0, INITIAL_DISPLAY).map((hashtag) => (
<Hashtag key={hashtag.name} hashtag={hashtag} />
))}
</SearchSection>
)}
{results.statuses.length > 0 && (
<SearchSection
key='statuses'
title={
<>
<Icon id='quote-right' icon={FindInPageIcon} />
<FormattedMessage
id='search_results.statuses'
defaultMessage='Posts'
/>
</>
}
onClickMore={handleSelectStatuses}
>
{results.statuses.slice(0, INITIAL_DISPLAY).map((id) => (
// @ts-expect-error inferred props are wrong
<Status key={id} id={id} />
))}
</SearchSection>
)}
</>
) : (
[]
);
break;
case 'accounts':
filteredResults = renderAccounts(results.accounts);
break;
case 'hashtags':
filteredResults = renderHashtags(results.hashtags);
break;
case 'statuses':
filteredResults = renderStatuses(results.statuses);
break;
}
}
return (
<Column
bindToDocument={!multiColumn}
ref={columnRef}
label={intl.formatMessage(messages.title, { q })}
>
<ColumnHeader
icon={'search'}
iconComponent={SearchIcon}
title={intl.formatMessage(messages.title, { q })}
onClick={handleHeaderClick}
multiColumn={multiColumn}
/>
<div className='explore__search-header'>
<Search singleColumn initialValue={trimmedValue} />
</div>
<div className='account__section-headline'>
<button
onClick={handleSelectAll}
className={mappedType === 'all' ? 'active' : undefined}
>
<FormattedMessage id='search_results.all' defaultMessage='All' />
</button>
<button
onClick={handleSelectAccounts}
className={mappedType === 'accounts' ? 'active' : undefined}
>
<FormattedMessage
id='search_results.accounts'
defaultMessage='Profiles'
/>
</button>
<button
onClick={handleSelectHashtags}
className={mappedType === 'hashtags' ? 'active' : undefined}
>
<FormattedMessage
id='search_results.hashtags'
defaultMessage='Hashtags'
/>
</button>
<button
onClick={handleSelectStatuses}
className={mappedType === 'statuses' ? 'active' : undefined}
>
<FormattedMessage
id='search_results.statuses'
defaultMessage='Posts'
/>
</button>
</div>
<div className='explore__search-results' data-nosnippet>
<ScrollableList
scrollKey='search-results'
isLoading={isLoading}
showLoading={isLoading && !results}
onLoadMore={handleLoadMore}
hasMore={hasMore}
emptyMessage={
trimmedValue.length > 0 ? (
<FormattedMessage
id='search_results.no_results'
defaultMessage='No results.'
/>
) : (
<FormattedMessage
id='search_results.no_search_yet'
defaultMessage='Try searching for posts, profiles or hashtags.'
/>
)
}
bindToDocument
>
{filteredResults}
</ScrollableList>
</div>
<Helmet>
<title>{intl.formatMessage(messages.title, { q })}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default SearchResults;

View File

@ -5,8 +5,8 @@ import { connect } from 'react-redux';
import { changeComposing, mountCompose, unmountCompose } from 'mastodon/actions/compose';
import ServerBanner from 'mastodon/components/server_banner';
import { Search } from 'mastodon/features/compose/components/search';
import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container';
import SearchContainer from 'mastodon/features/compose/containers/search_container';
import { LinkFooter } from 'mastodon/features/ui/components/link_footer';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
@ -41,7 +41,7 @@ class ComposePanel extends PureComponent {
return (
<div className='compose-panel' onFocus={this.onFocus}>
<SearchContainer openInRoute />
<Search openInRoute />
{!signedIn && (
<>

View File

@ -69,6 +69,7 @@ import {
OnboardingProfile,
OnboardingFollows,
Explore,
Search,
About,
PrivacyPolicy,
TermsOfService,
@ -225,7 +226,8 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path={['/start', '/start/profile']} exact component={OnboardingProfile} content={children} />
<WrappedRoute path='/start/follows' component={OnboardingFollows} content={children} />
<WrappedRoute path='/directory' component={Directory} content={children} />
<WrappedRoute path={['/explore', '/search']} component={Explore} content={children} />
<WrappedRoute path='/explore' component={Explore} content={children} />
<WrappedRoute path='/search' component={Search} content={children} />
<WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
<WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />

View File

@ -174,6 +174,10 @@ export function Explore () {
return import(/* webpackChunkName: "features/explore" */'../../explore');
}
export function Search () {
return import(/* webpackChunkName: "features/explore" */'../../search');
}
export function FilterModal () {
return import(/*webpackChunkName: "modals/filter_modal" */'../components/filter_modal');
}

View File

@ -309,7 +309,6 @@
"error.unexpected_crash.next_steps_addons": "Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.",
"errors.unexpected_crash.copy_stacktrace": "Copy stacktrace to clipboard",
"errors.unexpected_crash.report_issue": "Report issue",
"explore.search_results": "Search results",
"explore.suggested_follows": "People",
"explore.title": "Explore",
"explore.trending_links": "News",
@ -783,10 +782,11 @@
"search_results.accounts": "Profiles",
"search_results.all": "All",
"search_results.hashtags": "Hashtags",
"search_results.nothing_found": "Could not find anything for these search terms",
"search_results.no_results": "No results.",
"search_results.no_search_yet": "Try searching for posts, profiles or hashtags.",
"search_results.see_all": "See all",
"search_results.statuses": "Posts",
"search_results.title": "Search for {q}",
"search_results.title": "Search for \"{q}\"",
"server_banner.about_active_users": "People using this server during the last 30 days (Monthly Active Users)",
"server_banner.active_users": "active users",
"server_banner.administered_by": "Administered by:",

View File

@ -0,0 +1,21 @@
import type { ApiSearchResultsJSON } from 'mastodon/api_types/search';
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
export type SearchType = 'account' | 'hashtag' | 'accounts' | 'statuses';
export interface RecentSearch {
q: string;
type?: SearchType;
}
export interface SearchResults {
accounts: string[];
statuses: string[];
hashtags: ApiHashtagJSON[];
}
export const createSearchResults = (serverJSON: ApiSearchResultsJSON) => ({
accounts: serverJSON.accounts.map((account) => account.id),
statuses: serverJSON.statuses.map((status) => status.id),
hashtags: serverJSON.hashtags,
});

View File

@ -30,7 +30,7 @@ import { pictureInPictureReducer } from './picture_in_picture';
import { pollsReducer } from './polls';
import push_notifications from './push_notifications';
import { relationshipsReducer } from './relationships';
import search from './search';
import { searchReducer } from './search';
import server from './server';
import settings from './settings';
import status_lists from './status_lists';
@ -60,7 +60,7 @@ const reducers = {
server,
contexts,
compose,
search,
search: searchReducer,
media_attachments,
notifications,
notificationGroups: notificationGroupsReducer,

View File

@ -1,84 +0,0 @@
import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
import {
COMPOSE_MENTION,
COMPOSE_REPLY,
COMPOSE_DIRECT,
} from '../actions/compose';
import {
SEARCH_CHANGE,
SEARCH_CLEAR,
SEARCH_FETCH_REQUEST,
SEARCH_FETCH_FAIL,
SEARCH_FETCH_SUCCESS,
SEARCH_SHOW,
SEARCH_EXPAND_REQUEST,
SEARCH_EXPAND_SUCCESS,
SEARCH_EXPAND_FAIL,
SEARCH_HISTORY_UPDATE,
} from '../actions/search';
const initialState = ImmutableMap({
value: '',
submitted: false,
hidden: false,
results: ImmutableMap(),
isLoading: false,
searchTerm: '',
type: null,
recent: ImmutableOrderedSet(),
});
export default function search(state = initialState, action) {
switch(action.type) {
case SEARCH_CHANGE:
return state.set('value', action.value);
case SEARCH_CLEAR:
return state.withMutations(map => {
map.set('value', '');
map.set('results', ImmutableMap());
map.set('submitted', false);
map.set('hidden', false);
map.set('searchTerm', '');
map.set('type', null);
});
case SEARCH_SHOW:
return state.set('hidden', false);
case COMPOSE_REPLY:
case COMPOSE_MENTION:
case COMPOSE_DIRECT:
return state.set('hidden', true);
case SEARCH_FETCH_REQUEST:
return state.withMutations(map => {
map.set('results', ImmutableMap());
map.set('isLoading', true);
map.set('submitted', true);
map.set('type', action.searchType);
});
case SEARCH_FETCH_FAIL:
case SEARCH_EXPAND_FAIL:
return state.set('isLoading', false);
case SEARCH_FETCH_SUCCESS:
return state.withMutations(map => {
map.set('results', ImmutableMap({
accounts: ImmutableOrderedSet(action.results.accounts.map(item => item.id)),
statuses: ImmutableOrderedSet(action.results.statuses.map(item => item.id)),
hashtags: ImmutableOrderedSet(fromJS(action.results.hashtags)),
}));
map.set('searchTerm', action.searchTerm);
map.set('type', action.searchType);
map.set('isLoading', false);
});
case SEARCH_EXPAND_REQUEST:
return state.set('type', action.searchType).set('isLoading', true);
case SEARCH_EXPAND_SUCCESS: {
const results = action.searchType === 'hashtags' ? ImmutableOrderedSet(fromJS(action.results.hashtags)) : action.results[action.searchType].map(item => item.id);
return state.updateIn(['results', action.searchType], list => list.union(results)).set('isLoading', false);
}
case SEARCH_HISTORY_UPDATE:
return state.set('recent', ImmutableOrderedSet(fromJS(action.recent)));
default:
return state;
}
}

View File

@ -0,0 +1,74 @@
import { createReducer, isAnyOf } from '@reduxjs/toolkit';
import type { ApiSearchType } from 'mastodon/api_types/search';
import type { RecentSearch, SearchResults } from 'mastodon/models/search';
import { createSearchResults } from 'mastodon/models/search';
import {
updateSearchHistory,
submitSearch,
expandSearch,
} from '../actions/search';
interface State {
recent: RecentSearch[];
q: string;
type?: ApiSearchType;
loading: boolean;
results?: SearchResults;
}
const initialState: State = {
recent: [],
q: '',
type: undefined,
loading: false,
results: undefined,
};
export const searchReducer = createReducer(initialState, (builder) => {
builder.addCase(submitSearch.fulfilled, (state, action) => {
state.q = action.meta.arg.q;
state.type = action.meta.arg.type;
state.results = createSearchResults(action.payload);
state.loading = false;
});
builder.addCase(expandSearch.fulfilled, (state, action) => {
const type = action.meta.arg.type;
const results = createSearchResults(action.payload);
state.type = type;
state.results = {
accounts: state.results
? [...state.results.accounts, ...results.accounts]
: results.accounts,
statuses: state.results
? [...state.results.statuses, ...results.statuses]
: results.statuses,
hashtags: state.results
? [...state.results.hashtags, ...results.hashtags]
: results.hashtags,
};
state.loading = false;
});
builder.addCase(updateSearchHistory, (state, action) => {
state.recent = action.payload;
});
builder.addMatcher(
isAnyOf(expandSearch.pending, submitSearch.pending),
(state, action) => {
state.type = action.meta.arg.type;
state.loading = true;
},
);
builder.addMatcher(
isAnyOf(expandSearch.rejected, submitSearch.rejected),
(state) => {
state.loading = false;
},
);
});

View File

@ -3017,7 +3017,9 @@ $ui-header-logo-wordmark-width: 99px;
.column > .scrollable,
.tabs-bar__wrapper .column-header,
.tabs-bar__wrapper .column-back-button {
.tabs-bar__wrapper .column-back-button,
.explore__search-header,
.column-search-header {
border-left: 0;
border-right: 0;
}
@ -3060,10 +3062,6 @@ $ui-header-logo-wordmark-width: 99px;
}
}
.explore__search-header {
display: none;
}
.explore__suggestions__card {
padding: 12px 16px;
gap: 8px;
@ -3137,10 +3135,6 @@ $ui-header-logo-wordmark-width: 99px;
.columns-area__panels__pane--compositional {
display: none;
}
.explore__search-header {
display: flex;
}
}
.icon-with-badge {
@ -5446,6 +5440,17 @@ a.status-card {
}
.search__icon {
background: transparent;
border: 0;
padding: 0;
position: absolute;
top: 12px + 2px;
cursor: default;
pointer-events: none;
margin-inline-start: 16px - 2px;
width: 20px;
height: 20px;
&::-moz-focus-inner {
border: 0;
}
@ -5457,17 +5462,14 @@ a.status-card {
.icon {
position: absolute;
top: 12px + 2px;
display: inline-block;
top: 0;
inset-inline-start: 0;
opacity: 0;
transition: all 100ms linear;
transition-property: transform, opacity;
width: 20px;
height: 20px;
color: $darker-text-color;
cursor: default;
pointer-events: none;
margin-inline-start: 16px - 2px;
&.active {
pointer-events: auto;
@ -8645,6 +8647,9 @@ noscript {
.explore__search-header {
justify-content: center;
align-items: center;
border: 1px solid var(--background-border-color);
border-top: 0;
border-bottom: 0;
padding: 16px;
padding-bottom: 8px;
@ -8663,13 +8668,21 @@ noscript {
border: 1px solid var(--background-border-color);
}
.search .icon {
.search__icon {
top: 12px;
inset-inline-end: 12px;
color: $dark-text-color;
}
}
.layout-single-column .explore__search-header {
display: none;
@media screen and (max-width: $no-gap-breakpoint - 1px) {
display: flex;
}
}
.explore__search-results {
flex: 1 1 auto;
display: flex;