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-symbols/svg-600/outlined/cancel-fill.svg?react'; import CloseIcon from '@material-symbols/svg-600/outlined/close.svg?react'; import SearchIcon from '@material-symbols/svg-600/outlined/search.svg?react'; import { Icon } from 'mastodon/components/icon'; 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 contextTypes = { identity: PropTypes.object.isRequired, }; static propTypes = { 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 = [ { label: <>has: , action: e => { e.preventDefault(); this._insertText('has:'); } }, { label: <>is: , action: e => { e.preventDefault(); this._insertText('is:'); } }, { label: <>language: , action: e => { e.preventDefault(); this._insertText('language:'); } }, { label: <>from: , action: e => { e.preventDefault(); this._insertText('from:'); } }, { label: <>before: , action: e => { e.preventDefault(); this._insertText('before:'); } }, { label: <>during: , action: e => { e.preventDefault(); this._insertText('during:'); } }, { label: <>after: , action: e => { e.preventDefault(); this._insertText('after:'); } }, { label: <>in: , 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 => ({ label: labelForRecentSearch(search), action: () => this.handleRecentSearchClick(search), forget: e => { e.stopPropagation(); this.handleForgetRecentSearchClick(search); }, })); } _calculateOptions (value) { const { signedIn } = this.context.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: , action: this.handleURLClick }); } const couldBeHashtag = (trimmedValue.startsWith('#') && trimmedValue.length > 1) || trimmedValue.match(HASHTAG_REGEX); if (couldBeHashtag) { options.push({ key: 'go-to-hashtag', label: #{trimmedValue.replace(/^#/, '')} }} />, action: this.handleHashtagClick }); } const couldBeUsername = trimmedValue.match(/^@?[a-z0-9_-]+(@[^\s]+)?$/i); if (couldBeUsername) { options.push({ key: 'go-to-account', label: @{trimmedValue.replace(/^@/, '')} }} />, action: this.handleAccountClick }); } const couldBeStatusSearch = searchEnabled; if (couldBeStatusSearch && signedIn) { options.push({ key: 'status-search', label: {trimmedValue} }} />, action: this.handleStatusSearch }); } const couldBeUserSearch = true; if (couldBeUserSearch) { options.push({ key: 'account-search', label: {trimmedValue} }} />, action: this.handleAccountSearch }); } } this.setState({ options }); } render () { const { intl, value, submitted, recent } = this.props; const { expanded, options, selectedOption } = this.state; const { signedIn } = this.context.identity; const hasValue = value.length > 0 || submitted; return (
{options.length === 0 && ( <>

{recent.size > 0 ? this._getOptions().map(({ label, action, forget }, i) => ( )) : (
)}
)} {options.length > 0 && ( <>

{options.map(({ key, label, action }, i) => ( ))}
)}

{searchEnabled && signedIn ? (
{this.defaultOptions.map(({ key, label, action }, i) => ( ))}
) : (
{searchEnabled ? ( ) : ( )}
)}
); } } export default withRouter(injectIntl(Search));