Change search pop-out in web UI (#24305)
This commit is contained in:
		
							parent
							
								
									4e63f1e64b
								
							
						
					
					
						commit
						d22e712648
					
				
					 10 changed files with 447 additions and 91 deletions
				
			
		| 
						 | 
				
			
			@ -14,6 +14,9 @@ export const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST';
 | 
			
		|||
export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS';
 | 
			
		||||
export const SEARCH_EXPAND_FAIL    = 'SEARCH_EXPAND_FAIL';
 | 
			
		||||
 | 
			
		||||
export const SEARCH_RESULT_CLICK  = 'SEARCH_RESULT_CLICK';
 | 
			
		||||
export const SEARCH_RESULT_FORGET = 'SEARCH_RESULT_FORGET';
 | 
			
		||||
 | 
			
		||||
export function changeSearch(value) {
 | 
			
		||||
  return {
 | 
			
		||||
    type: SEARCH_CHANGE,
 | 
			
		||||
| 
						 | 
				
			
			@ -27,7 +30,7 @@ export function clearSearch() {
 | 
			
		|||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function submitSearch() {
 | 
			
		||||
export function submitSearch(type) {
 | 
			
		||||
  return (dispatch, getState) => {
 | 
			
		||||
    const value    = getState().getIn(['search', 'value']);
 | 
			
		||||
    const signedIn = !!getState().getIn(['meta', 'me']);
 | 
			
		||||
| 
						 | 
				
			
			@ -44,6 +47,7 @@ export function submitSearch() {
 | 
			
		|||
        q: value,
 | 
			
		||||
        resolve: signedIn,
 | 
			
		||||
        limit: 5,
 | 
			
		||||
        type,
 | 
			
		||||
      },
 | 
			
		||||
    }).then(response => {
 | 
			
		||||
      if (response.data.accounts) {
 | 
			
		||||
| 
						 | 
				
			
			@ -130,3 +134,42 @@ export const expandSearchFail = error => ({
 | 
			
		|||
export const showSearch = () => ({
 | 
			
		||||
  type: SEARCH_SHOW,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const openURL = routerHistory => (dispatch, getState) => {
 | 
			
		||||
  const value = getState().getIn(['search', 'value']);
 | 
			
		||||
  const signedIn = !!getState().getIn(['meta', 'me']);
 | 
			
		||||
 | 
			
		||||
  if (!signedIn) {
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  dispatch(fetchSearchRequest());
 | 
			
		||||
 | 
			
		||||
  api(getState).get('/api/v2/search', { params: { q: value, resolve: true } }).then(response => {
 | 
			
		||||
    if (response.data.accounts?.length > 0) {
 | 
			
		||||
      dispatch(importFetchedAccounts(response.data.accounts));
 | 
			
		||||
      routerHistory.push(`/@${response.data.accounts[0].acct}`);
 | 
			
		||||
    } else if (response.data.statuses?.length > 0) {
 | 
			
		||||
      dispatch(importFetchedStatuses(response.data.statuses));
 | 
			
		||||
      routerHistory.push(`/@${response.data.statuses[0].account.acct}/${response.data.statuses[0].id}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    dispatch(fetchSearchSuccess(response.data, value));
 | 
			
		||||
  }).catch(err => {
 | 
			
		||||
    dispatch(fetchSearchFail(err));
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const clickSearchResult = (q, type) => ({
 | 
			
		||||
  type: SEARCH_RESULT_CLICK,
 | 
			
		||||
 | 
			
		||||
  result: {
 | 
			
		||||
    type,
 | 
			
		||||
    q,
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const forgetSearchResult = q => ({
 | 
			
		||||
  type: SEARCH_RESULT_FORGET,
 | 
			
		||||
  q,
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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();
 | 
			
		||||
 | 
			
		||||
      this.props.onSubmit();
 | 
			
		||||
 | 
			
		||||
      if (this.props.openInRoute) {
 | 
			
		||||
        this.context.router.history.push('/search');
 | 
			
		||||
      if (options.length > 0) {
 | 
			
		||||
        this.setState({ selectedOption: Math.min(selectedOption + 1, options.length - 1) });
 | 
			
		||||
      }
 | 
			
		||||
    } else if (e.key === 'Escape') {
 | 
			
		||||
      document.querySelector('.ui').parentElement.focus();
 | 
			
		||||
 | 
			
		||||
      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 = () => {
 | 
			
		||||
    this.setState({ expanded: true });
 | 
			
		||||
    this.props.onShow();
 | 
			
		||||
    const { onShow, singleColumn } = this.props;
 | 
			
		||||
 | 
			
		||||
    if (this.searchForm && !this.props.singleColumn) {
 | 
			
		||||
    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();
 | 
			
		||||
      }
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -77,7 +77,7 @@ class SearchResults extends ImmutablePureComponent {
 | 
			
		|||
      count   += results.get('accounts').size;
 | 
			
		||||
      accounts = (
 | 
			
		||||
        <div className='search-results__section'>
 | 
			
		||||
          <h5><Icon id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
 | 
			
		||||
          <h5><Icon id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></h5>
 | 
			
		||||
 | 
			
		||||
          {results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,12 +4,16 @@ import {
 | 
			
		|||
  clearSearch,
 | 
			
		||||
  submitSearch,
 | 
			
		||||
  showSearch,
 | 
			
		||||
} from '../../../actions/search';
 | 
			
		||||
  openURL,
 | 
			
		||||
  clickSearchResult,
 | 
			
		||||
  forgetSearchResult,
 | 
			
		||||
} from 'mastodon/actions/search';
 | 
			
		||||
import Search from '../components/search';
 | 
			
		||||
 | 
			
		||||
const mapStateToProps = state => ({
 | 
			
		||||
  value: state.getIn(['search', 'value']),
 | 
			
		||||
  submitted: state.getIn(['search', 'submitted']),
 | 
			
		||||
  recent: state.getIn(['search', 'recent']),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const mapDispatchToProps = dispatch => ({
 | 
			
		||||
| 
						 | 
				
			
			@ -22,14 +26,26 @@ const mapDispatchToProps = dispatch => ({
 | 
			
		|||
    dispatch(clearSearch());
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  onSubmit () {
 | 
			
		||||
    dispatch(submitSearch());
 | 
			
		||||
  onSubmit (type) {
 | 
			
		||||
    dispatch(submitSearch(type));
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  onShow () {
 | 
			
		||||
    dispatch(showSearch());
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  onOpenURL (routerHistory) {
 | 
			
		||||
    dispatch(openURL(routerHistory));
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  onClickSearchResult (q, type) {
 | 
			
		||||
    dispatch(clickSearchResult(q, type));
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  onForgetSearchResult (q) {
 | 
			
		||||
    dispatch(forgetSearchResult(q));
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export default connect(mapStateToProps, mapDispatchToProps)(Search);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,36 +3,12 @@ import { connect } from 'react-redux';
 | 
			
		|||
import Warning from '../components/warning';
 | 
			
		||||
import PropTypes from 'prop-types';
 | 
			
		||||
import { FormattedMessage } from 'react-intl';
 | 
			
		||||
import { me } from '../../../initial_state';
 | 
			
		||||
 | 
			
		||||
const buildHashtagRE = () => {
 | 
			
		||||
  try {
 | 
			
		||||
    const HASHTAG_SEPARATORS = '_\\u00b7\\u200c';
 | 
			
		||||
    const ALPHA = '\\p{L}\\p{M}';
 | 
			
		||||
    const WORD = '\\p{L}\\p{M}\\p{N}\\p{Pc}';
 | 
			
		||||
    return new RegExp(
 | 
			
		||||
      '(?:^|[^\\/\\)\\w])#((' +
 | 
			
		||||
      '[' + WORD + '_]' +
 | 
			
		||||
      '[' + WORD + HASHTAG_SEPARATORS + ']*' +
 | 
			
		||||
      '[' + ALPHA + HASHTAG_SEPARATORS + ']' +
 | 
			
		||||
      '[' + WORD + HASHTAG_SEPARATORS +']*' +
 | 
			
		||||
      '[' + WORD + '_]' +
 | 
			
		||||
      ')|(' +
 | 
			
		||||
      '[' + WORD + '_]*' +
 | 
			
		||||
      '[' + ALPHA + ']' +
 | 
			
		||||
      '[' + WORD + '_]*' +
 | 
			
		||||
      '))', 'iu',
 | 
			
		||||
    );
 | 
			
		||||
  } catch {
 | 
			
		||||
    return /(?:^|[^/)\w])#(\w*[a-zA-Z·]\w*)/i;
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const APPROX_HASHTAG_RE = buildHashtagRE();
 | 
			
		||||
import { me } from 'mastodon/initial_state';
 | 
			
		||||
import { HASHTAG_PATTERN_REGEX } from 'mastodon/utils/hashtags';
 | 
			
		||||
 | 
			
		||||
const mapStateToProps = state => ({
 | 
			
		||||
  needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
 | 
			
		||||
  hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && APPROX_HASHTAG_RE.test(state.getIn(['compose', 'text'])),
 | 
			
		||||
  hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && HASHTAG_PATTERN_REGEX.test(state.getIn(['compose', 'text'])),
 | 
			
		||||
  directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct',
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -105,7 +105,7 @@ class Results extends React.PureComponent {
 | 
			
		|||
      <React.Fragment>
 | 
			
		||||
        <div className='account__section-headline'>
 | 
			
		||||
          <button onClick={this.handleSelectAll} className={type === 'all' && 'active'}><FormattedMessage id='search_results.all' defaultMessage='All' /></button>
 | 
			
		||||
          <button onClick={this.handleSelectAccounts} className={type === 'accounts' && 'active'}><FormattedMessage id='search_results.accounts' defaultMessage='People' /></button>
 | 
			
		||||
          <button onClick={this.handleSelectAccounts} className={type === 'accounts' && 'active'}><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></button>
 | 
			
		||||
          <button onClick={this.handleSelectHashtags} className={type === 'hashtags' && 'active'}><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></button>
 | 
			
		||||
          <button onClick={this.handleSelectStatuses} className={type === 'statuses' && 'active'}><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></button>
 | 
			
		||||
        </div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -530,7 +530,7 @@
 | 
			
		|||
  "search_popout.tips.status": "post",
 | 
			
		||||
  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
 | 
			
		||||
  "search_popout.tips.user": "user",
 | 
			
		||||
  "search_results.accounts": "People",
 | 
			
		||||
  "search_results.accounts": "Profiles",
 | 
			
		||||
  "search_results.all": "All",
 | 
			
		||||
  "search_results.hashtags": "Hashtags",
 | 
			
		||||
  "search_results.nothing_found": "Could not find anything for these search terms",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,13 +6,15 @@ import {
 | 
			
		|||
  SEARCH_FETCH_SUCCESS,
 | 
			
		||||
  SEARCH_SHOW,
 | 
			
		||||
  SEARCH_EXPAND_SUCCESS,
 | 
			
		||||
  SEARCH_RESULT_CLICK,
 | 
			
		||||
  SEARCH_RESULT_FORGET,
 | 
			
		||||
} from '../actions/search';
 | 
			
		||||
import {
 | 
			
		||||
  COMPOSE_MENTION,
 | 
			
		||||
  COMPOSE_REPLY,
 | 
			
		||||
  COMPOSE_DIRECT,
 | 
			
		||||
} from '../actions/compose';
 | 
			
		||||
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
 | 
			
		||||
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
 | 
			
		||||
 | 
			
		||||
const initialState = ImmutableMap({
 | 
			
		||||
  value: '',
 | 
			
		||||
| 
						 | 
				
			
			@ -21,6 +23,7 @@ const initialState = ImmutableMap({
 | 
			
		|||
  results: ImmutableMap(),
 | 
			
		||||
  isLoading: false,
 | 
			
		||||
  searchTerm: '',
 | 
			
		||||
  recent: ImmutableOrderedSet(),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export default function search(state = initialState, action) {
 | 
			
		||||
| 
						 | 
				
			
			@ -61,6 +64,10 @@ export default function search(state = initialState, action) {
 | 
			
		|||
  case SEARCH_EXPAND_SUCCESS:
 | 
			
		||||
    const results = action.searchType === 'hashtags' ? fromJS(action.results.hashtags) : action.results[action.searchType].map(item => item.id);
 | 
			
		||||
    return state.updateIn(['results', action.searchType], list => list.concat(results));
 | 
			
		||||
  case SEARCH_RESULT_CLICK:
 | 
			
		||||
    return state.update('recent', set => set.add(fromJS(action.result)));
 | 
			
		||||
  case SEARCH_RESULT_FORGET:
 | 
			
		||||
    return state.update('recent', set => set.filterNot(result => result.get('q') === action.q));
 | 
			
		||||
  default:
 | 
			
		||||
    return state;
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										47
									
								
								app/javascript/mastodon/utils/hashtags.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								app/javascript/mastodon/utils/hashtags.js
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,47 @@
 | 
			
		|||
const HASHTAG_SEPARATORS = '_\\u00b7\\u200c';
 | 
			
		||||
const ALPHA = '\\p{L}\\p{M}';
 | 
			
		||||
const WORD = '\\p{L}\\p{M}\\p{N}\\p{Pc}';
 | 
			
		||||
 | 
			
		||||
const buildHashtagPatternRegex = () => {
 | 
			
		||||
  try {
 | 
			
		||||
    return new RegExp(
 | 
			
		||||
      '(?:^|[^\\/\\)\\w])#((' +
 | 
			
		||||
      '[' + WORD + '_]' +
 | 
			
		||||
      '[' + WORD + HASHTAG_SEPARATORS + ']*' +
 | 
			
		||||
      '[' + ALPHA + HASHTAG_SEPARATORS + ']' +
 | 
			
		||||
      '[' + WORD + HASHTAG_SEPARATORS +']*' +
 | 
			
		||||
      '[' + WORD + '_]' +
 | 
			
		||||
      ')|(' +
 | 
			
		||||
      '[' + WORD + '_]*' +
 | 
			
		||||
      '[' + ALPHA + ']' +
 | 
			
		||||
      '[' + WORD + '_]*' +
 | 
			
		||||
      '))', 'iu',
 | 
			
		||||
    );
 | 
			
		||||
  } catch {
 | 
			
		||||
    return /(?:^|[^/)\w])#(\w*[a-zA-Z·]\w*)/i;
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const buildHashtagRegex = () => {
 | 
			
		||||
  try {
 | 
			
		||||
    return new RegExp(
 | 
			
		||||
      '^((' +
 | 
			
		||||
      '[' + WORD + '_]' +
 | 
			
		||||
      '[' + WORD + HASHTAG_SEPARATORS + ']*' +
 | 
			
		||||
      '[' + ALPHA + HASHTAG_SEPARATORS + ']' +
 | 
			
		||||
      '[' + WORD + HASHTAG_SEPARATORS +']*' +
 | 
			
		||||
      '[' + WORD + '_]' +
 | 
			
		||||
      ')|(' +
 | 
			
		||||
      '[' + WORD + '_]*' +
 | 
			
		||||
      '[' + ALPHA + ']' +
 | 
			
		||||
      '[' + WORD + '_]*' +
 | 
			
		||||
      '))$', 'iu',
 | 
			
		||||
    );
 | 
			
		||||
  } catch {
 | 
			
		||||
    return /^(\w*[a-zA-Z·]\w*)$/i;
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const HASHTAG_PATTERN_REGEX = buildHashtagPatternRegex();
 | 
			
		||||
 | 
			
		||||
export const HASHTAG_REGEX = buildHashtagRegex();
 | 
			
		||||
| 
						 | 
				
			
			@ -4816,6 +4816,86 @@ a.status-card.compact:hover {
 | 
			
		|||
.search {
 | 
			
		||||
  margin-bottom: 10px;
 | 
			
		||||
  position: relative;
 | 
			
		||||
 | 
			
		||||
  &__popout {
 | 
			
		||||
    box-sizing: border-box;
 | 
			
		||||
    display: none;
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    inset-inline-start: 0;
 | 
			
		||||
    margin-top: -2px;
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    background: $ui-base-color;
 | 
			
		||||
    border-radius: 0 0 4px 4px;
 | 
			
		||||
    box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);
 | 
			
		||||
    z-index: 2;
 | 
			
		||||
    font-size: 13px;
 | 
			
		||||
    padding: 15px 5px;
 | 
			
		||||
 | 
			
		||||
    h4 {
 | 
			
		||||
      text-transform: uppercase;
 | 
			
		||||
      color: $dark-text-color;
 | 
			
		||||
      font-weight: 500;
 | 
			
		||||
      padding: 0 10px;
 | 
			
		||||
      margin-bottom: 10px;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &__menu {
 | 
			
		||||
      &__message {
 | 
			
		||||
        color: $dark-text-color;
 | 
			
		||||
        padding: 0 10px;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      &__item {
 | 
			
		||||
        display: block;
 | 
			
		||||
        box-sizing: border-box;
 | 
			
		||||
        width: 100%;
 | 
			
		||||
        border: 0;
 | 
			
		||||
        font: inherit;
 | 
			
		||||
        background: transparent;
 | 
			
		||||
        color: $darker-text-color;
 | 
			
		||||
        padding: 10px;
 | 
			
		||||
        cursor: pointer;
 | 
			
		||||
        border-radius: 4px;
 | 
			
		||||
        text-align: start;
 | 
			
		||||
        text-overflow: ellipsis;
 | 
			
		||||
        overflow: hidden;
 | 
			
		||||
        white-space: nowrap;
 | 
			
		||||
 | 
			
		||||
        &--flex {
 | 
			
		||||
          display: flex;
 | 
			
		||||
          justify-content: space-between;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .icon-button {
 | 
			
		||||
          transition: none;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        &:hover,
 | 
			
		||||
        &:focus,
 | 
			
		||||
        &:active,
 | 
			
		||||
        &.selected {
 | 
			
		||||
          background: $ui-highlight-color;
 | 
			
		||||
          color: $primary-text-color;
 | 
			
		||||
 | 
			
		||||
          .icon-button {
 | 
			
		||||
            color: $primary-text-color;
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        mark {
 | 
			
		||||
          background: transparent;
 | 
			
		||||
          font-weight: 700;
 | 
			
		||||
          color: $primary-text-color;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &.active {
 | 
			
		||||
    .search__popout {
 | 
			
		||||
      display: block;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.search__input {
 | 
			
		||||
| 
						 | 
				
			
			@ -6695,10 +6775,6 @@ a.status-card.compact:hover {
 | 
			
		|||
  border-radius: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.search-popout {
 | 
			
		||||
  @include search-popout;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
noscript {
 | 
			
		||||
  text-align: center;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -7985,6 +8061,10 @@ noscript {
 | 
			
		|||
    padding: 10px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .search__popout {
 | 
			
		||||
    border: 1px solid lighten($ui-base-color, 8%);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .search .fa {
 | 
			
		||||
    top: 10px;
 | 
			
		||||
    inset-inline-end: 10px;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in a new issue