diff options
Diffstat (limited to 'app/javascript/mastodon/features/compose/components/search.jsx')
-rw-r--r-- | app/javascript/mastodon/features/compose/components/search.jsx | 289 |
1 files changed, 238 insertions, 51 deletions
diff --git a/app/javascript/mastodon/features/compose/components/search.jsx b/app/javascript/mastodon/features/compose/components/search.jsx index 5d2d8d194..717ecea37 100644 --- a/app/javascript/mastodon/features/compose/components/search.jsx +++ b/app/javascript/mastodon/features/compose/components/search.jsx @@ -1,37 +1,17 @@ import React from 'react'; import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import Overlay from 'react-overlays/Overlay'; -import { searchEnabled } from '../../../initial_state'; +import { searchEnabled } from 'mastodon/initial_state'; import Icon from 'mastodon/components/icon'; +import classNames from 'classnames'; +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' }, }); -class SearchPopout extends React.PureComponent { - - render () { - const extraInformation = searchEnabled ? <FormattedMessage id='search_popout.tips.full_text' defaultMessage='Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.' /> : <FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' />; - return ( - <div className='search-popout'> - <h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4> - - <ul> - <li><em>#example</em> <FormattedMessage id='search_popout.tips.hashtag' defaultMessage='hashtag' /></li> - <li><em>@username@domain</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li> - <li><em>URL</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li> - <li><em>URL</em> <FormattedMessage id='search_popout.tips.status' defaultMessage='status' /></li> - </ul> - - {extraInformation} - </div> - ); - } - -} - class Search extends React.PureComponent { static contextTypes = { @@ -41,9 +21,13 @@ class Search extends React.PureComponent { 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, @@ -53,44 +37,94 @@ class Search extends React.PureComponent { state = { expanded: false, + selectedOption: -1, + options: [], }; setRef = c => { this.searchForm = c; }; - handleChange = (e) => { - this.props.onChange(e.target.value); + handleChange = ({ target }) => { + const { onChange } = this.props; + + onChange(target.value); + + this._calculateOptions(target.value); }; - handleClear = (e) => { + handleClear = e => { + const { value, submitted, onClear } = this.props; + e.preventDefault(); - if (this.props.value.length > 0 || this.props.submitted) { - this.props.onClear(); + if (value.length > 0 || submitted) { + onClear(); + this.setState({ options: [], selectedOption: -1 }); } }; - handleKeyUp = (e) => { - if (e.key === 'Enter') { + handleKeyDown = (e) => { + const { selectedOption } = this.state; + const options = 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(); - this.props.onSubmit(); + if (selectedOption === -1) { + this._submit(); + } else if (options.length > 0) { + options[selectedOption].action(); + } + + this._unfocus(); - if (this.props.openInRoute) { - this.context.router.history.push('/search'); + break; + case 'Delete': + if (selectedOption > -1 && options.length > 0) { + const search = options[selectedOption]; + + if (typeof search.forget === 'function') { + e.preventDefault(); + search.forget(e); + } } - } else if (e.key === 'Escape') { - document.querySelector('.ui').parentElement.focus(); + + break; } }; handleFocus = () => { - this.setState({ expanded: true }); - this.props.onShow(); + const { onShow, singleColumn } = this.props; + + this.setState({ expanded: true, selectedOption: -1 }); + onShow(); - if (this.searchForm && !this.props.singleColumn) { + if (this.searchForm && !singleColumn) { const { left, right } = this.searchForm.getBoundingClientRect(); + if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) { this.searchForm.scrollIntoView(); } @@ -98,21 +132,148 @@ class Search extends React.PureComponent { }; handleBlur = () => { - this.setState({ expanded: false }); + this.setState({ expanded: false, selectedOption: -1 }); }; findTarget = () => { return this.searchForm; }; + handleHashtagClick = () => { + const { router } = this.context; + const { value, onClickSearchResult } = this.props; + + const query = value.trim().replace(/^#/, ''); + + router.history.push(`/tags/${query}`); + onClickSearchResult(query, 'hashtag'); + }; + + handleAccountClick = () => { + const { router } = this.context; + const { value, onClickSearchResult } = this.props; + + const query = value.trim().replace(/^@/, ''); + + router.history.push(`/@${query}`); + onClickSearchResult(query, 'account'); + }; + + handleURLClick = () => { + const { router } = this.context; + const { onOpenURL } = this.props; + + onOpenURL(router.history); + }; + + handleStatusSearch = () => { + this._submit('statuses'); + }; + + handleAccountSearch = () => { + this._submit('accounts'); + }; + + handleRecentSearchClick = search => { + 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')}`); + } + }; + + handleForgetRecentSearchClick = search => { + const { onForgetSearchResult } = this.props; + + onForgetSearchResult(search.get('q')); + }; + + _unfocus () { + document.querySelector('.ui').parentElement.focus(); + } + + _submit (type) { + const { onSubmit, openInRoute } = this.props; + const { router } = this.context; + + onSubmit(type); + + if (openInRoute) { + router.history.push('/search'); + } + } + + _getOptions () { + const { options } = this.state; + + if (options.length > 0) { + return options; + } + + const { recent } = this.props; + + return recent.toArray().map(search => ({ + label: search.get('type') === 'account' ? `@${search.get('q')}` : `#${search.get('q')}`, + + 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 } = this.props; - const { expanded } = this.state; + 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='search'> + <div className={classNames('search', { active: expanded })}> <input ref={this.setRef} className='search__input' @@ -121,7 +282,7 @@ class Search extends React.PureComponent { aria-label={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)} value={value} onChange={this.handleChange} - onKeyUp={this.handleKeyUp} + onKeyDown={this.handleKeyDown} onFocus={this.handleFocus} onBlur={this.handleBlur} /> @@ -130,15 +291,41 @@ class Search extends React.PureComponent { <Icon id='search' className={hasValue ? '' : 'active'} /> <Icon id='times-circle' className={hasValue ? 'active' : ''} aria-label={intl.formatMessage(messages.placeholder)} /> </div> - <Overlay show={expanded && !hasValue} placement='bottom' target={this.findTarget} popperConfig={{ strategy: 'fixed' }}> - {({ props, placement }) => ( - <div {...props} style={{ ...props.style, width: 285, zIndex: 2 }}> - <div className={`dropdown-animation ${placement}`}> - <SearchPopout /> + + <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> - </div> + </> )} - </Overlay> + + {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> + </> + )} + </div> </div> ); } |