Change search to use query params in web UI (#32949)
This commit is contained in:
		
							parent
							
								
									708919ee93
								
							
						
					
					
						commit
						0636bcdbe1
					
				| @ -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, () => { | ||||
|           window.location.href = element.href; | ||||
|         }), | ||||
|     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); | ||||
|  | ||||
| @ -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)); | ||||
|   } | ||||
| }; | ||||
							
								
								
									
										151
									
								
								app/javascript/mastodon/actions/search.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										151
									
								
								app/javascript/mastodon/actions/search.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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<RecentSearch[]>( | ||||
|   '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)); | ||||
|     } | ||||
|   }, | ||||
| ); | ||||
							
								
								
									
										16
									
								
								app/javascript/mastodon/api/search.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								app/javascript/mastodon/api/search.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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<ApiSearchResultsJSON>('v2/search', { | ||||
|     ...params, | ||||
|   }); | ||||
							
								
								
									
										11
									
								
								app/javascript/mastodon/api_types/search.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								app/javascript/mastodon/api_types/search.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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[]; | ||||
| } | ||||
| @ -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 }) => ( | ||||
|   <Hashtag | ||||
|     name={hashtag.name} | ||||
|     to={`/tags/${hashtag.name}`} | ||||
|     people={ | ||||
|       (hashtag.history[0].accounts as unknown as number) * 1 + | ||||
|       ((hashtag.history[1]?.accounts ?? 0) as unknown as number) * 1 | ||||
|     } | ||||
|     history={hashtag.history | ||||
|       .map((day) => (day.uses as unknown as number) * 1) | ||||
|       .reverse()} | ||||
|   /> | ||||
| ); | ||||
| 
 | ||||
| export interface HashtagProps { | ||||
|   className?: string; | ||||
|   description?: React.ReactNode; | ||||
|  | ||||
| @ -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, () => { | ||||
|       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 | ||||
|       }); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
| @ -144,8 +144,8 @@ const mapDispatchToProps = (dispatch) => ({ | ||||
|     })); | ||||
|   }, | ||||
| 
 | ||||
|   onOpenURL (url, routerHistory, onFailure) { | ||||
|     dispatch(openURL(url, routerHistory, onFailure)); | ||||
|   onOpenURL (url) { | ||||
|     return dispatch(openURL({ url })); | ||||
|   }, | ||||
| 
 | ||||
| }); | ||||
|  | ||||
| @ -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] === ' ') { | ||||
|       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: <FormattedMessage id='search.quick_action.open_url' defaultMessage='Open URL in Mastodon' />, action: this.handleURLClick }); | ||||
|       } | ||||
| 
 | ||||
|       const couldBeHashtag = (trimmedValue.startsWith('#') && trimmedValue.length > 1) || trimmedValue.match(HASHTAG_REGEX); | ||||
| 
 | ||||
