diff --git a/app/javascript/flavours/glitch/actions/search.js b/app/javascript/flavours/glitch/actions/search.js index d5154c6a8..33cf376a2 100644 --- a/app/javascript/flavours/glitch/actions/search.js +++ b/app/javascript/flavours/glitch/actions/search.js @@ -15,6 +15,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, @@ -28,7 +31,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']); @@ -45,6 +48,7 @@ export function submitSearch() { q: value, resolve: signedIn, limit: 10, + type, }, }).then(response => { if (response.data.accounts) { @@ -131,3 +135,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/flavours/glitch/features/compose/components/search.jsx b/app/javascript/flavours/glitch/features/compose/components/search.jsx index 4b655db1c..2f1b46e5d 100644 --- a/app/javascript/flavours/glitch/features/compose/components/search.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/search.jsx @@ -7,39 +7,21 @@ import { defineMessages, } from 'react-intl'; -import Overlay from 'react-overlays/Overlay'; +import classNames from 'classnames'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; + import { Icon } from 'flavours/glitch/components/icon'; import { searchEnabled } from 'flavours/glitch/initial_state'; import { focusRoot } from 'flavours/glitch/utils/dom_helpers'; +import { HASHTAG_REGEX } from 'flavours/glitch/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 PureComponent { - - render () { - const extraInformation = searchEnabled ? : ; - return ( -
-

- -
    -
  • #example
  • -
  • @username@domain
  • -
  • URL
  • -
  • URL
  • -
- - {extraInformation} -
- ); - } - -} - // The component. class Search extends PureComponent { @@ -50,9 +32,13 @@ class Search extends 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, @@ -62,59 +48,104 @@ class Search extends PureComponent { state = { expanded: false, + selectedOption: -1, + options: [], }; setRef = c => { this.searchForm = c; }; - handleChange = (e) => { + handleChange = ({ target }) => { const { onChange } = this.props; - if (onChange) { - onChange(e.target.value); - } + + onChange(target.value); + + this._calculateOptions(target.value); }; - handleClear = (e) => { + handleClear = e => { const { onClear, submitted, value, } = this.props; + e.preventDefault(); // Prevents focus change ?? - if (onClear && (submitted || value && value.length)) { + + if (value.length > 0 || submitted) { onClear(); + this.setState({ options: [], selectedOption: -1 }) } }; handleBlur = () => { - this.setState({ expanded: false }); + this.setState({ expanded: false, selectedOption: -1 }); }; 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(); } } }; - handleKeyUp = (e) => { - const { onSubmit } = this.props; - switch (e.key) { - case 'Enter': - onSubmit(); + handleKeyDown = (e) => { + const { selectedOption } = this.state; + const options = this._getOptions(); - if (this.props.openInRoute) { - this.context.router.history.push('/search'); + switch(e.key) { + case 'Escape': + e.preventDefault(); + + focusRoot(); + + break; + case 'ArrowDown': + e.preventDefault(); + + if (options.length > 0) { + this.setState({ selectedOption: Math.min(selectedOption + 1, options.length - 1) }); + } + + break; + case 'ArrowUp': + e.preventDefault(); + + if (options.length > 0) { + this.setState({ selectedOption: Math.max(selectedOption - 1, -1) }); + } + + break; + case 'Enter': + e.preventDefault(); + + if (selectedOption === -1) { + this._submit(); + } else if (options.length > 0) { + options[selectedOption].action(); + } + + this._unfocus(); + break; + case 'Delete': + if (selectedOption > -1 && options.length > 0) { + const search = options[selectedOption]; + + if (typeof search.forget === 'function') { + e.preventDefault(); + search.forget(e); + } } break; - case 'Escape': - focusRoot(); } }; @@ -122,14 +153,141 @@ class Search extends PureComponent { 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 ( -
+
@@ -147,15 +305,39 @@ class Search extends 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/flavours/glitch/features/compose/components/search_results.jsx b/app/javascript/flavours/glitch/features/compose/components/search_results.jsx index 9763335f1..606dfd6fd 100644 --- a/app/javascript/flavours/glitch/features/compose/components/search_results.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/search_results.jsx @@ -89,7 +89,7 @@ class SearchResults extends ImmutablePureComponent { count += results.get('accounts').size; accounts = (
-
+
{results.get('accounts').map(accountId => )} diff --git a/app/javascript/flavours/glitch/features/compose/containers/search_container.js b/app/javascript/flavours/glitch/features/compose/containers/search_container.js index 8280d962b..52dc65687 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/search_container.js +++ b/app/javascript/flavours/glitch/features/compose/containers/search_container.js @@ -5,6 +5,9 @@ import { clearSearch, submitSearch, showSearch, + openURL, + clickSearchResult, + forgetSearchResult, } from 'flavours/glitch/actions/search'; import Search from '../components/search'; @@ -12,6 +15,7 @@ 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 => ({ @@ -24,14 +28,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/flavours/glitch/features/compose/containers/warning_container.jsx b/app/javascript/flavours/glitch/features/compose/containers/warning_container.jsx index f7c3fc3e1..16916ba9c 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/warning_container.jsx +++ b/app/javascript/flavours/glitch/features/compose/containers/warning_container.jsx @@ -6,37 +6,14 @@ import { connect } from 'react-redux'; import { me } from 'flavours/glitch/initial_state'; import { profileLink, privacyPolicyLink } from 'flavours/glitch/utils/backend_links'; +import { HASHTAG_PATTERN_REGEX } from 'flavours/glitch/utils/hashtags'; import Warning from '../components/warning'; -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(); 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/flavours/glitch/features/explore/results.jsx b/app/javascript/flavours/glitch/features/explore/results.jsx index d764ac93b..724aa0290 100644 --- a/app/javascript/flavours/glitch/features/explore/results.jsx +++ b/app/javascript/flavours/glitch/features/explore/results.jsx @@ -111,7 +111,7 @@ class Results extends PureComponent { <>
- +
diff --git a/app/javascript/flavours/glitch/locales/en.json b/app/javascript/flavours/glitch/locales/en.json index 2a93f94aa..42e873011 100644 --- a/app/javascript/flavours/glitch/locales/en.json +++ b/app/javascript/flavours/glitch/locales/en.json @@ -98,12 +98,6 @@ "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.", "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.", "onboarding.skip": "Skip", - "search_popout.search_format": "Advanced search format", - "search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.", - "search_popout.tips.hashtag": "hashtag", - "search_popout.tips.status": "status", - "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", - "search_popout.tips.user": "user", "settings.always_show_spoilers_field": "Always enable the Content Warning field", "settings.auto_collapse": "Automatic collapsing", "settings.auto_collapse_all": "Everything", diff --git a/app/javascript/flavours/glitch/reducers/search.js b/app/javascript/flavours/glitch/reducers/search.js index ffd69585f..611e995e9 100644 --- a/app/javascript/flavours/glitch/reducers/search.js +++ b/app/javascript/flavours/glitch/reducers/search.js @@ -1,4 +1,4 @@ -import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; +import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; import { COMPOSE_MENTION, @@ -13,6 +13,8 @@ import { SEARCH_FETCH_SUCCESS, SEARCH_SHOW, SEARCH_EXPAND_SUCCESS, + SEARCH_RESULT_CLICK, + SEARCH_RESULT_FORGET, } from 'flavours/glitch/actions/search'; const initialState = ImmutableMap({ @@ -22,6 +24,7 @@ const initialState = ImmutableMap({ results: ImmutableMap(), isLoading: false, searchTerm: '', + recent: ImmutableOrderedSet(), }); export default function search(state = initialState, action) { @@ -62,6 +65,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/flavours/glitch/styles/components/drawer.scss b/app/javascript/flavours/glitch/styles/components/drawer.scss index 587b0e28c..74166db75 100644 --- a/app/javascript/flavours/glitch/styles/components/drawer.scss +++ b/app/javascript/flavours/glitch/styles/components/drawer.scss @@ -99,10 +99,6 @@ } } -.search-popout { - @include search-popout; -} - .navigation-bar { padding: 10px; color: $darker-text-color; diff --git a/app/javascript/flavours/glitch/styles/components/explore.scss b/app/javascript/flavours/glitch/styles/components/explore.scss index 8f67b365f..400b8cc5c 100644 --- a/app/javascript/flavours/glitch/styles/components/explore.scss +++ b/app/javascript/flavours/glitch/styles/components/explore.scss @@ -18,6 +18,10 @@ padding: 10px; } + .search__popout { + border: 1px solid lighten($ui-base-color, 8%); + } + .search .fa { top: 10px; inset-inline-end: 10px; diff --git a/app/javascript/flavours/glitch/styles/components/search.scss b/app/javascript/flavours/glitch/styles/components/search.scss index f93e14d76..6b9327c09 100644 --- a/app/javascript/flavours/glitch/styles/components/search.scss +++ b/app/javascript/flavours/glitch/styles/components/search.scss @@ -1,6 +1,86 @@ .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: 99; + 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 { diff --git a/app/javascript/flavours/glitch/utils/hashtags.ts b/app/javascript/flavours/glitch/utils/hashtags.ts new file mode 100644 index 000000000..0c5505c6c --- /dev/null +++ b/app/javascript/flavours/glitch/utils/hashtags.ts @@ -0,0 +1,29 @@ +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();