339 lines
		
	
	
	
		
			9.9 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			339 lines
		
	
	
	
		
			9.9 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
import PropTypes from 'prop-types';
 | 
						|
import { PureComponent } from 'react';
 | 
						|
 | 
						|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 | 
						|
 | 
						|
import classNames from 'classnames';
 | 
						|
 | 
						|
import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
						|
 | 
						|
import { Icon }  from 'mastodon/components/icon';
 | 
						|
import { 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' },
 | 
						|
});
 | 
						|
 | 
						|
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: [],
 | 
						|
  };
 | 
						|
 | 
						|
  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 = 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();
 | 
						|
      }
 | 
						|
 | 
						|
      this._unfocus();
 | 
						|
 | 
						|
      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 });
 | 
						|
  };
 | 
						|
 | 
						|
  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 { value, onOpenURL } = this.props;
 | 
						|
 | 
						|
    onOpenURL(value, 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, 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>
 | 
						|
            </>
 | 
						|
          )}
 | 
						|
        </div>
 | 
						|
      </div>
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
}
 | 
						|
 | 
						|
export default injectIntl(Search);
 |