|       if (couldBeHashtag) { | ||||
|         options.push({ key: 'go-to-hashtag', label: <FormattedMessage id='search.quick_action.go_to_hashtag' defaultMessage='Go to hashtag {x}' values={{ x: <mark>#{trimmedValue.replace(/^#/, '')}</mark> }} />, action: this.handleHashtagClick }); | ||||
|       } | ||||
| 
 | ||||
|       const couldBeUsername = trimmedValue.match(/^@?[a-z0-9_-]+(@[^\s]+)?$/i); | ||||
| 
 | ||||
|       if (couldBeUsername) { | ||||
|         options.push({ key: 'go-to-account', label: <FormattedMessage id='search.quick_action.go_to_account' defaultMessage='Go to profile {x}' values={{ x: <mark>@{trimmedValue.replace(/^@/, '')}</mark> }} />, action: this.handleAccountClick }); | ||||
|       } | ||||
| 
 | ||||
|       const couldBeStatusSearch = searchEnabled; | ||||
| 
 | ||||
|       if (couldBeStatusSearch && signedIn) { | ||||
|         options.push({ key: 'status-search', label: <FormattedMessage id='search.quick_action.status_search' defaultMessage='Posts matching {x}' values={{ x: <mark>{trimmedValue}</mark> }} />, action: this.handleStatusSearch }); | ||||
|       } | ||||
| 
 | ||||
|       const couldBeUserSearch = true; | ||||
| 
 | ||||
|       if (couldBeUserSearch) { | ||||
|         options.push({ key: 'account-search', label: <FormattedMessage id='search.quick_action.account_search' defaultMessage='Profiles matching {x}' values={{ x: <mark>{trimmedValue}</mark> }} />, 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 ( | ||||
|       <div className={classNames('search', { active: expanded })}> | ||||
|         <input | ||||
|           ref={this.setRef} | ||||
|           className='search__input' | ||||
|           type='text' | ||||
|           placeholder={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)} | ||||
|           aria-label={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)} | ||||
|           value={value} | ||||
|           onChange={this.handleChange} | ||||
|           onKeyDown={this.handleKeyDown} | ||||
|           onFocus={this.handleFocus} | ||||
|           onBlur={this.handleBlur} | ||||
|         /> | ||||
| 
 | ||||
|         <div role='button' tabIndex={0} className='search__icon' onClick={this.handleClear}> | ||||
|           <Icon id='search' icon={SearchIcon} className={hasValue ? '' : 'active'} /> | ||||
|           <Icon id='times-circle' icon={CancelIcon} className={hasValue ? 'active' : ''} aria-label={intl.formatMessage(messages.placeholder)} /> | ||||
|         </div> | ||||
| 
 | ||||
|         <div className='search__popout'> | ||||
|           {options.length === 0 && ( | ||||
|             <> | ||||
|               <h4><FormattedMessage id='search_popout.recent' defaultMessage='Recent searches' /></h4> | ||||
| 
 | ||||
|               <div className='search__popout__menu'> | ||||
|                 {recent.size > 0 ? this._getOptions().map(({ label, key, action, forget }, i) => ( | ||||
|                   <button key={key} onMouseDown={action} className={classNames('search__popout__menu__item search__popout__menu__item--flex', { selected: selectedOption === i })}> | ||||
|                     <span>{label}</span> | ||||
|                     <button className='icon-button' onMouseDown={forget}><Icon id='times' icon={CloseIcon} /></button> | ||||
|                   </button> | ||||
|                 )) : ( | ||||
|                   <div className='search__popout__menu__message'> | ||||
|                     <FormattedMessage id='search.no_recent_searches' defaultMessage='No recent searches' /> | ||||
|                   </div> | ||||
|                 )} | ||||
|               </div> | ||||
|             </> | ||||
|           )} | ||||
| 
 | ||||
|           {options.length > 0 && ( | ||||
|             <> | ||||
|               <h4><FormattedMessage id='search_popout.quick_actions' defaultMessage='Quick actions' /></h4> | ||||
| 
 | ||||
|               <div className='search__popout__menu'> | ||||
|                 {options.map(({ key, label, action }, i) => ( | ||||
|                   <button key={key} onMouseDown={action} className={classNames('search__popout__menu__item', { selected: selectedOption === i })}> | ||||
|                     {label} | ||||
|                   </button> | ||||
|                 ))} | ||||
|               </div> | ||||
|             </> | ||||
|           )} | ||||
| 
 | ||||
|           <h4><FormattedMessage id='search_popout.options' defaultMessage='Search options' /></h4> | ||||
| 
 | ||||
|           {searchEnabled && signedIn ? ( | ||||
|             <div className='search__popout__menu'> | ||||
|               {this.defaultOptions.map(({ key, label, action }, i) => ( | ||||
|                 <button key={key} onMouseDown={action} className={classNames('search__popout__menu__item', { selected: selectedOption === ((options.length || recent.size) + i) })}> | ||||
|                   {label} | ||||
|                 </button> | ||||
|               ))} | ||||
|             </div> | ||||
|           ) : ( | ||||
|             <div className='search__popout__menu__message'> | ||||
|               {searchEnabled ? ( | ||||
|                 <FormattedMessage id='search_popout.full_text_search_logged_out_message' defaultMessage='Only available when logged in.' /> | ||||
|               ) : ( | ||||
|                 <FormattedMessage id='search_popout.full_text_search_disabled_message' defaultMessage='Not available on {domain}.' values={{ domain }} /> | ||||
|               )} | ||||
|             </div> | ||||
|           )} | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| export default withRouter(withIdentity(injectIntl(Search))); | ||||
							
								
								
									
										593
									
								
								app/javascript/mastodon/features/compose/components/search.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										593
									
								
								app/javascript/mastodon/features/compose/components/search.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,593 @@ | ||||
| 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; | ||||
|   initialValue?: string; | ||||
| }> = ({ singleColumn, initialValue }) => { | ||||
|   const intl = useIntl(); | ||||
|   const recent = useAppSelector((state) => state.search.recent); | ||||
|   const { signedIn } = useIdentity(); | ||||
|   const dispatch = useAppDispatch(); | ||||
|   const history = useHistory(); | ||||
|   const searchInputRef = useRef<HTMLInputElement>(null); | ||||
|   const [value, setValue] = useState(initialValue ?? ''); | ||||
|   const hasValue = value.length > 0; | ||||
|   const [expanded, setExpanded] = useState(false); | ||||
|   const [selectedOption, setSelectedOption] = useState(-1); | ||||
|   const [quickActions, setQuickActions] = useState<SearchOption[]>([]); | ||||
|   const searchOptions: SearchOption[] = []; | ||||
| 
 | ||||
|   if (searchEnabled) { | ||||
|     searchOptions.push( | ||||
|       { | ||||
|         key: 'prompt-has', | ||||
|         label: ( | ||||
|           <> | ||||
|             <mark>has:</mark>{' '} | ||||
|             <FormattedList | ||||
|               type='disjunction' | ||||
|               value={['media', 'poll', 'embed']} | ||||
|             /> | ||||
|           </> | ||||
|         ), | ||||
|         action: (e) => { | ||||
|           e.preventDefault(); | ||||
|           insertText('has:'); | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         key: 'prompt-is', | ||||
|         label: ( | ||||
|           <> | ||||
|             <mark>is:</mark>{' '} | ||||
|             <FormattedList type='disjunction' value={['reply', 'sensitive']} /> | ||||
|           </> | ||||
|         ), | ||||
|         action: (e) => { | ||||
|           e.preventDefault(); | ||||
|           insertText('is:'); | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         key: 'prompt-language', | ||||
|         label: ( | ||||
|           <> | ||||
|             <mark>language:</mark>{' '} | ||||
|             <FormattedMessage | ||||
|               id='search_popout.language_code' | ||||
|               defaultMessage='ISO language code' | ||||
|             /> | ||||
|           </> | ||||
|         ), | ||||
|         action: (e) => { | ||||
|           e.preventDefault(); | ||||
|           insertText('language:'); | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         key: 'prompt-from', | ||||
|         label: ( | ||||
|           <> | ||||
|             <mark>from:</mark>{' '} | ||||
|             <FormattedMessage id='search_popout.user' defaultMessage='user' /> | ||||
|           </> | ||||
|         ), | ||||
|         action: (e) => { | ||||
|           e.preventDefault(); | ||||
|           insertText('from:'); | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         key: 'prompt-before', | ||||
|         label: ( | ||||
|           <> | ||||
|             <mark>before:</mark>{' '} | ||||
|             <FormattedMessage | ||||
|               id='search_popout.specific_date' | ||||
|               defaultMessage='specific date' | ||||
|             /> | ||||
|           </> | ||||
|         ), | ||||
|         action: (e) => { | ||||
|           e.preventDefault(); | ||||
|           insertText('before:'); | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         key: 'prompt-during', | ||||
|         label: ( | ||||
|           <> | ||||
|             <mark>during:</mark>{' '} | ||||
|             <FormattedMessage | ||||
|               id='search_popout.specific_date' | ||||
|               defaultMessage='specific date' | ||||
|             /> | ||||
|           </> | ||||
|         ), | ||||
|         action: (e) => { | ||||
|           e.preventDefault(); | ||||
|           insertText('during:'); | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         key: 'prompt-after', | ||||
|         label: ( | ||||
|           <> | ||||
|             <mark>after:</mark>{' '} | ||||
|             <FormattedMessage | ||||
|               id='search_popout.specific_date' | ||||
|               defaultMessage='specific date' | ||||
|             /> | ||||
|           </> | ||||
|         ), | ||||
|         action: (e) => { | ||||
|           e.preventDefault(); | ||||
|           insertText('after:'); | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         key: 'prompt-in', | ||||
|         label: ( | ||||
|           <> | ||||
|             <mark>in:</mark>{' '} | ||||
|             <FormattedList | ||||
|               type='disjunction' | ||||
|               value={['all', 'library', 'public']} | ||||
|             /> | ||||
|           </> | ||||
|         ), | ||||
|         action: (e) => { | ||||
|           e.preventDefault(); | ||||
|           insertText('in:'); | ||||
|         }, | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   const recentOptions: SearchOption[] = recent.map((search) => ({ | ||||
|     key: `${search.type}/${search.q}`, | ||||
|     label: labelForRecentSearch(search), | ||||
|     action: () => { | ||||
|       setValue(search.q); | ||||
| 
 | ||||
|       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<HTMLInputElement>) => { | ||||
|       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: ( | ||||
|               <FormattedMessage | ||||
|                 id='search.quick_action.open_url' | ||||
|                 defaultMessage='Open URL in Mastodon' | ||||
|               /> | ||||
|             ), | ||||
|             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: ( | ||||
|               <FormattedMessage | ||||
|                 id='search.quick_action.go_to_hashtag' | ||||
|                 defaultMessage='Go to hashtag {x}' | ||||
|                 values={{ x: <mark>#{trimmedValue.replace(/^#/, '')}</mark> }} | ||||
|               /> | ||||
|             ), | ||||
|             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: ( | ||||
|               <FormattedMessage | ||||
|                 id='search.quick_action.go_to_account' | ||||
|                 defaultMessage='Go to profile {x}' | ||||
|                 values={{ x: <mark>@{trimmedValue.replace(/^@/, '')}</mark> }} | ||||
|               /> | ||||
|             ), | ||||
|             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: ( | ||||
|               <FormattedMessage | ||||
|                 id='search.quick_action.status_search' | ||||
|                 defaultMessage='Posts matching {x}' | ||||
|                 values={{ x: <mark>{trimmedValue}</mark> }} | ||||
|               /> | ||||
|             ), | ||||
|             action: () => { | ||||
|               submit(trimmedValue, 'statuses'); | ||||
|             }, | ||||
|           }); | ||||
|         } | ||||
| 
 | ||||
|         newQuickActions.push({ | ||||
|           key: 'account-search', | ||||
|           label: ( | ||||
|             <FormattedMessage | ||||
|               id='search.quick_action.account_search' | ||||
|               defaultMessage='Profiles matching {x}' | ||||
|               values={{ x: <mark>{trimmedValue}</mark> }} | ||||
|             /> | ||||
|           ), | ||||
|           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 ( | ||||
|     <form className={classNames('search', { active: expanded })}> | ||||
|       <input | ||||
|         ref={searchInputRef} | ||||
|         className='search__input' | ||||
|         type='text' | ||||
|         placeholder={intl.formatMessage( | ||||
|           signedIn ? messages.placeholderSignedIn : messages.placeholder, | ||||
|         )} | ||||
|         aria-label={intl.formatMessage( | ||||
|           signedIn ? messages.placeholderSignedIn : messages.placeholder, | ||||
|         )} | ||||
|         value={value} | ||||
|         onChange={handleChange} | ||||
|         onKeyDown={handleKeyDown} | ||||
|         onFocus={handleFocus} | ||||
|         onBlur={handleBlur} | ||||
|       /> | ||||
| 
 | ||||
|       <button type='button' className='search__icon' onClick={handleClear}> | ||||
|         <Icon | ||||
|           id='search' | ||||
|           icon={SearchIcon} | ||||
|           className={hasValue ? '' : 'active'} | ||||
|         /> | ||||
|         <Icon | ||||
|           id='times-circle' | ||||
|           icon={CancelIcon} | ||||
|           className={hasValue ? 'active' : ''} | ||||
|           aria-label={intl.formatMessage(messages.placeholder)} | ||||
|         /> | ||||
|       </button> | ||||
| 
 | ||||
|       <div className='search__popout'> | ||||
|         {!hasValue && ( | ||||
|           <> | ||||
|             <h4> | ||||
|               <FormattedMessage | ||||
|                 id='search_popout.recent' | ||||
|                 defaultMessage='Recent searches' | ||||
|               /> | ||||
|             </h4> | ||||
| 
 | ||||
|             <div className='search__popout__menu'> | ||||
|               {recentOptions.length > 0 ? ( | ||||
|                 recentOptions.map(({ label, key, action, forget }, i) => ( | ||||
|                   <button | ||||
|                     key={key} | ||||
|                     onMouseDown={action} | ||||
|                     className={classNames( | ||||
|                       'search__popout__menu__item search__popout__menu__item--flex', | ||||
|                       { selected: selectedOption === i }, | ||||
|                     )} | ||||
|                   > | ||||
|                     <span>{label}</span> | ||||
|                     <button className='icon-button' onMouseDown={forget}> | ||||
|                       <Icon id='times' icon={CloseIcon} /> | ||||
|                     </button> | ||||
|                   </button> | ||||
|                 )) | ||||
|               ) : ( | ||||
|                 <div className='search__popout__menu__message'> | ||||
|                   <FormattedMessage | ||||
|                     id='search.no_recent_searches' | ||||
|                     defaultMessage='No recent searches' | ||||
|                   /> | ||||
|                 </div> | ||||
|               )} | ||||
|             </div> | ||||
|           </> | ||||
|         )} | ||||
| 
 | ||||
|         {quickActions.length > 0 && ( | ||||
|           <> | ||||
|             <h4> | ||||
|               <FormattedMessage | ||||
|                 id='search_popout.quick_actions' | ||||
|                 defaultMessage='Quick actions' | ||||
|               /> | ||||
|             </h4> | ||||
| 
 | ||||
|             <div className='search__popout__menu'> | ||||
|               {quickActions.map(({ key, label, action }, i) => ( | ||||
|                 <button | ||||
|                   key={key} | ||||
|                   onMouseDown={action} | ||||
|                   className={classNames('search__popout__menu__item', { | ||||
|                     selected: selectedOption === i, | ||||
|                   })} | ||||
|                 > | ||||
|                   {label} | ||||
|                 </button> | ||||
|               ))} | ||||
|             </div> | ||||
|           </> | ||||
|         )} | ||||
| 
 | ||||
|         <h4> | ||||
|           <FormattedMessage | ||||
|             id='search_popout.options' | ||||
|             defaultMessage='Search options' | ||||
|           /> | ||||
|         </h4> | ||||
| 
 | ||||
|         {searchEnabled && signedIn ? ( | ||||
|           <div className='search__popout__menu'> | ||||
|             {searchOptions.map(({ key, label, action }, i) => ( | ||||
|               <button | ||||
|                 key={key} | ||||
|                 onMouseDown={action} | ||||
|                 className={classNames('search__popout__menu__item', { | ||||
|                   selected: | ||||
|                     selectedOption === | ||||
|                     (quickActions.length || recent.length) + i, | ||||
|                 })} | ||||
|               > | ||||
|                 {label} | ||||
|               </button> | ||||
|             ))} | ||||
|           </div> | ||||
|         ) : ( | ||||
|           <div className='search__popout__menu__message'> | ||||
|             {searchEnabled ? ( | ||||
|               <FormattedMessage | ||||
|                 id='search_popout.full_text_search_logged_out_message' | ||||
|                 defaultMessage='Only available when logged in.' | ||||
|               /> | ||||
|             ) : ( | ||||
|               <FormattedMessage | ||||
|                 id='search_popout.full_text_search_disabled_message' | ||||
|                 defaultMessage='Not available on {domain}.' | ||||
|                 values={{ domain }} | ||||
|               /> | ||||
|             )} | ||||
|           </div> | ||||
|         )} | ||||
|       </div> | ||||
|     </form> | ||||
|   ); | ||||
| }; | ||||
| @ -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 { Account } from 'mastodon/components/account'; | ||||
| 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 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 = ( | ||||
|       <SearchSection title={<><Icon id='users' icon={PeopleIcon} /><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></>}> | ||||
|         {withoutLastResult(results.get('accounts')).map(accountId => <Account key={accountId} id={accountId} />)} | ||||
|         {(results.get('accounts').size > INITIAL_PAGE_LIMIT && results.get('accounts').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={handleLoadMoreAccounts} />} | ||||
|       </SearchSection> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   if (results.get('hashtags') && results.get('hashtags').size > 0) { | ||||
|     hashtags = ( | ||||
|       <SearchSection title={<><Icon id='hashtag' icon={TagIcon} /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></>}> | ||||
|         {withoutLastResult(results.get('hashtags')).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)} | ||||
|         {(results.get('hashtags').size > INITIAL_PAGE_LIMIT && results.get('hashtags').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={handleLoadMoreHashtags} />} | ||||
|       </SearchSection> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   if (results.get('statuses') && results.get('statuses').size > 0) { | ||||
|     statuses = ( | ||||
|       <SearchSection title={<><Icon id='quote-right' icon={FindInPageIcon} /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></>}> | ||||
|         {withoutLastResult(results.get('statuses')).map(statusId => <StatusContainer key={statusId} id={statusId} />)} | ||||
|         {(results.get('statuses').size > INITIAL_PAGE_LIMIT && results.get('statuses').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={handleLoadMoreStatuses} />} | ||||
|       </SearchSection> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <div className='search-results'> | ||||
|       {!accounts && !hashtags && !statuses && ( | ||||
|         isLoading ? ( | ||||
|           <LoadingIndicator /> | ||||
|         ) : ( | ||||
|           <div className='empty-column-indicator'> | ||||
|             <FormattedMessage id='search_results.nothing_found' defaultMessage='Could not find anything for these search terms' /> | ||||
|           </div> | ||||
|         ) | ||||
|       )} | ||||
|       {accounts} | ||||
|       {hashtags} | ||||
|       {statuses} | ||||
|     </div> | ||||
|   ); | ||||
| 
 | ||||
| }; | ||||
| @ -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); | ||||
| @ -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 { | ||||
|             <a href='/auth/sign_out' className='drawer__tab' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)} onClick={this.handleLogoutClick}><Icon id='sign-out' icon={LogoutIcon} /></a> | ||||
|           </nav> | ||||
| 
 | ||||
|           {multiColumn && <SearchContainer /> } | ||||
|           {multiColumn && <Search /> } | ||||
| 
 | ||||
|           <div className='drawer__pager'> | ||||
|             <div className='drawer__inner' onFocus={this.onFocus}> | ||||
| @ -123,14 +117,6 @@ class Compose extends PureComponent { | ||||
|                 <img alt='' draggable='false' src={mascot || elephantUIPlane} /> | ||||
|               </div> | ||||
|             </div> | ||||
| 
 | ||||
|             <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}> | ||||
|               {({ x }) => ( | ||||
|                 <div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}> | ||||
|                   <SearchResults /> | ||||
|                 </div> | ||||
|               )} | ||||
|             </Motion> | ||||
|           </div> | ||||
|         </div> | ||||
|       ); | ||||
|  | ||||
| @ -1,20 +0,0 @@ | ||||
| import PropTypes from 'prop-types'; | ||||
| 
 | ||||
| import { FormattedMessage } from 'react-intl'; | ||||
| 
 | ||||
| export const SearchSection = ({ title, onClickMore, children }) => ( | ||||
|   <div className='search-results__section'> | ||||
|     <div className='search-results__section__header'> | ||||
|       <h3>{title}</h3> | ||||
|       {onClickMore && <button onClick={onClickMore}><FormattedMessage id='search_results.see_all' defaultMessage='See all' /></button>} | ||||
|     </div> | ||||
| 
 | ||||
|     {children} | ||||
|   </div> | ||||
| ); | ||||
| 
 | ||||
| SearchSection.propTypes = { | ||||
|   title: PropTypes.node.isRequired, | ||||
|   onClickMore: PropTypes.func, | ||||
|   children: PropTypes.children, | ||||
| }; | ||||
| @ -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 ( | ||||
|       <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}> | ||||
|         <ColumnHeader | ||||
|           icon={isSearching ? 'search' : 'explore'} | ||||
|           iconComponent={isSearching ? SearchIcon : ExploreIcon} | ||||
|           title={intl.formatMessage(isSearching ? messages.searchResults : messages.title)} | ||||
|           onClick={this.handleHeaderClick} | ||||
|           multiColumn={multiColumn} | ||||
|         /> | ||||
| 
 | ||||
|         <div className='explore__search-header'> | ||||
|           <Search /> | ||||
|         </div> | ||||
| 
 | ||||
|         {isSearching ? ( | ||||
|           <SearchResults /> | ||||
|         ) : ( | ||||
|           <> | ||||
|             <div className='account__section-headline'> | ||||
|               <NavLink exact to='/explore'> | ||||
|                 <FormattedMessage tagName='div' id='explore.trending_statuses' defaultMessage='Posts' /> | ||||
|               </NavLink> | ||||
| 
 | ||||
|               <NavLink exact to='/explore/tags'> | ||||
|                 <FormattedMessage tagName='div' id='explore.trending_tags' defaultMessage='Hashtags' /> | ||||
|               </NavLink> | ||||
| 
 | ||||
|               {signedIn && ( | ||||
|                 <NavLink exact to='/explore/suggestions'> | ||||
|                   <FormattedMessage tagName='div' id='explore.suggested_follows' defaultMessage='People' /> | ||||
|                 </NavLink> | ||||
|               )} | ||||
| 
 | ||||
|               <NavLink exact to='/explore/links'> | ||||
|                 <FormattedMessage tagName='div' id='explore.trending_links' defaultMessage='News' /> | ||||
|               </NavLink> | ||||
|             </div> | ||||
| 
 | ||||
|             <Switch> | ||||
|               <Route path='/explore/tags' component={Tags} /> | ||||
|               <Route path='/explore/links' component={Links} /> | ||||
|               <Route path='/explore/suggestions' component={Suggestions} /> | ||||
|               <Route exact path={['/explore', '/explore/posts', '/search']}> | ||||
|                 <Statuses multiColumn={multiColumn} /> | ||||
|               </Route> | ||||
|             </Switch> | ||||
| 
 | ||||
|             <Helmet> | ||||
|               <title>{intl.formatMessage(messages.title)}</title> | ||||
|               <meta name='robots' content={isSearching ? 'noindex' : 'all'} /> | ||||
|             </Helmet> | ||||
|           </> | ||||
|         )} | ||||
|       </Column> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| export default withIdentity(connect(mapStateToProps)(injectIntl(Explore))); | ||||
							
								
								
									
										105
									
								
								app/javascript/mastodon/features/explore/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								app/javascript/mastodon/features/explore/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,105 @@ | ||||
| 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 type { ColumnRef } 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<ColumnRef>(null); | ||||
| 
 | ||||
|   const handleHeaderClick = useCallback(() => { | ||||
|     columnRef.current?.scrollTop(); | ||||
|   }, []); | ||||
| 
 | ||||
|   return ( | ||||
|     <Column | ||||
|       bindToDocument={!multiColumn} | ||||
|       ref={columnRef} | ||||
|       label={intl.formatMessage(messages.title)} | ||||
|     > | ||||
|       <ColumnHeader | ||||
|         icon={'explore'} | ||||
|         iconComponent={ExploreIcon} | ||||
|         title={intl.formatMessage(messages.title)} | ||||
|         onClick={handleHeaderClick} | ||||
|         multiColumn={multiColumn} | ||||
|       /> | ||||
| 
 | ||||
|       <div className='explore__search-header'> | ||||
|         <Search singleColumn /> | ||||
|       </div> | ||||
| 
 | ||||
|       <div className='account__section-headline'> | ||||
|         <NavLink exact to='/explore'> | ||||
|           <FormattedMessage | ||||
|             tagName='div' | ||||
|             id='explore.trending_statuses' | ||||
|             defaultMessage='Posts' | ||||
|           /> | ||||
|         </NavLink> | ||||
| 
 | ||||
|         <NavLink exact to='/explore/tags'> | ||||
|           <FormattedMessage | ||||
|             tagName='div' | ||||
|             id='explore.trending_tags' | ||||
|             defaultMessage='Hashtags' | ||||
|           /> | ||||
|         </NavLink> | ||||
| 
 | ||||
|         {signedIn && ( | ||||
|           <NavLink exact to='/explore/suggestions'> | ||||
|             <FormattedMessage | ||||
|               tagName='div' | ||||
|               id='explore.suggested_follows' | ||||
|               defaultMessage='People' | ||||
|             /> | ||||
|           </NavLink> | ||||
|         )} | ||||
| 
 | ||||
|         <NavLink exact to='/explore/links'> | ||||
|           <FormattedMessage | ||||
|             tagName='div' | ||||
|             id='explore.trending_links' | ||||
|             defaultMessage='News' | ||||
|           /> | ||||
|         </NavLink> | ||||
|       </div> | ||||
| 
 | ||||
|       <Switch> | ||||
|         <Route path='/explore/tags' component={Tags} /> | ||||
|         <Route path='/explore/links' component={Links} /> | ||||
|         <Route path='/explore/suggestions' component={Suggestions} /> | ||||
|         <Route exact path={['/explore', '/explore/posts']}> | ||||
|           <Statuses multiColumn={multiColumn} /> | ||||
|         </Route> | ||||
|       </Switch> | ||||
| 
 | ||||
|       <Helmet> | ||||
|         <title>{intl.formatMessage(messages.title)}</title> | ||||
|         <meta name='robots' content='all' /> | ||||
|       </Helmet> | ||||
|     </Column> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| // eslint-disable-next-line import/no-default-export
 | ||||
| export default Explore; | ||||
| @ -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 { Account } from 'mastodon/components/account'; | ||||
| import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag'; | ||||
| import { Icon } from 'mastodon/components/icon'; | ||||
| import ScrollableList from 'mastodon/components/scrollable_list'; | ||||
| 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 => ( | ||||
|   <Account key={id} id={id} /> | ||||
| )); | ||||
| 
 | ||||
| const renderHashtags = hashtags => hidePeek(hashtags).map(hashtag => ( | ||||
|   <Hashtag key={hashtag.get('name')} hashtag={hashtag} /> | ||||
| )); | ||||
| 
 | ||||
| const renderStatuses = statuses => hidePeek(statuses).map(id => ( | ||||
|   <Status key={id} id={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 && ( | ||||
|             <SearchSection key='accounts' title={<><Icon id='users' icon={PeopleIcon} /><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></>} onClickMore={this.handleLoadMoreAccounts}> | ||||
|               {accounts.take(INITIAL_DISPLAY).map(id => <Account key={id} id={id} />)} | ||||
|             </SearchSection> | ||||
|           )} | ||||
| 
 | ||||
|           {hashtags.size > 0 && ( | ||||
|             <SearchSection key='hashtags' title={<><Icon id='hashtag' icon={TagIcon} /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></>} onClickMore={this.handleLoadMoreHashtags}> | ||||
|               {hashtags.take(INITIAL_DISPLAY).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)} | ||||
|             </SearchSection> | ||||
|           )} | ||||
| 
 | ||||
|           {statuses.size > 0 && ( | ||||
|             <SearchSection key='statuses' title={<><Icon id='quote-right' icon={FindInPageIcon} /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></>} onClickMore={this.handleLoadMoreStatuses}> | ||||
|               {statuses.take(INITIAL_DISPLAY).map(id => <Status key={id} id={id} />)} | ||||
|             </SearchSection> | ||||
|           )} | ||||
|         </> | ||||
|       ) : []; | ||||
|       break; | ||||
|     case 'accounts': | ||||
|       filteredResults = renderAccounts(accounts); | ||||
|       break; | ||||
|     case 'hashtags': | ||||
|       filteredResults = renderHashtags(hashtags); | ||||
|       break; | ||||
|     case 'statuses': | ||||
|       filteredResults = renderStatuses(statuses); | ||||
|       break; | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|       <> | ||||
|         <div className='account__section-headline'> | ||||
|           <button onClick={this.handleSelectAll} className={type === 'all' ? 'active' : undefined}><FormattedMessage id='search_results.all' defaultMessage='All' /></button> | ||||
|           <button onClick={this.handleSelectAccounts} className={type === 'accounts' ? 'active' : undefined}><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></button> | ||||
|           <button onClick={this.handleSelectHashtags} className={type === 'hashtags' ? 'active' : undefined}><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></button> | ||||
|           <button onClick={this.handleSelectStatuses} className={type === 'statuses' ? 'active' : undefined}><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></button> | ||||
|         </div> | ||||
| 
 | ||||
|         <div className='explore__search-results' data-nosnippet> | ||||
|           <ScrollableList | ||||
|             scrollKey='search-results' | ||||
|             isLoading={isLoading} | ||||
|             onLoadMore={this.handleLoadMore} | ||||
|             hasMore={hasMore} | ||||
|             emptyMessage={<FormattedMessage id='search_results.nothing_found' defaultMessage='Could not find anything for these search terms' />} | ||||
|             bindToDocument | ||||
|           > | ||||
|             {filteredResults} | ||||
|           </ScrollableList> | ||||
|         </div> | ||||
| 
 | ||||
|         <Helmet> | ||||
|           <title>{intl.formatMessage(messages.title, { q })}</title> | ||||
|         </Helmet> | ||||
|       </> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| export default connect(mapStateToProps)(injectIntl(Results)); | ||||
| @ -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 }) => ( | ||||
|   <div className='search-results__section'> | ||||
|     <div className='search-results__section__header'> | ||||
|       <h3>{title}</h3> | ||||
|       {onClickMore && ( | ||||
|         <button onClick={onClickMore}> | ||||
|           <FormattedMessage | ||||
|             id='search_results.see_all' | ||||
|             defaultMessage='See all' | ||||
|           /> | ||||
|         </button> | ||||
|       )} | ||||
|     </div> | ||||
| 
 | ||||
|     {children} | ||||
|   </div> | ||||
| ); | ||||
							
								
								
									
										304
									
								
								app/javascript/mastodon/features/search/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										304
									
								
								app/javascript/mastodon/features/search/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,304 @@ | ||||
| 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 { Account } from 'mastodon/components/account'; | ||||
| import { Column } from 'mastodon/components/column'; | ||||
| import type { ColumnRef } 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 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 = <T,>(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<string>(accountIds).map((id) => <Account key={id} id={id} />); | ||||
| 
 | ||||
| const renderHashtags = (hashtags: HashtagType[]) => | ||||
|   hidePeek<HashtagType>(hashtags).map((hashtag) => ( | ||||
|     <Hashtag key={hashtag.name} hashtag={hashtag} /> | ||||
|   )); | ||||
| 
 | ||||
| const renderStatuses = (statusIds: string[]) => | ||||
|   hidePeek<string>(statusIds).map((id) => ( | ||||
|     // @ts-expect-error inferred props are wrong
 | ||||
|     <Status key={id} id={id} /> | ||||
|   )); | ||||
| 
 | ||||
| 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<ColumnRef>(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); | ||||
|   const trimmedValue = q?.trim() ?? ''; | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (trimmedValue.length > 0) { | ||||
|       void dispatch( | ||||
|         submitSearch({ | ||||
|           q: trimmedValue, | ||||
|           type: mappedType === 'all' ? undefined : mappedType, | ||||
|         }), | ||||
|       ); | ||||
|     } | ||||
|   }, [dispatch, trimmedValue, 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 && ( | ||||
|                 <SearchSection | ||||
|                   key='accounts' | ||||
|                   title={ | ||||
|                     <> | ||||
|                       <Icon id='users' icon={PeopleIcon} /> | ||||
|                       <FormattedMessage | ||||
|                         id='search_results.accounts' | ||||
|                         defaultMessage='Profiles' | ||||
|                       /> | ||||
|                     </> | ||||
|                   } | ||||
|                   onClickMore={handleSelectAccounts} | ||||
|                 > | ||||
|                   {results.accounts.slice(0, INITIAL_DISPLAY).map((id) => ( | ||||
|                     <Account key={id} id={id} /> | ||||
|                   ))} | ||||
|                 </SearchSection> | ||||
|               )} | ||||
| 
 | ||||
|               {results.hashtags.length > 0 && ( | ||||
|                 <SearchSection | ||||
|                   key='hashtags' | ||||
|                   title={ | ||||
|                     <> | ||||
|                       <Icon id='hashtag' icon={TagIcon} /> | ||||
|                       <FormattedMessage | ||||
|                         id='search_results.hashtags' | ||||
|                         defaultMessage='Hashtags' | ||||
|                       /> | ||||
|                     </> | ||||
|                   } | ||||
|                   onClickMore={handleSelectHashtags} | ||||
|                 > | ||||
|                   {results.hashtags.slice(0, INITIAL_DISPLAY).map((hashtag) => ( | ||||
|                     <Hashtag key={hashtag.name} hashtag={hashtag} /> | ||||
|                   ))} | ||||
|                 </SearchSection> | ||||
|               )} | ||||
| 
 | ||||
|               {results.statuses.length > 0 && ( | ||||
|                 <SearchSection | ||||
|                   key='statuses' | ||||
|                   title={ | ||||
|                     <> | ||||
|                       <Icon id='quote-right' icon={FindInPageIcon} /> | ||||
|                       <FormattedMessage | ||||
|                         id='search_results.statuses' | ||||
|                         defaultMessage='Posts' | ||||
|                       /> | ||||
|                     </> | ||||
|                   } | ||||
|                   onClickMore={handleSelectStatuses} | ||||
|                 > | ||||
|                   {results.statuses.slice(0, INITIAL_DISPLAY).map((id) => ( | ||||
|                     // @ts-expect-error inferred props are wrong
 | ||||
|                     <Status key={id} id={id} /> | ||||
|                   ))} | ||||
|                 </SearchSection> | ||||
|               )} | ||||
|             </> | ||||
|           ) : ( | ||||
|             [] | ||||
|           ); | ||||
|         break; | ||||
|       case 'accounts': | ||||
|         filteredResults = renderAccounts(results.accounts); | ||||
|         break; | ||||
|       case 'hashtags': | ||||
|         filteredResults = renderHashtags(results.hashtags); | ||||
|         break; | ||||
|       case 'statuses': | ||||
|         filteredResults = renderStatuses(results.statuses); | ||||
|         break; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <Column | ||||
|       bindToDocument={!multiColumn} | ||||
|       ref={columnRef} | ||||
|       label={intl.formatMessage(messages.title, { q })} | ||||
|     > | ||||
|       <ColumnHeader | ||||
|         icon={'search'} | ||||
|         iconComponent={SearchIcon} | ||||
|         title={intl.formatMessage(messages.title, { q })} | ||||
|         onClick={handleHeaderClick} | ||||
|         multiColumn={multiColumn} | ||||
|       /> | ||||
| 
 | ||||
|       <div className='explore__search-header'> | ||||
|         <Search singleColumn initialValue={trimmedValue} /> | ||||
|       </div> | ||||
| 
 | ||||
|       <div className='account__section-headline'> | ||||
|         <button | ||||
|           onClick={handleSelectAll} | ||||
|           className={mappedType === 'all' ? 'active' : undefined} | ||||
|         > | ||||
|           <FormattedMessage id='search_results.all' defaultMessage='All' /> | ||||
|         </button> | ||||
|         <button | ||||
|           onClick={handleSelectAccounts} | ||||
|           className={mappedType === 'accounts' ? 'active' : undefined} | ||||
|         > | ||||
|           <FormattedMessage | ||||
|             id='search_results.accounts' | ||||
|             defaultMessage='Profiles' | ||||
|           /> | ||||
|         </button> | ||||
|         <button | ||||
|           onClick={handleSelectHashtags} | ||||
|           className={mappedType === 'hashtags' ? 'active' : undefined} | ||||
|         > | ||||
|           <FormattedMessage | ||||
|             id='search_results.hashtags' | ||||
|             defaultMessage='Hashtags' | ||||
|           /> | ||||
|         </button> | ||||
|         <button | ||||
|           onClick={handleSelectStatuses} | ||||
|           className={mappedType === 'statuses' ? 'active' : undefined} | ||||
|         > | ||||
|           <FormattedMessage | ||||
|             id='search_results.statuses' | ||||
|             defaultMessage='Posts' | ||||
|           /> | ||||
|         </button> | ||||
|       </div> | ||||
| 
 | ||||
|       <div className='explore__search-results' data-nosnippet> | ||||
|         <ScrollableList | ||||
|           scrollKey='search-results' | ||||
|           isLoading={isLoading} | ||||
|           showLoading={isLoading && !results} | ||||
|           onLoadMore={handleLoadMore} | ||||
|           hasMore={hasMore} | ||||
|           emptyMessage={ | ||||
|             trimmedValue.length > 0 ? ( | ||||
|               <FormattedMessage | ||||
|                 id='search_results.no_results' | ||||
|                 defaultMessage='No results.' | ||||
|               /> | ||||
|             ) : ( | ||||
|               <FormattedMessage | ||||
|                 id='search_results.no_search_yet' | ||||
|                 defaultMessage='Try searching for posts, profiles or hashtags.' | ||||
|               /> | ||||
|             ) | ||||
|           } | ||||
|           bindToDocument | ||||
|         > | ||||
|           {filteredResults} | ||||
|         </ScrollableList> | ||||
|       </div> | ||||
| 
 | ||||
|       <Helmet> | ||||
|         <title>{intl.formatMessage(messages.title, { q })}</title> | ||||
|         <meta name='robots' content='noindex' /> | ||||
|       </Helmet> | ||||
|     </Column> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| // eslint-disable-next-line import/no-default-export
 | ||||
| export default SearchResults; | ||||
| @ -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 { LinkFooter } from 'mastodon/features/ui/components/link_footer'; | ||||
| import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; | ||||
| 
 | ||||
| @ -41,7 +41,7 @@ class ComposePanel extends PureComponent { | ||||
| 
 | ||||
|     return ( | ||||
|       <div className='compose-panel' onFocus={this.onFocus}> | ||||
|         <SearchContainer openInRoute /> | ||||
|         <Search openInRoute /> | ||||
| 
 | ||||
|         {!signedIn && ( | ||||
|           <> | ||||
|  | ||||
| @ -69,6 +69,7 @@ import { | ||||
|   OnboardingProfile, | ||||
|   OnboardingFollows, | ||||
|   Explore, | ||||
|   Search, | ||||
|   About, | ||||
|   PrivacyPolicy, | ||||
|   TermsOfService, | ||||
| @ -225,7 +226,8 @@ class SwitchingColumnsArea extends PureComponent { | ||||
|             <WrappedRoute path={['/start', '/start/profile']} exact component={OnboardingProfile} content={children} /> | ||||
|             <WrappedRoute path='/start/follows' component={OnboardingFollows} content={children} /> | ||||
|             <WrappedRoute path='/directory' component={Directory} content={children} /> | ||||
|             <WrappedRoute path={['/explore', '/search']} component={Explore} content={children} /> | ||||
|             <WrappedRoute path='/explore' component={Explore} content={children} /> | ||||
|             <WrappedRoute path='/search' component={Search} content={children} /> | ||||
|             <WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} /> | ||||
| 
 | ||||
|             <WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} /> | ||||
|  | ||||
| @ -174,6 +174,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'); | ||||
| } | ||||
|  | ||||
| @ -309,7 +309,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", | ||||
| @ -783,10 +782,11 @@ | ||||
|   "search_results.accounts": "Profiles", | ||||
|   "search_results.all": "All", | ||||
|   "search_results.hashtags": "Hashtags", | ||||
|   "search_results.nothing_found": "Could not find anything for these search terms", | ||||
|   "search_results.no_results": "No results.", | ||||
|   "search_results.no_search_yet": "Try searching for posts, profiles or hashtags.", | ||||
|   "search_results.see_all": "See all", | ||||
|   "search_results.statuses": "Posts", | ||||
|   "search_results.title": "Search for {q}", | ||||
|   "search_results.title": "Search for \"{q}\"", | ||||
|   "server_banner.about_active_users": "People using this server during the last 30 days (Monthly Active Users)", | ||||
|   "server_banner.active_users": "active users", | ||||
|   "server_banner.administered_by": "Administered by:", | ||||
|  | ||||
							
								
								
									
										21
									
								
								app/javascript/mastodon/models/search.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								app/javascript/mastodon/models/search.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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, | ||||
| }); | ||||
| @ -30,7 +30,7 @@ import { pictureInPictureReducer } from './picture_in_picture'; | ||||
| import { pollsReducer } 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'; | ||||
| @ -60,7 +60,7 @@ const reducers = { | ||||
|   server, | ||||
|   contexts, | ||||
|   compose, | ||||
|   search, | ||||
|   search: searchReducer, | ||||
|   media_attachments, | ||||
|   notifications, | ||||
|   notificationGroups: notificationGroupsReducer, | ||||
|  | ||||
| @ -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; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										74
									
								
								app/javascript/mastodon/reducers/search.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								app/javascript/mastodon/reducers/search.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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; | ||||
|     }, | ||||
|   ); | ||||
| }); | ||||
| @ -3017,7 +3017,9 @@ $ui-header-logo-wordmark-width: 99px; | ||||
| 
 | ||||
|     .column > .scrollable, | ||||
|     .tabs-bar__wrapper .column-header, | ||||
|     .tabs-bar__wrapper .column-back-button { | ||||
|     .tabs-bar__wrapper .column-back-button, | ||||
|     .explore__search-header, | ||||
|     .column-search-header { | ||||
|       border-left: 0; | ||||
|       border-right: 0; | ||||
|     } | ||||
| @ -3060,10 +3062,6 @@ $ui-header-logo-wordmark-width: 99px; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .explore__search-header { | ||||
|   display: none; | ||||
| } | ||||
| 
 | ||||
| .explore__suggestions__card { | ||||
|   padding: 12px 16px; | ||||
|   gap: 8px; | ||||
| @ -3137,10 +3135,6 @@ $ui-header-logo-wordmark-width: 99px; | ||||
|   .columns-area__panels__pane--compositional { | ||||
|     display: none; | ||||
|   } | ||||
| 
 | ||||
|   .explore__search-header { | ||||
|     display: flex; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .icon-with-badge { | ||||
| @ -5446,6 +5440,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; | ||||
|   } | ||||
| @ -5457,17 +5462,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; | ||||
| @ -8645,6 +8647,9 @@ noscript { | ||||
| .explore__search-header { | ||||
|   justify-content: center; | ||||
|   align-items: center; | ||||
|   border: 1px solid var(--background-border-color); | ||||
|   border-top: 0; | ||||
|   border-bottom: 0; | ||||
|   padding: 16px; | ||||
|   padding-bottom: 8px; | ||||
| 
 | ||||
| @ -8663,13 +8668,21 @@ noscript { | ||||
|     border: 1px solid var(--background-border-color); | ||||
|   } | ||||
| 
 | ||||
|   .search .icon { | ||||
|   .search__icon { | ||||
|     top: 12px; | ||||
|     inset-inline-end: 12px; | ||||
|     color: $dark-text-color; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .layout-single-column .explore__search-header { | ||||
|   display: none; | ||||
| 
 | ||||
|   @media screen and (max-width: $no-gap-breakpoint - 1px) { | ||||
|     display: flex; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .explore__search-results { | ||||
|   flex: 1 1 auto; | ||||
|   display: flex; | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user