glitch-soc/mastodon

View on GitHub
app/javascript/mastodon/features/compose/components/search.jsx

Summary

Maintainability
F
3 wks
Test Coverage
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)));