about summary refs log tree commit diff
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2023-04-01 09:59:10 +0200
committerGitHub <noreply@github.com>2023-04-01 09:59:10 +0200
commit2b113764117c9ab98875141bcf1758ba8be58173 (patch)
tree8928280ea2321d85518b857e000d1016378e6246
parent46483ae849bc06ee74f4745f4564b213e742c51c (diff)
Change search pop-out in web UI (#24305)
-rw-r--r--app/javascript/mastodon/actions/search.js45
-rw-r--r--app/javascript/mastodon/features/compose/components/search.jsx289
-rw-r--r--app/javascript/mastodon/features/compose/components/search_results.jsx2
-rw-r--r--app/javascript/mastodon/features/compose/containers/search_container.js22
-rw-r--r--app/javascript/mastodon/features/compose/containers/warning_container.jsx30
-rw-r--r--app/javascript/mastodon/features/explore/results.jsx2
-rw-r--r--app/javascript/mastodon/locales/en.json2
-rw-r--r--app/javascript/mastodon/reducers/search.js9
-rw-r--r--app/javascript/mastodon/utils/hashtags.js47
-rw-r--r--app/javascript/styles/mastodon/components.scss88
10 files changed, 446 insertions, 90 deletions
diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js
index 666c6c223..56608f28b 100644
--- a/app/javascript/mastodon/actions/search.js
+++ b/app/javascript/mastodon/actions/search.js
@@ -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,
+});
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>
     );
   }
diff --git a/app/javascript/mastodon/features/compose/components/search_results.jsx b/app/javascript/mastodon/features/compose/components/search_results.jsx
index 78da3ca27..1dccd950c 100644
--- a/app/javascript/mastodon/features/compose/components/search_results.jsx
+++ b/app/javascript/mastodon/features/compose/components/search_results.jsx
@@ -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} />)}
 
diff --git a/app/javascript/mastodon/features/compose/containers/search_container.js b/app/javascript/mastodon/features/compose/containers/search_container.js
index 392bd0f56..3ee55fae5 100644
--- a/app/javascript/mastodon/features/compose/containers/search_container.js
+++ b/app/javascript/mastodon/features/compose/containers/search_container.js
@@ -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);
diff --git a/app/javascript/mastodon/features/compose/containers/warning_container.jsx b/app/javascript/mastodon/features/compose/containers/warning_container.jsx
index 3c6ed483d..e99f5dacd 100644
--- a/app/javascript/mastodon/features/compose/containers/warning_container.jsx
+++ b/app/javascript/mastodon/features/compose/containers/warning_container.jsx
@@ -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',
 });
 
diff --git a/app/javascript/mastodon/features/explore/results.jsx b/app/javascript/mastodon/features/explore/results.jsx
index 27132f132..9725cf35c 100644
--- a/app/javascript/mastodon/features/explore/results.jsx
+++ b/app/javascript/mastodon/features/explore/results.jsx
@@ -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>
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 3bdba3b1d..3d59fa01d 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -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",
diff --git a/app/javascript/mastodon/reducers/search.js b/app/javascript/mastodon/reducers/search.js
index d3e71da9d..e545f430c 100644
--- a/app/javascript/mastodon/reducers/search.js
+++ b/app/javascript/mastodon/reducers/search.js
@@ -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;
   }
diff --git a/app/javascript/mastodon/utils/hashtags.js b/app/javascript/mastodon/utils/hashtags.js
new file mode 100644
index 000000000..358ce37f5
--- /dev/null
+++ b/app/javascript/mastodon/utils/hashtags.js
@@ -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();
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 32dcd59b6..6681aa75c 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -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;