You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
398 lines
13 KiB
398 lines
13 KiB
import PropTypes from 'prop-types'; |
|
import { PureComponent } from 'react'; |
|
|
|
import { defineMessages, injectIntl, FormattedMessage, FormattedList } from 'react-intl'; |
|
|
|
import classNames from 'classnames'; |
|
|
|
import ImmutablePropTypes from 'react-immutable-proptypes'; |
|
|
|
import { Icon } from 'mastodon/components/icon'; |
|
import { domain, searchEnabled } from 'mastodon/initial_state'; |
|
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 => { |
|
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 = { |
|
router: PropTypes.object.isRequired, |
|
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, |
|
}; |
|
|
|
state = { |
|
expanded: false, |
|
selectedOption: -1, |
|
options: [], |
|
}; |
|
|
|
defaultOptions = [ |
|
{ label: <><mark>has:</mark> <FormattedList type='disjunction' value={['media', 'poll', 'embed']} /></>, action: e => { e.preventDefault(); this._insertText('has:') } }, |
|
{ label: <><mark>is:</mark> <FormattedList type='disjunction' value={['reply', 'sensitive']} /></>, action: e => { e.preventDefault(); this._insertText('is:') } }, |
|
{ label: <><mark>language:</mark> <FormattedMessage id='search_popout.language_code' defaultMessage='ISO language code' /></>, action: e => { e.preventDefault(); this._insertText('language:') } }, |
|
{ label: <><mark>from:</mark> <FormattedMessage id='search_popout.user' defaultMessage='user' /></>, action: e => { e.preventDefault(); this._insertText('from:') } }, |
|
{ label: <><mark>before:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('before:') } }, |
|
{ label: <><mark>during:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('during:') } }, |
|
{ label: <><mark>after:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('after:') } }, |
|
{ label: <><mark>in:</mark> <FormattedList type='disjunction' value={['all', 'library']} /></>, 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 { router } = this.context; |
|
const { value, onClickSearchResult } = this.props; |
|
|
|
const query = value.trim().replace(/^#/, ''); |
|
|
|
router.history.push(`/tags/${query}`); |
|
onClickSearchResult(query, 'hashtag'); |
|
this._unfocus(); |
|
}; |
|
|
|
handleAccountClick = () => { |
|
const { router } = this.context; |
|
const { value, onClickSearchResult } = this.props; |
|
|
|
const query = value.trim().replace(/^@/, ''); |
|
|
|
router.history.push(`/@${query}`); |
|
onClickSearchResult(query, 'account'); |
|
this._unfocus(); |
|
}; |
|
|
|
handleURLClick = () => { |
|
const { router } = this.context; |
|
const { value, onOpenURL } = this.props; |
|
|
|
onOpenURL(value, router.history); |
|
this._unfocus(); |
|
}; |
|
|
|
handleStatusSearch = () => { |
|
this._submit('statuses'); |
|
}; |
|
|
|
handleAccountSearch = () => { |
|
this._submit('accounts'); |
|
}; |
|
|
|
handleRecentSearchClick = search => { |
|
const { onChange } = this.props; |
|
const { router } = this.context; |
|
|
|
if (search.get('type') === 'account') { |
|
router.history.push(`/@${search.get('q')}`); |
|
} else if (search.get('type') === 'hashtag') { |
|
router.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 } = this.props; |
|
const { router } = this.context; |
|
|
|
onSubmit(type); |
|
|
|
if (value) { |
|
onClickSearchResult(value, type); |
|
} |
|
|
|
if (openInRoute) { |
|
router.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 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) { |
|
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.context.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' className={hasValue ? '' : 'active'} /> |
|
<Icon id='times-circle' 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, action, forget }, i) => ( |
|
<button key={label} 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' /></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 ? ( |
|
<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'> |
|
<FormattedMessage id='search_popout.full_text_search_disabled_message' defaultMessage='Not available on {domain}.' values={{ domain }} /> |
|
</div> |
|
)} |
|
</div> |
|
</div> |
|
); |
|
} |
|
|
|
} |
|
|
|
export default injectIntl(Search);
|
|
|