diff --git a/app/javascript/hooks/useLinks.ts b/app/javascript/hooks/useLinks.ts index f08b9500da..c99f3f4199 100644 --- a/app/javascript/hooks/useLinks.ts +++ b/app/javascript/hooks/useLinks.ts @@ -2,6 +2,8 @@ import { useCallback } from 'react'; import { useHistory } from 'react-router-dom'; +import { isFulfilled, isRejected } from '@reduxjs/toolkit'; + import { openURL } from 'mastodon/actions/search'; import { useAppDispatch } from 'mastodon/store'; @@ -28,12 +30,22 @@ export const useLinks = () => { ); const handleMentionClick = useCallback( - (element: HTMLAnchorElement) => { - dispatch( - openURL(element.href, history, () => { + async (element: HTMLAnchorElement) => { + const result = await dispatch(openURL({ url: element.href })); + + if (isFulfilled(result)) { + if (result.payload.accounts[0]) { + history.push(`/@${result.payload.accounts[0].acct}`); + } else if (result.payload.statuses[0]) { + history.push( + `/@${result.payload.statuses[0].account.acct}/${result.payload.statuses[0].id}`, + ); + } else { window.location.href = element.href; - }), - ); + } + } else if (isRejected(result)) { + window.location.href = element.href; + } }, [dispatch, history], ); @@ -48,7 +60,7 @@ export const useLinks = () => { if (isMentionClick(target)) { e.preventDefault(); - handleMentionClick(target); + void handleMentionClick(target); } else if (isHashtagClick(target)) { e.preventDefault(); handleHashtagClick(target); diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js deleted file mode 100644 index bde17ae0db..0000000000 --- a/app/javascript/mastodon/actions/search.js +++ /dev/null @@ -1,215 +0,0 @@ -import { fromJS } from 'immutable'; - -import { searchHistory } from 'mastodon/settings'; - -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']); - 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, - type, - offset, - limit: 11, - }, - }).then(({ data }) => { - if (data.accounts) { - dispatch(importFetchedAccounts(data.accounts)); - } - - 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)); - }); -}; - -export const expandSearchRequest = (searchType) => ({ - type: SEARCH_EXPAND_REQUEST, - searchType, -}); - -export const expandSearchSuccess = (results, searchTerm, searchType) => ({ - type: SEARCH_EXPAND_SUCCESS, - results, - searchTerm, - searchType, -}); - -export const expandSearchFail = error => ({ - type: SEARCH_EXPAND_FAIL, - error, -}); - -export const showSearch = () => ({ - type: SEARCH_SHOW, -}); - -export const openURL = (value, history, onFailure) => (dispatch, getState) => { - const signedIn = !!getState().getIn(['meta', 'me']); - - if (!signedIn) { - if (onFailure) { - onFailure(); - } - - 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)); - history.push(`/@${response.data.accounts[0].acct}`); - } else if (response.data.statuses?.length > 0) { - dispatch(importFetchedStatuses(response.data.statuses)); - history.push(`/@${response.data.statuses[0].account.acct}/${response.data.statuses[0].id}`); - } else if (onFailure) { - onFailure(); - } - - dispatch(fetchSearchSuccess(response.data, value)); - }).catch(err => { - dispatch(fetchSearchFail(err)); - - if (onFailure) { - onFailure(); - } - }); -}; - -export const clickSearchResult = (q, type) => (dispatch, getState) => { - const previous = getState().getIn(['search', 'recent']); - - if (previous.some(x => x.get('q') === q && x.get('type') === type)) { - return; - } - - const me = getState().getIn(['meta', 'me']); - const current = previous.add(fromJS({ type, q })).takeLast(4); - - searchHistory.set(me, current.toJS()); - dispatch(updateSearchHistory(current)); -}; - -export const forgetSearchResult = q => (dispatch, getState) => { - const previous = getState().getIn(['search', 'recent']); - const me = getState().getIn(['meta', 'me']); - const current = previous.filterNot(result => result.get('q') === q); - - searchHistory.set(me, current.toJS()); - dispatch(updateSearchHistory(current)); -}; - -export const updateSearchHistory = recent => ({ - type: SEARCH_HISTORY_UPDATE, - recent, -}); - -export const hydrateSearch = () => (dispatch, getState) => { - const me = getState().getIn(['meta', 'me']); - const history = searchHistory.get(me); - - if (history !== null) { - dispatch(updateSearchHistory(history)); - } -}; diff --git a/app/javascript/mastodon/actions/search.ts b/app/javascript/mastodon/actions/search.ts new file mode 100644 index 0000000000..7ee432f782 --- /dev/null +++ b/app/javascript/mastodon/actions/search.ts @@ -0,0 +1,151 @@ +import { createAction } from '@reduxjs/toolkit'; + +import { apiGetSearch } from 'mastodon/api/search'; +import type { ApiSearchType } from 'mastodon/api_types/search'; +import type { + RecentSearch, + SearchType as RecentSearchType, +} from 'mastodon/models/search'; +import { searchHistory } from 'mastodon/settings'; +import { + createDataLoadingThunk, + createAppAsyncThunk, +} from 'mastodon/store/typed_functions'; + +import { fetchRelationships } from './accounts'; +import { importFetchedAccounts, importFetchedStatuses } from './importer'; + +export const SEARCH_HISTORY_UPDATE = 'SEARCH_HISTORY_UPDATE'; + +export const submitSearch = createDataLoadingThunk( + 'search/submit', + async ({ q, type }: { q: string; type?: ApiSearchType }, { getState }) => { + const signedIn = !!getState().meta.get('me'); + + return apiGetSearch({ + q, + type, + resolve: signedIn, + limit: 11, + }); + }, + (data, { dispatch }) => { + if (data.accounts.length > 0) { + dispatch(importFetchedAccounts(data.accounts)); + dispatch(fetchRelationships(data.accounts.map((account) => account.id))); + } + + if (data.statuses.length > 0) { + dispatch(importFetchedStatuses(data.statuses)); + } + + return data; + }, + { + useLoadingBar: false, + }, +); + +export const expandSearch = createDataLoadingThunk( + 'search/expand', + async ({ type }: { type: ApiSearchType }, { getState }) => { + const q = getState().search.q; + const results = getState().search.results; + const offset = results?.[type].length; + + return apiGetSearch({ + q, + type, + limit: 11, + offset, + }); + }, + (data, { dispatch }) => { + if (data.accounts.length > 0) { + dispatch(importFetchedAccounts(data.accounts)); + dispatch(fetchRelationships(data.accounts.map((account) => account.id))); + } + + if (data.statuses.length > 0) { + dispatch(importFetchedStatuses(data.statuses)); + } + + return data; + }, + { + useLoadingBar: true, + }, +); + +export const openURL = createDataLoadingThunk( + 'search/openURL', + ({ url }: { url: string }, { getState }) => { + const signedIn = !!getState().meta.get('me'); + + return apiGetSearch({ + q: url, + resolve: signedIn, + limit: 1, + }); + }, + (data, { dispatch }) => { + if (data.accounts.length > 0) { + dispatch(importFetchedAccounts(data.accounts)); + } else if (data.statuses.length > 0) { + dispatch(importFetchedStatuses(data.statuses)); + } + + return data; + }, + { + useLoadingBar: true, + }, +); + +export const clickSearchResult = createAppAsyncThunk( + 'search/clickResult', + ( + { q, type }: { q: string; type?: RecentSearchType }, + { dispatch, getState }, + ) => { + const previous = getState().search.recent; + + if (previous.some((x) => x.q === q && x.type === type)) { + return; + } + + const me = getState().meta.get('me') as string; + const current = [{ type, q }, ...previous].slice(0, 4); + + searchHistory.set(me, current); + dispatch(updateSearchHistory(current)); + }, +); + +export const forgetSearchResult = createAppAsyncThunk( + 'search/forgetResult', + (q: string, { dispatch, getState }) => { + const previous = getState().search.recent; + const me = getState().meta.get('me') as string; + const current = previous.filter((result) => result.q !== q); + + searchHistory.set(me, current); + dispatch(updateSearchHistory(current)); + }, +); + +export const updateSearchHistory = createAction( + 'search/updateHistory', +); + +export const hydrateSearch = createAppAsyncThunk( + 'search/hydrate', + (_args, { dispatch, getState }) => { + const me = getState().meta.get('me') as string; + const history = searchHistory.get(me) as RecentSearch[] | null; + + if (history !== null) { + dispatch(updateSearchHistory(history)); + } + }, +); diff --git a/app/javascript/mastodon/api/search.ts b/app/javascript/mastodon/api/search.ts new file mode 100644 index 0000000000..79b0385fe8 --- /dev/null +++ b/app/javascript/mastodon/api/search.ts @@ -0,0 +1,16 @@ +import { apiRequestGet } from 'mastodon/api'; +import type { + ApiSearchType, + ApiSearchResultsJSON, +} from 'mastodon/api_types/search'; + +export const apiGetSearch = (params: { + q: string; + resolve?: boolean; + type?: ApiSearchType; + limit?: number; + offset?: number; +}) => + apiRequestGet('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 0000000000..795cbb2b41 --- /dev/null +++ b/app/javascript/mastodon/api_types/search.ts @@ -0,0 +1,11 @@ +import type { ApiAccountJSON } from './accounts'; +import type { ApiStatusJSON } from './statuses'; +import type { ApiHashtagJSON } from './tags'; + +export type ApiSearchType = 'accounts' | 'statuses' | 'hashtags'; + +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 0000000000..1439c6ec76 --- /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/components/hashtag.tsx b/app/javascript/mastodon/components/hashtag.tsx index 8963e4a40d..7c9e66041a 100644 --- a/app/javascript/mastodon/components/hashtag.tsx +++ b/app/javascript/mastodon/components/hashtag.tsx @@ -12,6 +12,7 @@ import { Sparklines, SparklinesCurve } from 'react-sparklines'; import { ShortNumber } from 'mastodon/components/short_number'; import { Skeleton } from 'mastodon/components/skeleton'; +import type { Hashtag as HashtagType } from 'mastodon/models/tags'; interface SilentErrorBoundaryProps { children: React.ReactNode; @@ -80,6 +81,22 @@ export const ImmutableHashtag = ({ hashtag }: ImmutableHashtagProps) => ( /> ); +export const CompatibilityHashtag: React.FC<{ + hashtag: HashtagType; +}> = ({ hashtag }) => ( + (day.uses as unknown as number) * 1) + .reverse()} + /> +); + export interface HashtagProps { className?: string; description?: React.ReactNode; diff --git a/app/javascript/mastodon/features/account/components/header.jsx b/app/javascript/mastodon/features/account/components/header.jsx index 6583c1f604..16be9b96ac 100644 --- a/app/javascript/mastodon/features/account/components/header.jsx +++ b/app/javascript/mastodon/features/account/components/header.jsx @@ -6,6 +6,7 @@ import classNames from 'classnames'; import { Helmet } from 'react-helmet'; import { NavLink, withRouter } from 'react-router-dom'; +import { isFulfilled, isRejected } from '@reduxjs/toolkit'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; @@ -215,8 +216,20 @@ class Header extends ImmutablePureComponent { const link = e.currentTarget; - onOpenURL(link.href, history, () => { - window.location = link.href; + onOpenURL(link.href).then((result) => { + if (isFulfilled(result)) { + if (result.payload.accounts[0]) { + history.push(`/@${result.payload.accounts[0].acct}`); + } else if (result.payload.statuses[0]) { + history.push(`/@${result.payload.statuses[0].account.acct}/${result.payload.statuses[0].id}`); + } else { + window.location = link.href; + } + } else if (isRejected(result)) { + window.location = link.href; + } + }).catch(() => { + // Nothing }); } }; diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx b/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx index 8df06bd920..14050c25d1 100644 --- a/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx +++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx @@ -144,8 +144,8 @@ const mapDispatchToProps = (dispatch) => ({ })); }, - onOpenURL (url, routerHistory, onFailure) { - dispatch(openURL(url, routerHistory, onFailure)); + onOpenURL (url) { + return dispatch(openURL({ url })); }, }); 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 7fa7ad248b..0000000000 --- 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: <>has: , action: e => { e.preventDefault(); this._insertText('has:'); } }, - { key: 'prompt-is', label: <>is: , action: e => { e.preventDefault(); this._insertText('is:'); } }, - { key: 'prompt-language', label: <>language: , action: e => { e.preventDefault(); this._insertText('language:'); } }, - { key: 'prompt-from', label: <>from: , action: e => { e.preventDefault(); this._insertText('from:'); } }, - { key: 'prompt-before', label: <>before: , action: e => { e.preventDefault(); this._insertText('before:'); } }, - { key: 'prompt-during', label: <>during: , action: e => { e.preventDefault(); this._insertText('during:'); } }, - { key: 'prompt-after', label: <>after: , action: e => { e.preventDefault(); this._insertText('after:'); } }, - { key: 'prompt-in', label: <>in: , 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] === ' ') { - onChange(`${value}${text}`); - } else { - onChange(`${value} ${text}`); - } - } - - _submit (type) { - const { onSubmit, openInRoute, value, onClickSearchResult, history } = this.props; - - onSubmit(type); - - if (value) { - onClickSearchResult(value, type); - } - - if (openInRoute) { - history.push('/search'); - } - - this._unfocus(); - } - - _getOptions () { - const { options } = this.state; - - if (options.length > 0) { - return options; - } - - const { recent } = this.props; - - return recent.toArray().map(search => ({ - key: `${search.get('type')}/${search.get('q')}`, - - label: labelForRecentSearch(search), - - action: () => this.handleRecentSearchClick(search), - - forget: e => { - e.stopPropagation(); - this.handleForgetRecentSearchClick(search); - }, - })); - } - - _calculateOptions (value) { - const { signedIn } = this.props.identity; - 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 && signedIn) { - 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, recent } = this.props; - const { expanded, options, selectedOption } = this.state; - const { signedIn } = this.props.identity; - - const hasValue = value.length > 0 || submitted; - - return ( -
- - -
- - -
- -
- {options.length === 0 && ( - <> -

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

- -
- {options.map(({ key, label, action }, i) => ( - - ))} -
- - )} - -

- - {searchEnabled && signedIn ? ( -
- {this.defaultOptions.map(({ key, label, action }, i) => ( - - ))} -
- ) : ( -
- {searchEnabled ? ( - - ) : ( - - )} -
- )} -
-
- ); - } - -} - -export default withRouter(withIdentity(injectIntl(Search))); diff --git a/app/javascript/mastodon/features/compose/components/search.tsx b/app/javascript/mastodon/features/compose/components/search.tsx new file mode 100644 index 0000000000..9d674c8564 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/search.tsx @@ -0,0 +1,590 @@ +import { useCallback, useState, useRef } from 'react'; + +import { + defineMessages, + useIntl, + FormattedMessage, + FormattedList, +} from 'react-intl'; + +import classNames from 'classnames'; +import { useHistory } from 'react-router-dom'; + +import { isFulfilled } from '@reduxjs/toolkit'; + +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 { + clickSearchResult, + forgetSearchResult, + openURL, +} from 'mastodon/actions/search'; +import { Icon } from 'mastodon/components/icon'; +import { useIdentity } from 'mastodon/identity_context'; +import { domain, searchEnabled } from 'mastodon/initial_state'; +import type { RecentSearch, SearchType } from 'mastodon/models/search'; +import { useAppSelector, useAppDispatch } from 'mastodon/store'; +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', + }, +}); + +const labelForRecentSearch = (search: RecentSearch) => { + switch (search.type) { + case 'account': + return `@${search.q}`; + case 'hashtag': + return `#${search.q}`; + default: + return search.q; + } +}; + +const unfocus = () => { + document.querySelector('.ui')?.parentElement?.focus(); +}; + +interface SearchOption { + key: string; + label: React.ReactNode; + action: (e: React.MouseEvent | React.KeyboardEvent) => void; + forget?: (e: React.MouseEvent | React.KeyboardEvent) => void; +} + +export const Search: React.FC<{ + singleColumn: boolean; +}> = ({ singleColumn }) => { + const intl = useIntl(); + const recent = useAppSelector((state) => state.search.recent); + const { signedIn } = useIdentity(); + const dispatch = useAppDispatch(); + const history = useHistory(); + const searchInputRef = useRef(null); + const [value, setValue] = useState(''); + const hasValue = value.length > 0; + const [expanded, setExpanded] = useState(false); + const [selectedOption, setSelectedOption] = useState(-1); + const [quickActions, setQuickActions] = useState([]); + const searchOptions: SearchOption[] = []; + + if (searchEnabled) { + searchOptions.push( + { + key: 'prompt-has', + label: ( + <> + has:{' '} + + + ), + action: (e) => { + e.preventDefault(); + insertText('has:'); + }, + }, + { + key: 'prompt-is', + label: ( + <> + is:{' '} + + + ), + action: (e) => { + e.preventDefault(); + insertText('is:'); + }, + }, + { + key: 'prompt-language', + label: ( + <> + language:{' '} + + + ), + action: (e) => { + e.preventDefault(); + insertText('language:'); + }, + }, + { + key: 'prompt-from', + label: ( + <> + from:{' '} + + + ), + action: (e) => { + e.preventDefault(); + insertText('from:'); + }, + }, + { + key: 'prompt-before', + label: ( + <> + before:{' '} + + + ), + action: (e) => { + e.preventDefault(); + insertText('before:'); + }, + }, + { + key: 'prompt-during', + label: ( + <> + during:{' '} + + + ), + action: (e) => { + e.preventDefault(); + insertText('during:'); + }, + }, + { + key: 'prompt-after', + label: ( + <> + after:{' '} + + + ), + action: (e) => { + e.preventDefault(); + insertText('after:'); + }, + }, + { + key: 'prompt-in', + label: ( + <> + in:{' '} + + + ), + action: (e) => { + e.preventDefault(); + insertText('in:'); + }, + }, + ); + } + + const recentOptions: SearchOption[] = recent.map((search) => ({ + key: `${search.type}/${search.q}`, + label: labelForRecentSearch(search), + action: () => { + if (search.type === 'account') { + history.push(`/@${search.q}`); + } else if (search.type === 'hashtag') { + history.push(`/tags/${search.q}`); + } else { + const queryParams = new URLSearchParams({ q: search.q }); + if (search.type) queryParams.set('type', search.type); + history.push({ pathname: '/search', search: queryParams.toString() }); + } + + unfocus(); + }, + forget: (e) => { + e.stopPropagation(); + void dispatch(forgetSearchResult(search.q)); + }, + })); + + const navigableOptions = hasValue + ? quickActions.concat(searchOptions) + : recentOptions.concat(quickActions, searchOptions); + + const insertText = (text: string) => { + setValue((currentValue) => { + if (currentValue === '') { + return text; + } else if (currentValue.endsWith(' ')) { + return `${currentValue}${text}`; + } else { + return `${currentValue} ${text}`; + } + }); + }; + + const submit = useCallback( + (q: string, type?: SearchType) => { + void dispatch(clickSearchResult({ q, type })); + const queryParams = new URLSearchParams({ q }); + if (type) queryParams.set('type', type); + history.push({ pathname: '/search', search: queryParams.toString() }); + unfocus(); + }, + [dispatch, history], + ); + + const handleChange = useCallback( + ({ target: { value } }: React.ChangeEvent) => { + setValue(value); + + const trimmedValue = value.trim(); + const newQuickActions = []; + + if (trimmedValue.length > 0) { + const couldBeURL = + trimmedValue.startsWith('https://') && !trimmedValue.includes(' '); + + if (couldBeURL) { + newQuickActions.push({ + key: 'open-url', + label: ( + + ), + action: async () => { + const result = await dispatch(openURL({ url: trimmedValue })); + + if (isFulfilled(result)) { + if (result.payload.accounts[0]) { + history.push(`/@${result.payload.accounts[0].acct}`); + } else if (result.payload.statuses[0]) { + history.push( + `/@${result.payload.statuses[0].account.acct}/${result.payload.statuses[0].id}`, + ); + } + } + + unfocus(); + }, + }); + } + + const couldBeHashtag = + (trimmedValue.startsWith('#') && trimmedValue.length > 1) || + trimmedValue.match(HASHTAG_REGEX); + + if (couldBeHashtag) { + newQuickActions.push({ + key: 'go-to-hashtag', + label: ( + #{trimmedValue.replace(/^#/, '')} }} + /> + ), + action: () => { + const query = trimmedValue.replace(/^#/, ''); + history.push(`/tags/${query}`); + void dispatch(clickSearchResult({ q: query, type: 'hashtag' })); + unfocus(); + }, + }); + } + + const couldBeUsername = /^@?[a-z0-9_-]+(@[^\s]+)?$/i.exec(trimmedValue); + + if (couldBeUsername) { + newQuickActions.push({ + key: 'go-to-account', + label: ( + @{trimmedValue.replace(/^@/, '')} }} + /> + ), + action: () => { + const query = trimmedValue.replace(/^@/, ''); + history.push(`/@${query}`); + void dispatch(clickSearchResult({ q: query, type: 'account' })); + unfocus(); + }, + }); + } + + const couldBeStatusSearch = searchEnabled; + + if (couldBeStatusSearch && signedIn) { + newQuickActions.push({ + key: 'status-search', + label: ( + {trimmedValue} }} + /> + ), + action: () => { + submit(trimmedValue, 'statuses'); + }, + }); + } + + newQuickActions.push({ + key: 'account-search', + label: ( + {trimmedValue} }} + /> + ), + action: () => { + submit(trimmedValue, 'accounts'); + }, + }); + } + + setQuickActions(newQuickActions); + }, + [dispatch, history, signedIn, setValue, setQuickActions, submit], + ); + + const handleClear = useCallback(() => { + setValue(''); + setQuickActions([]); + setSelectedOption(-1); + }, [setValue, setQuickActions, setSelectedOption]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + switch (e.key) { + case 'Escape': + e.preventDefault(); + unfocus(); + + break; + case 'ArrowDown': + e.preventDefault(); + + if (navigableOptions.length > 0) { + setSelectedOption( + Math.min(selectedOption + 1, navigableOptions.length - 1), + ); + } + + break; + case 'ArrowUp': + e.preventDefault(); + + if (navigableOptions.length > 0) { + setSelectedOption(Math.max(selectedOption - 1, -1)); + } + + break; + case 'Enter': + e.preventDefault(); + + if (selectedOption === -1) { + submit(value); + } else if (navigableOptions.length > 0) { + navigableOptions[selectedOption]?.action(e); + } + + break; + case 'Delete': + if (selectedOption > -1 && navigableOptions.length > 0) { + const search = navigableOptions[selectedOption]; + + if (typeof search?.forget === 'function') { + e.preventDefault(); + search.forget(e); + } + } + + break; + } + }, + [navigableOptions, value, selectedOption, setSelectedOption, submit], + ); + + const handleFocus = useCallback(() => { + setExpanded(true); + setSelectedOption(-1); + + if (searchInputRef.current && !singleColumn) { + const { left, right } = searchInputRef.current.getBoundingClientRect(); + + if ( + left < 0 || + right > (window.innerWidth || document.documentElement.clientWidth) + ) { + searchInputRef.current.scrollIntoView(); + } + } + }, [setExpanded, setSelectedOption, singleColumn]); + + const handleBlur = useCallback(() => { + setExpanded(false); + setSelectedOption(-1); + }, [setExpanded, setSelectedOption]); + + return ( +
+ + + + +
+ {!hasValue && ( + <> +

+ +

+ +
+ {recentOptions.length > 0 ? ( + recentOptions.map(({ label, key, action, forget }, i) => ( + + + )) + ) : ( +
+ +
+ )} +
+ + )} + + {quickActions.length > 0 && ( + <> +

+ +

+ +
+ {quickActions.map(({ key, label, action }, i) => ( + + ))} +
+ + )} + +

+ +

+ + {searchEnabled && signedIn ? ( +
+ {searchOptions.map(({ key, label, action }, i) => ( + + ))} +
+ ) : ( +
+ {searchEnabled ? ( + + ) : ( + + )} +
+ )} +
+
+ ); +}; diff --git a/app/javascript/mastodon/features/compose/components/search_results.jsx b/app/javascript/mastodon/features/compose/components/search_results.jsx deleted file mode 100644 index 6a482c8ec2..0000000000 --- a/app/javascript/mastodon/features/compose/components/search_results.jsx +++ /dev/null @@ -1,93 +0,0 @@ -import { useCallback } from 'react'; - -import { FormattedMessage } from 'react-intl'; - -import FindInPageIcon from '@/material-icons/400-24px/find_in_page.svg?react'; -import PeopleIcon from '@/material-icons/400-24px/group.svg?react'; -import TagIcon from '@/material-icons/400-24px/tag.svg?react'; -import { expandSearch } from 'mastodon/actions/search'; -import { Icon } from 'mastodon/components/icon'; -import { LoadMore } from 'mastodon/components/load_more'; -import { LoadingIndicator } from 'mastodon/components/loading_indicator'; -import { SearchSection } from 'mastodon/features/explore/components/search_section'; -import { useAppDispatch, useAppSelector } from 'mastodon/store'; - -import { ImmutableHashtag as Hashtag } from '../../../components/hashtag'; -import AccountContainer from '../../../containers/account_container'; -import StatusContainer from '../../../containers/status_container'; - -const INITIAL_PAGE_LIMIT = 10; - -const withoutLastResult = list => { - if (list.size > INITIAL_PAGE_LIMIT && list.size % INITIAL_PAGE_LIMIT === 1) { - return list.skipLast(1); - } else { - return list; - } -}; - -export const SearchResults = () => { - const results = useAppSelector((state) => state.getIn(['search', 'results'])); - const isLoading = useAppSelector((state) => state.getIn(['search', 'isLoading'])); - - const dispatch = useAppDispatch(); - - const handleLoadMoreAccounts = useCallback(() => { - dispatch(expandSearch('accounts')); - }, [dispatch]); - - const handleLoadMoreStatuses = useCallback(() => { - dispatch(expandSearch('statuses')); - }, [dispatch]); - - const handleLoadMoreHashtags = useCallback(() => { - dispatch(expandSearch('hashtags')); - }, [dispatch]); - - let accounts, statuses, hashtags; - - if (results.get('accounts') && results.get('accounts').size > 0) { - accounts = ( - }> - {withoutLastResult(results.get('accounts')).map(accountId => )} - {(results.get('accounts').size > INITIAL_PAGE_LIMIT && results.get('accounts').size % INITIAL_PAGE_LIMIT === 1) && } - - ); - } - - if (results.get('hashtags') && results.get('hashtags').size > 0) { - hashtags = ( - }> - {withoutLastResult(results.get('hashtags')).map(hashtag => )} - {(results.get('hashtags').size > INITIAL_PAGE_LIMIT && results.get('hashtags').size % INITIAL_PAGE_LIMIT === 1) && } - - ); - } - - if (results.get('statuses') && results.get('statuses').size > 0) { - statuses = ( - }> - {withoutLastResult(results.get('statuses')).map(statusId => )} - {(results.get('statuses').size > INITIAL_PAGE_LIMIT && results.get('statuses').size % INITIAL_PAGE_LIMIT === 1) && } - - ); - } - - return ( -
- {!accounts && !hashtags && !statuses && ( - isLoading ? ( - - ) : ( -
- -
- ) - )} - {accounts} - {hashtags} - {statuses} -
- ); - -}; diff --git a/app/javascript/mastodon/features/compose/containers/search_container.js b/app/javascript/mastodon/features/compose/containers/search_container.js deleted file mode 100644 index 616b91369c..0000000000 --- a/app/javascript/mastodon/features/compose/containers/search_container.js +++ /dev/null @@ -1,59 +0,0 @@ -import { createSelector } from '@reduxjs/toolkit'; -import { connect } from 'react-redux'; - -import { - changeSearch, - clearSearch, - submitSearch, - showSearch, - openURL, - clickSearchResult, - forgetSearchResult, -} from 'mastodon/actions/search'; - -import Search from '../components/search'; - -const getRecentSearches = createSelector( - state => state.getIn(['search', 'recent']), - recent => recent.reverse(), -); - -const mapStateToProps = state => ({ - value: state.getIn(['search', 'value']), - submitted: state.getIn(['search', 'submitted']), - recent: getRecentSearches(state), -}); - -const mapDispatchToProps = dispatch => ({ - - onChange (value) { - dispatch(changeSearch(value)); - }, - - onClear () { - dispatch(clearSearch()); - }, - - onSubmit (type) { - dispatch(submitSearch(type)); - }, - - onShow () { - dispatch(showSearch()); - }, - - onOpenURL (q, routerHistory) { - dispatch(openURL(q, 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/index.jsx b/app/javascript/mastodon/features/compose/index.jsx index 3a96ab49c3..660f08615b 100644 --- a/app/javascript/mastodon/features/compose/index.jsx +++ b/app/javascript/mastodon/features/compose/index.jsx @@ -9,8 +9,6 @@ import { Link } from 'react-router-dom'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; -import spring from 'react-motion/lib/spring'; - import PeopleIcon from '@/material-icons/400-24px/group.svg?react'; import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react'; import LogoutIcon from '@/material-icons/400-24px/logout.svg?react'; @@ -26,11 +24,9 @@ import elephantUIPlane from '../../../images/elephant_ui_plane.svg'; import { changeComposing, mountCompose, unmountCompose } from '../../actions/compose'; import { mascot } from '../../initial_state'; import { isMobile } from '../../is_mobile'; -import Motion from '../ui/util/optional_motion'; -import { SearchResults } from './components/search_results'; +import { Search } from './components/search'; import ComposeFormContainer from './containers/compose_form_container'; -import SearchContainer from './containers/search_container'; const messages = defineMessages({ start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, @@ -43,9 +39,8 @@ const messages = defineMessages({ compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' }, }); -const mapStateToProps = (state, ownProps) => ({ +const mapStateToProps = (state) => ({ columns: state.getIn(['settings', 'columns']), - showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : false, }); class Compose extends PureComponent { @@ -54,7 +49,6 @@ class Compose extends PureComponent { dispatch: PropTypes.func.isRequired, columns: ImmutablePropTypes.list.isRequired, multiColumn: PropTypes.bool, - showSearch: PropTypes.bool, intl: PropTypes.object.isRequired, }; @@ -88,7 +82,7 @@ class Compose extends PureComponent { }; render () { - const { multiColumn, showSearch, intl } = this.props; + const { multiColumn, intl } = this.props; if (multiColumn) { const { columns } = this.props; @@ -113,7 +107,7 @@ class Compose extends PureComponent { - {multiColumn && } + {multiColumn && }
@@ -123,14 +117,6 @@ class Compose extends PureComponent {
- - - {({ x }) => ( -
- -
- )} -
); diff --git a/app/javascript/mastodon/features/explore/components/search_section.jsx b/app/javascript/mastodon/features/explore/components/search_section.jsx deleted file mode 100644 index c84e3f7cef..0000000000 --- a/app/javascript/mastodon/features/explore/components/search_section.jsx +++ /dev/null @@ -1,20 +0,0 @@ -import PropTypes from 'prop-types'; - -import { FormattedMessage } from 'react-intl'; - -export const SearchSection = ({ title, onClickMore, children }) => ( -
-
-

{title}

- {onClickMore && } -
- - {children} -
-); - -SearchSection.propTypes = { - title: PropTypes.node.isRequired, - onClickMore: PropTypes.func, - children: PropTypes.children, -}; \ No newline at end of file diff --git a/app/javascript/mastodon/features/explore/index.jsx b/app/javascript/mastodon/features/explore/index.jsx deleted file mode 100644 index 83e5df22f8..0000000000 --- a/app/javascript/mastodon/features/explore/index.jsx +++ /dev/null @@ -1,114 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; - -import { Helmet } from 'react-helmet'; -import { NavLink, Switch, Route } from 'react-router-dom'; - -import { connect } from 'react-redux'; - -import ExploreIcon from '@/material-icons/400-24px/explore.svg?react'; -import SearchIcon from '@/material-icons/400-24px/search.svg?react'; -import Column from 'mastodon/components/column'; -import ColumnHeader from 'mastodon/components/column_header'; -import Search from 'mastodon/features/compose/containers/search_container'; -import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; -import { trendsEnabled } from 'mastodon/initial_state'; - -import Links from './links'; -import SearchResults from './results'; -import Statuses from './statuses'; -import Suggestions from './suggestions'; -import Tags from './tags'; - -const messages = defineMessages({ - title: { id: 'explore.title', defaultMessage: 'Explore' }, - searchResults: { id: 'explore.search_results', defaultMessage: 'Search results' }, -}); - -const mapStateToProps = state => ({ - layout: state.getIn(['meta', 'layout']), - isSearching: state.getIn(['search', 'submitted']) || !trendsEnabled, -}); - -class Explore extends PureComponent { - static propTypes = { - identity: identityContextPropShape, - intl: PropTypes.object.isRequired, - multiColumn: PropTypes.bool, - isSearching: PropTypes.bool, - }; - - handleHeaderClick = () => { - this.column.scrollTop(); - }; - - setRef = c => { - this.column = c; - }; - - render() { - const { intl, multiColumn, isSearching } = this.props; - const { signedIn } = this.props.identity; - - return ( - - - -
- -
- - {isSearching ? ( - - ) : ( - <> -
- - - - - - - - - {signedIn && ( - - - - )} - - - - -
- - - - - - - - - - - - {intl.formatMessage(messages.title)} - - - - )} -
- ); - } - -} - -export default withIdentity(connect(mapStateToProps)(injectIntl(Explore))); diff --git a/app/javascript/mastodon/features/explore/index.tsx b/app/javascript/mastodon/features/explore/index.tsx new file mode 100644 index 0000000000..10445a12ec --- /dev/null +++ b/app/javascript/mastodon/features/explore/index.tsx @@ -0,0 +1,104 @@ +import { useCallback, useRef } from 'react'; + +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; +import { NavLink, Switch, Route } from 'react-router-dom'; + +import ExploreIcon from '@/material-icons/400-24px/explore.svg?react'; +import Column from 'mastodon/components/column'; +import { ColumnHeader } from 'mastodon/components/column_header'; +import { Search } from 'mastodon/features/compose/components/search'; +import { useIdentity } from 'mastodon/identity_context'; + +import Links from './links'; +import Statuses from './statuses'; +import Suggestions from './suggestions'; +import Tags from './tags'; + +const messages = defineMessages({ + title: { id: 'explore.title', defaultMessage: 'Explore' }, +}); + +const Explore: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => { + const { signedIn } = useIdentity(); + const intl = useIntl(); + const columnRef = useRef(null); + + const handleHeaderClick = useCallback(() => { + columnRef.current?.scrollTop(); + }, []); + + return ( + + + +
+ +
+ +
+ + + + + + + + + {signedIn && ( + + + + )} + + + + +
+ + + + + + + + + + + + {intl.formatMessage(messages.title)} + + +
+ ); +}; + +// eslint-disable-next-line import/no-default-export +export default Explore; diff --git a/app/javascript/mastodon/features/explore/results.jsx b/app/javascript/mastodon/features/explore/results.jsx deleted file mode 100644 index 355c0f1c4c..0000000000 --- a/app/javascript/mastodon/features/explore/results.jsx +++ /dev/null @@ -1,232 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; - -import { Helmet } from 'react-helmet'; - -import { List as ImmutableList } from 'immutable'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { connect } from 'react-redux'; - -import FindInPageIcon from '@/material-icons/400-24px/find_in_page.svg?react'; -import PeopleIcon from '@/material-icons/400-24px/group.svg?react'; -import TagIcon from '@/material-icons/400-24px/tag.svg?react'; -import { submitSearch, expandSearch } from 'mastodon/actions/search'; -import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag'; -import { Icon } from 'mastodon/components/icon'; -import ScrollableList from 'mastodon/components/scrollable_list'; -import Account from 'mastodon/containers/account_container'; -import Status from 'mastodon/containers/status_container'; - -import { SearchSection } from './components/search_section'; - -const messages = defineMessages({ - title: { id: 'search_results.title', defaultMessage: 'Search for {q}' }, -}); - -const mapStateToProps = state => ({ - isLoading: state.getIn(['search', 'isLoading']), - results: state.getIn(['search', 'results']), - q: state.getIn(['search', 'searchTerm']), - submittedType: state.getIn(['search', 'type']), -}); - -const INITIAL_PAGE_LIMIT = 10; -const INITIAL_DISPLAY = 4; - -const hidePeek = list => { - if (list.size > INITIAL_PAGE_LIMIT && list.size % INITIAL_PAGE_LIMIT === 1) { - return list.skipLast(1); - } else { - return list; - } -}; - -const renderAccounts = accounts => hidePeek(accounts).map(id => ( - -)); - -const renderHashtags = hashtags => hidePeek(hashtags).map(hashtag => ( - -)); - -const renderStatuses = statuses => hidePeek(statuses).map(id => ( - -)); - -class Results extends PureComponent { - - static propTypes = { - results: ImmutablePropTypes.contains({ - accounts: ImmutablePropTypes.orderedSet, - statuses: ImmutablePropTypes.orderedSet, - hashtags: ImmutablePropTypes.orderedSet, - }), - isLoading: PropTypes.bool, - multiColumn: PropTypes.bool, - dispatch: PropTypes.func.isRequired, - q: PropTypes.string, - intl: PropTypes.object, - submittedType: PropTypes.oneOf(['accounts', 'statuses', 'hashtags']), - }; - - state = { - type: this.props.submittedType || 'all', - }; - - static getDerivedStateFromProps(props, state) { - if (props.submittedType !== state.type) { - return { - type: props.submittedType || 'all', - }; - } - - return null; - } - - handleSelectAll = () => { - const { submittedType, dispatch } = this.props; - - // If we originally searched for a specific type, we need to resubmit - // the query to get all types of results - if (submittedType) { - dispatch(submitSearch()); - } - - this.setState({ type: 'all' }); - }; - - handleSelectAccounts = () => { - const { submittedType, dispatch } = this.props; - - // If we originally searched for something else (but not everything), - // we need to resubmit the query for this specific type - if (submittedType !== 'accounts') { - dispatch(submitSearch('accounts')); - } - - this.setState({ type: 'accounts' }); - }; - - handleSelectHashtags = () => { - const { submittedType, dispatch } = this.props; - - // If we originally searched for something else (but not everything), - // we need to resubmit the query for this specific type - if (submittedType !== 'hashtags') { - dispatch(submitSearch('hashtags')); - } - - this.setState({ type: 'hashtags' }); - }; - - handleSelectStatuses = () => { - const { submittedType, dispatch } = this.props; - - // If we originally searched for something else (but not everything), - // we need to resubmit the query for this specific type - if (submittedType !== 'statuses') { - dispatch(submitSearch('statuses')); - } - - this.setState({ type: 'statuses' }); - }; - - handleLoadMoreAccounts = () => this._loadMore('accounts'); - handleLoadMoreStatuses = () => this._loadMore('statuses'); - handleLoadMoreHashtags = () => this._loadMore('hashtags'); - - _loadMore (type) { - const { dispatch } = this.props; - dispatch(expandSearch(type)); - } - - handleLoadMore = () => { - const { type } = this.state; - - if (type !== 'all') { - this._loadMore(type); - } - }; - - render () { - const { intl, isLoading, q, results } = this.props; - const { type } = this.state; - - // We request 1 more result than we display so we can tell if there'd be a next page - const hasMore = type !== 'all' ? results.get(type, ImmutableList()).size > INITIAL_PAGE_LIMIT && results.get(type).size % INITIAL_PAGE_LIMIT === 1 : false; - - let filteredResults; - - const accounts = results.get('accounts', ImmutableList()); - const hashtags = results.get('hashtags', ImmutableList()); - const statuses = results.get('statuses', ImmutableList()); - - switch(type) { - case 'all': - filteredResults = (accounts.size + hashtags.size + statuses.size) > 0 ? ( - <> - {accounts.size > 0 && ( - } onClickMore={this.handleLoadMoreAccounts}> - {accounts.take(INITIAL_DISPLAY).map(id => )} - - )} - - {hashtags.size > 0 && ( - } onClickMore={this.handleLoadMoreHashtags}> - {hashtags.take(INITIAL_DISPLAY).map(hashtag => )} - - )} - - {statuses.size > 0 && ( - } onClickMore={this.handleLoadMoreStatuses}> - {statuses.take(INITIAL_DISPLAY).map(id => )} - - )} - - ) : []; - break; - case 'accounts': - filteredResults = renderAccounts(accounts); - break; - case 'hashtags': - filteredResults = renderHashtags(hashtags); - break; - case 'statuses': - filteredResults = renderStatuses(statuses); - break; - } - - return ( - <> -
- - - - -
- -
- } - bindToDocument - > - {filteredResults} - -
- - - {intl.formatMessage(messages.title, { q })} - - - ); - } - -} - -export default connect(mapStateToProps)(injectIntl(Results)); diff --git a/app/javascript/mastodon/features/search/components/search_section.tsx b/app/javascript/mastodon/features/search/components/search_section.tsx new file mode 100644 index 0000000000..ae0c129676 --- /dev/null +++ b/app/javascript/mastodon/features/search/components/search_section.tsx @@ -0,0 +1,23 @@ +import { FormattedMessage } from 'react-intl'; + +export const SearchSection: React.FC<{ + title: React.ReactNode; + onClickMore?: () => void; + children: React.ReactNode; +}> = ({ title, onClickMore, children }) => ( +
+
+

{title}

+ {onClickMore && ( + + )} +
+ + {children} +
+); diff --git a/app/javascript/mastodon/features/search/index.tsx b/app/javascript/mastodon/features/search/index.tsx new file mode 100644 index 0000000000..8d13ccbcbb --- /dev/null +++ b/app/javascript/mastodon/features/search/index.tsx @@ -0,0 +1,301 @@ +import { useCallback, useEffect, useRef } from 'react'; + +import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import { useSearchParam } from '@/hooks/useSearchParam'; +import FindInPageIcon from '@/material-icons/400-24px/find_in_page.svg?react'; +import PeopleIcon from '@/material-icons/400-24px/group.svg?react'; +import SearchIcon from '@/material-icons/400-24px/search.svg?react'; +import TagIcon from '@/material-icons/400-24px/tag.svg?react'; +import { submitSearch, expandSearch } from 'mastodon/actions/search'; +import type { ApiSearchType } from 'mastodon/api_types/search'; +import Column from 'mastodon/components/column'; +import { ColumnHeader } from 'mastodon/components/column_header'; +import { CompatibilityHashtag as Hashtag } from 'mastodon/components/hashtag'; +import { Icon } from 'mastodon/components/icon'; +import ScrollableList from 'mastodon/components/scrollable_list'; +import Account from 'mastodon/containers/account_container'; +import Status from 'mastodon/containers/status_container'; +import { Search } from 'mastodon/features/compose/components/search'; +import type { Hashtag as HashtagType } from 'mastodon/models/tags'; +import { useAppDispatch, useAppSelector } from 'mastodon/store'; + +import { SearchSection } from './components/search_section'; + +const messages = defineMessages({ + title: { id: 'search_results.title', defaultMessage: 'Search for {q}' }, +}); + +const INITIAL_PAGE_LIMIT = 10; +const INITIAL_DISPLAY = 4; + +const hidePeek = (list: T[]) => { + if ( + list.length > INITIAL_PAGE_LIMIT && + list.length % INITIAL_PAGE_LIMIT === 1 + ) { + return list.slice(0, -2); + } else { + return list; + } +}; + +const renderAccounts = (accountIds: string[]) => + hidePeek(accountIds).map((id) => ( + // @ts-expect-error inferred props are wrong + + )); + +const renderHashtags = (hashtags: HashtagType[]) => + hidePeek(hashtags).map((hashtag) => ( + + )); + +const renderStatuses = (statusIds: string[]) => + hidePeek(statusIds).map((id) => ( + // @ts-expect-error inferred props are wrong + + )); + +type SearchType = 'all' | ApiSearchType; + +const typeFromParam = (param?: string): SearchType => { + if (param && ['all', 'accounts', 'statuses', 'hashtags'].includes(param)) { + return param as SearchType; + } else { + return 'all'; + } +}; + +export const SearchResults: React.FC<{ multiColumn: boolean }> = ({ + multiColumn, +}) => { + const columnRef = useRef(null); + const intl = useIntl(); + const [q] = useSearchParam('q'); + const [type, setType] = useSearchParam('type'); + const isLoading = useAppSelector((state) => state.search.loading); + const results = useAppSelector((state) => state.search.results); + const dispatch = useAppDispatch(); + const mappedType = typeFromParam(type); + + useEffect(() => { + const trimmedValue = q?.trim() ?? ''; + + if (trimmedValue.length > 0) { + void dispatch( + submitSearch({ + q: trimmedValue, + type: mappedType === 'all' ? undefined : mappedType, + }), + ); + } + }, [dispatch, q, mappedType]); + + const handleHeaderClick = useCallback(() => { + columnRef.current?.scrollTop(); + }, []); + + const handleSelectAll = useCallback(() => { + setType(null); + }, [setType]); + + const handleSelectAccounts = useCallback(() => { + setType('accounts'); + }, [setType]); + + const handleSelectHashtags = useCallback(() => { + setType('hashtags'); + }, [setType]); + + const handleSelectStatuses = useCallback(() => { + setType('statuses'); + }, [setType]); + + const handleLoadMore = useCallback(() => { + if (mappedType !== 'all') { + void dispatch(expandSearch({ type: mappedType })); + } + }, [dispatch, mappedType]); + + // We request 1 more result than we display so we can tell if there'd be a next page + const hasMore = + mappedType !== 'all' && results + ? results[mappedType].length > INITIAL_PAGE_LIMIT && + results[mappedType].length % INITIAL_PAGE_LIMIT === 1 + : false; + + let filteredResults; + + if (results) { + switch (mappedType) { + case 'all': + filteredResults = + results.accounts.length + + results.hashtags.length + + results.statuses.length > + 0 ? ( + <> + {results.accounts.length > 0 && ( + + + + + } + onClickMore={handleSelectAccounts} + > + {results.accounts.slice(0, INITIAL_DISPLAY).map((id) => ( + // @ts-expect-error inferred props are wrong + + ))} + + )} + + {results.hashtags.length > 0 && ( + + + + + } + onClickMore={handleSelectHashtags} + > + {results.hashtags.slice(0, INITIAL_DISPLAY).map((hashtag) => ( + + ))} + + )} + + {results.statuses.length > 0 && ( + + + + + } + onClickMore={handleSelectStatuses} + > + {results.statuses.slice(0, INITIAL_DISPLAY).map((id) => ( + // @ts-expect-error inferred props are wrong + + ))} + + )} + + ) : ( + [] + ); + break; + case 'accounts': + filteredResults = renderAccounts(results.accounts); + break; + case 'hashtags': + filteredResults = renderHashtags(results.hashtags); + break; + case 'statuses': + filteredResults = renderStatuses(results.statuses); + break; + } + } + + return ( + + + +
+ +
+ +
+ + + + +
+ +
+ + } + bindToDocument + > + {filteredResults} + +
+ + + {intl.formatMessage(messages.title, { q })} + + +
+ ); +}; + +// eslint-disable-next-line import/no-default-export +export default SearchResults; diff --git a/app/javascript/mastodon/features/ui/components/compose_panel.jsx b/app/javascript/mastodon/features/ui/components/compose_panel.jsx index 18321cbe63..1b4ac0c6ca 100644 --- a/app/javascript/mastodon/features/ui/components/compose_panel.jsx +++ b/app/javascript/mastodon/features/ui/components/compose_panel.jsx @@ -5,8 +5,8 @@ import { connect } from 'react-redux'; import { changeComposing, mountCompose, unmountCompose } from 'mastodon/actions/compose'; import ServerBanner from 'mastodon/components/server_banner'; +import { Search } from 'mastodon/features/compose/components/search'; import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container'; -import SearchContainer from 'mastodon/features/compose/containers/search_container'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; import LinkFooter from './link_footer'; @@ -42,7 +42,7 @@ class ComposePanel extends PureComponent { return (
- + {!signedIn && ( <> diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index b90ea5585a..7170d0836e 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -65,6 +65,7 @@ import { Lists, Directory, Explore, + Search, Onboarding, About, PrivacyPolicy, @@ -216,7 +217,8 @@ class SwitchingColumnsArea extends PureComponent { - + + diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index ff5db65347..c39937261a 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -182,6 +182,10 @@ export function Explore () { return import(/* webpackChunkName: "features/explore" */'../../explore'); } +export function Search () { + return import(/* webpackChunkName: "features/explore" */'../../search'); +} + export function FilterModal () { return import(/*webpackChunkName: "modals/filter_modal" */'../components/filter_modal'); } diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 9728528f8e..1ab68b8fe0 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -303,7 +303,6 @@ "error.unexpected_crash.next_steps_addons": "Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.", "errors.unexpected_crash.copy_stacktrace": "Copy stacktrace to clipboard", "errors.unexpected_crash.report_issue": "Report issue", - "explore.search_results": "Search results", "explore.suggested_follows": "People", "explore.title": "Explore", "explore.trending_links": "News", diff --git a/app/javascript/mastodon/models/search.ts b/app/javascript/mastodon/models/search.ts new file mode 100644 index 0000000000..75a65bf99c --- /dev/null +++ b/app/javascript/mastodon/models/search.ts @@ -0,0 +1,21 @@ +import type { ApiSearchResultsJSON } from 'mastodon/api_types/search'; +import type { ApiHashtagJSON } from 'mastodon/api_types/tags'; + +export type SearchType = 'account' | 'hashtag' | 'accounts' | 'statuses'; + +export interface RecentSearch { + q: string; + type?: SearchType; +} + +export interface SearchResults { + accounts: string[]; + statuses: string[]; + hashtags: ApiHashtagJSON[]; +} + +export const createSearchResults = (serverJSON: ApiSearchResultsJSON) => ({ + accounts: serverJSON.accounts.map((account) => account.id), + statuses: serverJSON.statuses.map((status) => status.id), + hashtags: serverJSON.hashtags, +}); diff --git a/app/javascript/mastodon/models/tags.ts b/app/javascript/mastodon/models/tags.ts new file mode 100644 index 0000000000..714fc53d45 --- /dev/null +++ b/app/javascript/mastodon/models/tags.ts @@ -0,0 +1,3 @@ +import type { ApiHashtagJSON } from 'mastodon/api_types/tags'; + +export type Hashtag = ApiHashtagJSON; diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts index b92de0dbcd..7f1eac7331 100644 --- a/app/javascript/mastodon/reducers/index.ts +++ b/app/javascript/mastodon/reducers/index.ts @@ -32,7 +32,7 @@ import { pictureInPictureReducer } from './picture_in_picture'; import polls from './polls'; import push_notifications from './push_notifications'; import { relationshipsReducer } from './relationships'; -import search from './search'; +import { searchReducer } from './search'; import server from './server'; import settings from './settings'; import status_lists from './status_lists'; @@ -63,7 +63,7 @@ const reducers = { server, contexts, compose, - search, + search: searchReducer, media_attachments, notifications, notificationGroups: notificationGroupsReducer, diff --git a/app/javascript/mastodon/reducers/search.js b/app/javascript/mastodon/reducers/search.js deleted file mode 100644 index 7de1c65c07..0000000000 --- a/app/javascript/mastodon/reducers/search.js +++ /dev/null @@ -1,84 +0,0 @@ -import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; - -import { - COMPOSE_MENTION, - COMPOSE_REPLY, - COMPOSE_DIRECT, -} from '../actions/compose'; -import { - SEARCH_CHANGE, - SEARCH_CLEAR, - SEARCH_FETCH_REQUEST, - SEARCH_FETCH_FAIL, - SEARCH_FETCH_SUCCESS, - SEARCH_SHOW, - SEARCH_EXPAND_REQUEST, - SEARCH_EXPAND_SUCCESS, - SEARCH_EXPAND_FAIL, - SEARCH_HISTORY_UPDATE, -} from '../actions/search'; - -const initialState = ImmutableMap({ - value: '', - submitted: false, - hidden: false, - results: ImmutableMap(), - isLoading: false, - searchTerm: '', - type: null, - recent: ImmutableOrderedSet(), -}); - -export default function search(state = initialState, action) { - switch(action.type) { - case SEARCH_CHANGE: - return state.set('value', action.value); - case SEARCH_CLEAR: - return state.withMutations(map => { - map.set('value', ''); - map.set('results', ImmutableMap()); - map.set('submitted', false); - map.set('hidden', false); - map.set('searchTerm', ''); - map.set('type', null); - }); - case SEARCH_SHOW: - return state.set('hidden', false); - case COMPOSE_REPLY: - case COMPOSE_MENTION: - case COMPOSE_DIRECT: - return state.set('hidden', true); - case SEARCH_FETCH_REQUEST: - return state.withMutations(map => { - map.set('results', ImmutableMap()); - map.set('isLoading', true); - map.set('submitted', true); - map.set('type', action.searchType); - }); - case SEARCH_FETCH_FAIL: - case SEARCH_EXPAND_FAIL: - return state.set('isLoading', false); - case SEARCH_FETCH_SUCCESS: - return state.withMutations(map => { - map.set('results', ImmutableMap({ - accounts: ImmutableOrderedSet(action.results.accounts.map(item => item.id)), - statuses: ImmutableOrderedSet(action.results.statuses.map(item => item.id)), - hashtags: ImmutableOrderedSet(fromJS(action.results.hashtags)), - })); - - map.set('searchTerm', action.searchTerm); - map.set('type', action.searchType); - map.set('isLoading', false); - }); - case SEARCH_EXPAND_REQUEST: - return state.set('type', action.searchType).set('isLoading', true); - case SEARCH_EXPAND_SUCCESS: { - const results = action.searchType === 'hashtags' ? ImmutableOrderedSet(fromJS(action.results.hashtags)) : action.results[action.searchType].map(item => item.id); - return state.updateIn(['results', action.searchType], list => list.union(results)).set('isLoading', false); - } - case SEARCH_HISTORY_UPDATE: - return state.set('recent', ImmutableOrderedSet(fromJS(action.recent))); - default: - return state; - } -} diff --git a/app/javascript/mastodon/reducers/search.ts b/app/javascript/mastodon/reducers/search.ts new file mode 100644 index 0000000000..3f6b96fa07 --- /dev/null +++ b/app/javascript/mastodon/reducers/search.ts @@ -0,0 +1,74 @@ +import { createReducer, isAnyOf } from '@reduxjs/toolkit'; + +import type { ApiSearchType } from 'mastodon/api_types/search'; +import type { RecentSearch, SearchResults } from 'mastodon/models/search'; +import { createSearchResults } from 'mastodon/models/search'; + +import { + updateSearchHistory, + submitSearch, + expandSearch, +} from '../actions/search'; + +interface State { + recent: RecentSearch[]; + q: string; + type?: ApiSearchType; + loading: boolean; + results?: SearchResults; +} + +const initialState: State = { + recent: [], + q: '', + type: undefined, + loading: false, + results: undefined, +}; + +export const searchReducer = createReducer(initialState, (builder) => { + builder.addCase(submitSearch.fulfilled, (state, action) => { + state.q = action.meta.arg.q; + state.type = action.meta.arg.type; + state.results = createSearchResults(action.payload); + state.loading = false; + }); + + builder.addCase(expandSearch.fulfilled, (state, action) => { + const type = action.meta.arg.type; + const results = createSearchResults(action.payload); + + state.type = type; + state.results = { + accounts: state.results + ? [...state.results.accounts, ...results.accounts] + : results.accounts, + statuses: state.results + ? [...state.results.statuses, ...results.statuses] + : results.statuses, + hashtags: state.results + ? [...state.results.hashtags, ...results.hashtags] + : results.hashtags, + }; + state.loading = false; + }); + + builder.addCase(updateSearchHistory, (state, action) => { + state.recent = action.payload; + }); + + builder.addMatcher( + isAnyOf(expandSearch.pending, submitSearch.pending), + (state, action) => { + state.type = action.meta.arg.type; + state.loading = true; + }, + ); + + builder.addMatcher( + isAnyOf(expandSearch.rejected, submitSearch.rejected), + (state) => { + state.loading = false; + }, + ); +}); diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 1bfe37cee6..91ed1f94a1 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -5676,6 +5676,17 @@ a.status-card { } .search__icon { + background: transparent; + border: 0; + padding: 0; + position: absolute; + top: 12px + 2px; + cursor: default; + pointer-events: none; + margin-inline-start: 16px - 2px; + width: 20px; + height: 20px; + &::-moz-focus-inner { border: 0; } @@ -5687,17 +5698,14 @@ a.status-card { .icon { position: absolute; - top: 12px + 2px; - display: inline-block; + top: 0; + inset-inline-start: 0; opacity: 0; transition: all 100ms linear; transition-property: transform, opacity; width: 20px; height: 20px; color: $darker-text-color; - cursor: default; - pointer-events: none; - margin-inline-start: 16px - 2px; &.active { pointer-events: auto; @@ -9073,8 +9081,8 @@ noscript { border: 1px solid var(--background-border-color); } - .search .icon { - top: 9px; + .search__icon { + top: 10px; inset-inline-end: 10px; color: $dark-text-color; }