402 lines
14 KiB
JavaScript
402 lines
14 KiB
JavaScript
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)));
|