summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2024-11-17 19:00:39 +0100
committerEugen Rochko <eugen@zeonfederated.com>2024-11-17 23:43:00 +0100
commit243dd80b87c2ee9277625c7255e7062eabaaa071 (patch)
tree2704d16a2d853ec9fbf95825e64c915f99801819
parent44d92fa4f6aac5c45ad358287af48cd879f5665e (diff)
WIP: Change search to use query params in web UIfeature-search-params
-rw-r--r--app/javascript/mastodon/actions/search.js180
-rw-r--r--app/javascript/mastodon/api/search.ts13
-rw-r--r--app/javascript/mastodon/api_types/search.ts9
-rw-r--r--app/javascript/mastodon/api_types/tags.ts12
-rw-r--r--app/javascript/mastodon/features/compose/components/search.jsx402
-rw-r--r--app/javascript/mastodon/features/compose/components/search.tsx577
-rw-r--r--app/javascript/mastodon/features/compose/containers/search_container.js59
-rw-r--r--app/javascript/mastodon/features/compose/index.jsx4
-rw-r--r--app/javascript/mastodon/features/explore/index.jsx114
-rw-r--r--app/javascript/mastodon/features/explore/index.tsx121
-rw-r--r--app/javascript/mastodon/features/explore/results.jsx232
-rw-r--r--app/javascript/mastodon/features/explore/search_results.tsx259
-rw-r--r--app/javascript/mastodon/features/ui/components/compose_panel.jsx4
-rw-r--r--app/javascript/mastodon/models/search.ts21
-rw-r--r--app/javascript/mastodon/models/tags.ts3
-rw-r--r--app/javascript/mastodon/reducers/index.ts4
-rw-r--r--app/javascript/mastodon/reducers/search.js84
-rw-r--r--app/javascript/mastodon/reducers/search.ts71
-rw-r--r--app/javascript/styles/mastodon/components.scss16
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] === ' ') {