diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js index 666c6c223b..56608f28ba 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 5d2d8d194f..717ecea370 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 ? : ; - return ( -
-

- -
    -
  • #example
  • -
  • @username@domain
  • -
  • URL
  • -
  • URL
  • -
- - {extraInformation} -
- ); - } - -} - 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: , action: this.handleURLClick }); + } + + const couldBeHashtag = (trimmedValue.startsWith('#') && trimmedValue.length > 1) || trimmedValue.match(HASHTAG_REGEX); + + if (couldBeHashtag) { + options.push({ key: 'go-to-hashtag', label: #{trimmedValue.replace(/^#/, '')} }} />, action: this.handleHashtagClick }); + } + + const couldBeUsername = trimmedValue.match(/^@?[a-z0-9_-]+(@[^\s]+)?$/i); + + if (couldBeUsername) { + options.push({ key: 'go-to-account', label: @{trimmedValue.replace(/^@/, '')} }} />, action: this.handleAccountClick }); + } + + const couldBeStatusSearch = searchEnabled; + + if (couldBeStatusSearch) { + options.push({ key: 'status-search', label: {trimmedValue} }} />, action: this.handleStatusSearch }); + } + + const couldBeUserSearch = true; + + if (couldBeUserSearch) { + options.push({ key: 'account-search', label: {trimmedValue} }} />, 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 ( -
+
@@ -130,15 +291,41 @@ class Search extends React.PureComponent {
- - {({ props, placement }) => ( -
-
- + +
+ {options.length === 0 && ( + <> +

+ +
+ {recent.size > 0 ? this._getOptions().map(({ label, action, forget }, i) => ( + + + )) : ( +
+ +
+ )}
-
+ )} - + + {options.length > 0 && ( + <> +

+ +
+ {options.map(({ key, label, action }, i) => ( + + ))} +
+ + )} +
); } diff --git a/app/javascript/mastodon/features/compose/components/search_results.jsx b/app/javascript/mastodon/features/compose/components/search_results.jsx index 78da3ca274..1dccd950cb 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 = (
-
+
{results.get('accounts').map(accountId => )} diff --git a/app/javascript/mastodon/features/compose/containers/search_container.js b/app/javascript/mastodon/features/compose/containers/search_container.js index 392bd0f566..3ee55fae59 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 3c6ed483d0..e99f5dacd9 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 27132f1328..9725cf35cd 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 {
- +
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 3bdba3b1d5..3d59fa01d7 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 d3e71da9d9..e545f430cc 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 0000000000..358ce37f54 --- /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 32dcd59b66..6681aa75c4 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;