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