diff options
author | Eugen Rochko <eugen@zeonfederated.com> | 2024-11-17 19:00:39 +0100 |
---|---|---|
committer | Eugen Rochko <eugen@zeonfederated.com> | 2024-11-17 23:43:00 +0100 |
commit | 243dd80b87c2ee9277625c7255e7062eabaaa071 (patch) | |
tree | 2704d16a2d853ec9fbf95825e64c915f99801819 | |
parent | 44d92fa4f6aac5c45ad358287af48cd879f5665e (diff) |
WIP: Change search to use query params in web UIfeature-search-params
19 files changed, 1150 insertions, 1035 deletions
diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js index bde17ae0db1..18fc4aec70d 100644 --- a/app/javascript/mastodon/actions/search.js +++ b/app/javascript/mastodon/actions/search.js @@ -1,147 +1,67 @@ -import { fromJS } from 'immutable'; - +import { apiGetSearch } from 'mastodon/api/search'; import { searchHistory } from 'mastodon/settings'; +import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; import api from '../api'; import { fetchRelationships } from './accounts'; import { importFetchedAccounts, importFetchedStatuses } from './importer'; -export const SEARCH_CHANGE = 'SEARCH_CHANGE'; -export const SEARCH_CLEAR = 'SEARCH_CLEAR'; -export const SEARCH_SHOW = 'SEARCH_SHOW'; - -export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST'; -export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS'; -export const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL'; - -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_HISTORY_UPDATE = 'SEARCH_HISTORY_UPDATE'; -export function changeSearch(value) { - return { - type: SEARCH_CHANGE, - value, - }; -} - -export function clearSearch() { - return { - type: SEARCH_CLEAR, - }; -} - -export function submitSearch(type) { - return (dispatch, getState) => { - const value = getState().getIn(['search', 'value']); +export const submitSearch = createDataLoadingThunk( + 'search/submit', + async ({ q, type }, { getState }) => { const signedIn = !!getState().getIn(['meta', 'me']); - if (value.length === 0) { - dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, '', type)); - return; - } - - dispatch(fetchSearchRequest(type)); - - api().get('/api/v2/search', { - params: { - q: value, - resolve: signedIn, - limit: 11, - type, - }, - }).then(response => { - if (response.data.accounts) { - dispatch(importFetchedAccounts(response.data.accounts)); - } - - if (response.data.statuses) { - dispatch(importFetchedStatuses(response.data.statuses)); - } - - dispatch(fetchSearchSuccess(response.data, value, type)); - dispatch(fetchRelationships(response.data.accounts.map(item => item.id))); - }).catch(error => { - dispatch(fetchSearchFail(error)); - }); - }; -} - -export function fetchSearchRequest(searchType) { - return { - type: SEARCH_FETCH_REQUEST, - searchType, - }; -} - -export function fetchSearchSuccess(results, searchTerm, searchType) { - return { - type: SEARCH_FETCH_SUCCESS, - results, - searchType, - searchTerm, - }; -} - -export function fetchSearchFail(error) { - return { - type: SEARCH_FETCH_FAIL, - error, - }; -} - -export const expandSearch = type => (dispatch, getState) => { - const value = getState().getIn(['search', 'value']); - const offset = getState().getIn(['search', 'results', type]).size - 1; - - dispatch(expandSearchRequest(type)); - - api().get('/api/v2/search', { - params: { - q: value, + return apiGetSearch({ + q, type, - offset, + resolve: signedIn, limit: 11, - }, - }).then(({ data }) => { + }); + }, + (data, { dispatch }) => { if (data.accounts) { dispatch(importFetchedAccounts(data.accounts)); + dispatch(fetchRelationships(data.accounts.map(account => account.id))); } if (data.statuses) { dispatch(importFetchedStatuses(data.statuses)); } - dispatch(expandSearchSuccess(data, value, type)); - dispatch(fetchRelationships(data.accounts.map(item => item.id))); - }).catch(error => { - dispatch(expandSearchFail(error)); - }); -}; + return data; + }, +); -export const expandSearchRequest = (searchType) => ({ - type: SEARCH_EXPAND_REQUEST, - searchType, -}); +export const expandSearch = createDataLoadingThunk( + 'search/expand', + async ({ type }, { getState }) => { + const q = getState().search.q; + const results = getState().search.results; + const offset = results && results[type].length; -export const expandSearchSuccess = (results, searchTerm, searchType) => ({ - type: SEARCH_EXPAND_SUCCESS, - results, - searchTerm, - searchType, -}); + return apiGetSearch({ + q, + type, + limit: 11, + offset, + }); + }, + (data, { dispatch }) => { + if (data.accounts) { + dispatch(importFetchedAccounts(data.accounts)); + dispatch(fetchRelationships(data.accounts.map(account => account.id))); + } -export const expandSearchFail = error => ({ - type: SEARCH_EXPAND_FAIL, - error, -}); + if (data.statuses) { + dispatch(importFetchedStatuses(data.statuses)); + } -export const showSearch = () => ({ - type: SEARCH_SHOW, -}); + return data; + }, +); export const openURL = (value, history, onFailure) => (dispatch, getState) => { const signedIn = !!getState().getIn(['meta', 'me']); @@ -154,8 +74,6 @@ export const openURL = (value, history, onFailure) => (dispatch, getState) => { return; } - dispatch(fetchSearchRequest()); - api().get('/api/v2/search', { params: { q: value, resolve: true } }).then(response => { if (response.data.accounts?.length > 0) { dispatch(importFetchedAccounts(response.data.accounts)); @@ -166,11 +84,7 @@ export const openURL = (value, history, onFailure) => (dispatch, getState) => { } else if (onFailure) { onFailure(); } - - dispatch(fetchSearchSuccess(response.data, value)); - }).catch(err => { - dispatch(fetchSearchFail(err)); - + }).catch(() => { if (onFailure) { onFailure(); } @@ -178,25 +92,25 @@ export const openURL = (value, history, onFailure) => (dispatch, getState) => { }; export const clickSearchResult = (q, type) => (dispatch, getState) => { - const previous = getState().getIn(['search', 'recent']); + const previous = getState().search.recent; - if (previous.some(x => x.get('q') === q && x.get('type') === type)) { + if (previous.some(x => x.q === q && x.type === type)) { return; } const me = getState().getIn(['meta', 'me']); - const current = previous.add(fromJS({ type, q })).takeLast(4); + const current = [{ type, q }, ...previous].slice(0, 4); - searchHistory.set(me, current.toJS()); + searchHistory.set(me, current); dispatch(updateSearchHistory(current)); }; export const forgetSearchResult = q => (dispatch, getState) => { - const previous = getState().getIn(['search', 'recent']); + const previous = getState().search.recent; const me = getState().getIn(['meta', 'me']); - const current = previous.filterNot(result => result.get('q') === q); + const current = previous.filter(result => result.q !== q); - searchHistory.set(me, current.toJS()); + searchHistory.set(me, current); dispatch(updateSearchHistory(current)); }; diff --git a/app/javascript/mastodon/api/search.ts b/app/javascript/mastodon/api/search.ts new file mode 100644 index 00000000000..bae564a9561 --- /dev/null +++ b/app/javascript/mastodon/api/search.ts @@ -0,0 +1,13 @@ +import { apiRequestGet } from 'mastodon/api'; +import type { ApiSearchResultsJSON } from 'mastodon/api_types/search'; + +export const apiGetSearch = (params: { + q: string; + resolve: boolean; + type: string; + limit: number; + offset: number; +}) => + apiRequestGet<ApiSearchResultsJSON>('v2/search', { + ...params, + }); diff --git a/app/javascript/mastodon/api_types/search.ts b/app/javascript/mastodon/api_types/search.ts new file mode 100644 index 00000000000..c64ff09c4ab --- /dev/null +++ b/app/javascript/mastodon/api_types/search.ts @@ -0,0 +1,9 @@ +import type { ApiAccountJSON } from './accounts'; +import type { ApiStatusJSON } from './statuses'; +import type { ApiHashtagJSON } from './tags'; + +export interface ApiSearchResultsJSON { + accounts: ApiAccountJSON[]; + statuses: ApiStatusJSON[]; + hashtags: ApiHashtagJSON[]; +} diff --git a/app/javascript/mastodon/api_types/tags.ts b/app/javascript/mastodon/api_types/tags.ts new file mode 100644 index 00000000000..1439c6ec768 --- /dev/null +++ b/app/javascript/mastodon/api_types/tags.ts @@ -0,0 +1,12 @@ +interface ApiHistoryJSON { + day: string; + accounts: string; + uses: string; +} + +export interface ApiHashtagJSON { + name: string; + url: string; + history: ApiHistoryJSON[]; + following?: boolean; +} diff --git a/app/javascript/mastodon/features/compose/components/search.jsx b/app/javascript/mastodon/features/compose/components/search.jsx deleted file mode 100644 index 7fa7ad248bb..00000000000 --- a/app/javascript/mastodon/features/compose/components/search.jsx +++ /dev/null @@ -1,402 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { defineMessages, injectIntl, FormattedMessage, FormattedList } from 'react-intl'; - -import classNames from 'classnames'; -import { withRouter } from 'react-router-dom'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; - -import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react'; -import CloseIcon from '@/material-icons/400-24px/close.svg?react'; -import SearchIcon from '@/material-icons/400-24px/search.svg?react'; -import { Icon } from 'mastodon/components/icon'; -import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; -import { domain, searchEnabled } from 'mastodon/initial_state'; -import { HASHTAG_REGEX } from 'mastodon/utils/hashtags'; -import { WithRouterPropTypes } from 'mastodon/utils/react_router'; - -const messages = defineMessages({ - placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }, - placeholderSignedIn: { id: 'search.search_or_paste', defaultMessage: 'Search or paste URL' }, -}); - -const labelForRecentSearch = search => { - switch(search.get('type')) { - case 'account': - return `@${search.get('q')}`; - case 'hashtag': - return `#${search.get('q')}`; - default: - return search.get('q'); - } -}; - -class Search extends PureComponent { - static propTypes = { - identity: identityContextPropShape, - value: PropTypes.string.isRequired, - recent: ImmutablePropTypes.orderedSet, - submitted: PropTypes.bool, - onChange: PropTypes.func.isRequired, - onSubmit: PropTypes.func.isRequired, - onOpenURL: PropTypes.func.isRequired, - onClickSearchResult: PropTypes.func.isRequired, - onForgetSearchResult: PropTypes.func.isRequired, - onClear: PropTypes.func.isRequired, - onShow: PropTypes.func.isRequired, - openInRoute: PropTypes.bool, - intl: PropTypes.object.isRequired, - singleColumn: PropTypes.bool, - ...WithRouterPropTypes, - }; - - state = { - expanded: false, - selectedOption: -1, - options: [], - }; - - defaultOptions = [ - { key: 'prompt-has', label: <><mark>has:</mark> <FormattedList type='disjunction' value={['media', 'poll', 'embed']} /></>, action: e => { e.preventDefault(); this._insertText('has:'); } }, - { key: 'prompt-is', label: <><mark>is:</mark> <FormattedList type='disjunction' value={['reply', 'sensitive']} /></>, action: e => { e.preventDefault(); this._insertText('is:'); } }, - { key: 'prompt-language', label: <><mark>language:</mark> <FormattedMessage id='search_popout.language_code' defaultMessage='ISO language code' /></>, action: e => { e.preventDefault(); this._insertText('language:'); } }, - { key: 'prompt-from', label: <><mark>from:</mark> <FormattedMessage id='search_popout.user' defaultMessage='user' /></>, action: e => { e.preventDefault(); this._insertText('from:'); } }, - { key: 'prompt-before', label: <><mark>before:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('before:'); } }, - { key: 'prompt-during', label: <><mark>during:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('during:'); } }, - { key: 'prompt-after', label: <><mark>after:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('after:'); } }, - { key: 'prompt-in', label: <><mark>in:</mark> <FormattedList type='disjunction' value={['all', 'library', 'public']} /></>, action: e => { e.preventDefault(); this._insertText('in:'); } } - ]; - - setRef = c => { - this.searchForm = c; - }; - - handleChange = ({ target }) => { - const { onChange } = this.props; - - onChange(target.value); - - this._calculateOptions(target.value); - }; - - handleClear = e => { - const { value, submitted, onClear } = this.props; - - e.preventDefault(); - - if (value.length > 0 || submitted) { - onClear(); - this.setState({ options: [], selectedOption: -1 }); - } - }; - - handleKeyDown = (e) => { - const { selectedOption } = this.state; - const options = searchEnabled ? this._getOptions().concat(this.defaultOptions) : this._getOptions(); - - switch(e.key) { - case 'Escape': - e.preventDefault(); - this._unfocus(); - - break; - case 'ArrowDown': - e.preventDefault(); - - if (options.length > 0) { - this.setState({ selectedOption: Math.min(selectedOption + 1, options.length - 1) }); - } - - break; - case 'ArrowUp': - e.preventDefault(); - - if (options.length > 0) { - this.setState({ selectedOption: Math.max(selectedOption - 1, -1) }); - } - - break; - case 'Enter': - e.preventDefault(); - - if (selectedOption === -1) { - this._submit(); - } else if (options.length > 0) { - options[selectedOption].action(e); - } - - break; - case 'Delete': - if (selectedOption > -1 && options.length > 0) { - const search = options[selectedOption]; - - if (typeof search.forget === 'function') { - e.preventDefault(); - search.forget(e); - } - } - - break; - } - }; - - handleFocus = () => { - const { onShow, singleColumn } = this.props; - - this.setState({ expanded: true, selectedOption: -1 }); - onShow(); - - if (this.searchForm && !singleColumn) { - const { left, right } = this.searchForm.getBoundingClientRect(); - - if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) { - this.searchForm.scrollIntoView(); - } - } - }; - - handleBlur = () => { - this.setState({ expanded: false, selectedOption: -1 }); - }; - - handleHashtagClick = () => { - const { value, onClickSearchResult, history } = this.props; - - const query = value.trim().replace(/^#/, ''); - - history.push(`/tags/${query}`); - onClickSearchResult(query, 'hashtag'); - this._unfocus(); - }; - - handleAccountClick = () => { - const { value, onClickSearchResult, history } = this.props; - - const query = value.trim().replace(/^@/, ''); - - history.push(`/@${query}`); - onClickSearchResult(query, 'account'); - this._unfocus(); - }; - - handleURLClick = () => { - const { value, onOpenURL, history } = this.props; - - onOpenURL(value, history); - this._unfocus(); - }; - - handleStatusSearch = () => { - this._submit('statuses'); - }; - - handleAccountSearch = () => { - this._submit('accounts'); - }; - - handleRecentSearchClick = search => { - const { onChange, history } = this.props; - - if (search.get('type') === 'account') { - history.push(`/@${search.get('q')}`); - } else if (search.get('type') === 'hashtag') { - history.push(`/tags/${search.get('q')}`); - } else { - onChange(search.get('q')); - this._submit(search.get('type')); - } - - this._unfocus(); - }; - - handleForgetRecentSearchClick = search => { - const { onForgetSearchResult } = this.props; - - onForgetSearchResult(search.get('q')); - }; - - _unfocus () { - document.querySelector('.ui').parentElement.focus(); - } - - _insertText (text) { - const { value, onChange } = this.props; - - if (value === '') { - onChange(text); - } else if (value[value.length - 1] === ' ') { |