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 { useHistory } from 'react-router-dom'; | ||||||
| 
 | 
 | ||||||
|  | import { isFulfilled, isRejected } from '@reduxjs/toolkit'; | ||||||
|  | 
 | ||||||
| import { openURL } from 'mastodon/actions/search'; | import { openURL } from 'mastodon/actions/search'; | ||||||
| import { useAppDispatch } from 'mastodon/store'; | import { useAppDispatch } from 'mastodon/store'; | ||||||
| 
 | 
 | ||||||
| @ -28,12 +30,22 @@ export const useLinks = () => { | |||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
|   const handleMentionClick = useCallback( |   const handleMentionClick = useCallback( | ||||||
|     (element: HTMLAnchorElement) => { |     async (element: HTMLAnchorElement) => { | ||||||
|       dispatch( |       const result = await dispatch(openURL({ url: element.href })); | ||||||
|         openURL(element.href, history, () => { | 
 | ||||||
|  |       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; |           window.location.href = element.href; | ||||||
|         }), |         } | ||||||
|       ); |       } else if (isRejected(result)) { | ||||||
|  |         window.location.href = element.href; | ||||||
|  |       } | ||||||
|     }, |     }, | ||||||
|     [dispatch, history], |     [dispatch, history], | ||||||
|   ); |   ); | ||||||
| @ -48,7 +60,7 @@ export const useLinks = () => { | |||||||
| 
 | 
 | ||||||
|       if (isMentionClick(target)) { |       if (isMentionClick(target)) { | ||||||
|         e.preventDefault(); |         e.preventDefault(); | ||||||
|         handleMentionClick(target); |         void handleMentionClick(target); | ||||||
|       } else if (isHashtagClick(target)) { |       } else if (isHashtagClick(target)) { | ||||||
|         e.preventDefault(); |         e.preventDefault(); | ||||||
|         handleHashtagClick(target); |         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 { ShortNumber } from 'mastodon/components/short_number'; | ||||||
| import { Skeleton } from 'mastodon/components/skeleton'; | import { Skeleton } from 'mastodon/components/skeleton'; | ||||||
|  | import type { Hashtag as HashtagType } from 'mastodon/models/tags'; | ||||||
| 
 | 
 | ||||||
| interface SilentErrorBoundaryProps { | interface SilentErrorBoundaryProps { | ||||||
|   children: React.ReactNode; |   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 { | export interface HashtagProps { | ||||||
|   className?: string; |   className?: string; | ||||||
|   description?: React.ReactNode; |   description?: React.ReactNode; | ||||||
|  | |||||||
| @ -6,6 +6,7 @@ import classNames from 'classnames'; | |||||||
| import { Helmet } from 'react-helmet'; | import { Helmet } from 'react-helmet'; | ||||||
| import { NavLink, withRouter } from 'react-router-dom'; | import { NavLink, withRouter } from 'react-router-dom'; | ||||||
| 
 | 
 | ||||||
|  | import { isFulfilled, isRejected } from '@reduxjs/toolkit'; | ||||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
| 
 | 
 | ||||||
| @ -215,8 +216,20 @@ class Header extends ImmutablePureComponent { | |||||||
| 
 | 
 | ||||||
|       const link = e.currentTarget; |       const link = e.currentTarget; | ||||||
| 
 | 
 | ||||||
|       onOpenURL(link.href, history, () => { |       onOpenURL(link.href).then((result) => { | ||||||
|         window.location = link.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 = link.href; | ||||||
|  |           } | ||||||
|  |         } else if (isRejected(result)) { | ||||||
|  |           window.location = link.href; | ||||||
|  |         } | ||||||
|  |       }).catch(() => { | ||||||
|  |         // Nothing | ||||||
|       }); |       }); | ||||||
|     } |     } | ||||||
|   }; |   }; | ||||||
|  | |||||||
| @ -144,8 +144,8 @@ const mapDispatchToProps = (dispatch) => ({ | |||||||
|     })); |     })); | ||||||
|   }, |   }, | ||||||
| 
 | 
 | ||||||
|   onOpenURL (url, routerHistory, onFailure) { |   onOpenURL (url) { | ||||||
|     dispatch(openURL(url, routerHistory, onFailure)); |     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 ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| import { connect } from 'react-redux'; | import { connect } from 'react-redux'; | ||||||
| 
 | 
 | ||||||
| import spring from 'react-motion/lib/spring'; |  | ||||||
| 
 |  | ||||||
| import PeopleIcon from '@/material-icons/400-24px/group.svg?react'; | import PeopleIcon from '@/material-icons/400-24px/group.svg?react'; | ||||||
| import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react'; | import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react'; | ||||||
| import LogoutIcon from '@/material-icons/400-24px/logout.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 { changeComposing, mountCompose, unmountCompose } from '../../actions/compose'; | ||||||
| import { mascot } from '../../initial_state'; | import { mascot } from '../../initial_state'; | ||||||
| import { isMobile } from '../../is_mobile'; | 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 ComposeFormContainer from './containers/compose_form_container'; | ||||||
| import SearchContainer from './containers/search_container'; |  | ||||||
| 
 | 
 | ||||||
| const messages = defineMessages({ | const messages = defineMessages({ | ||||||
|   start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, |   start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, | ||||||
| @ -43,9 +39,8 @@ const messages = defineMessages({ | |||||||
|   compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' }, |   compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' }, | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| const mapStateToProps = (state, ownProps) => ({ | const mapStateToProps = (state) => ({ | ||||||
|   columns: state.getIn(['settings', 'columns']), |   columns: state.getIn(['settings', 'columns']), | ||||||
|   showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : false, |  | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| class Compose extends PureComponent { | class Compose extends PureComponent { | ||||||
| @ -54,7 +49,6 @@ class Compose extends PureComponent { | |||||||
|     dispatch: PropTypes.func.isRequired, |     dispatch: PropTypes.func.isRequired, | ||||||
|     columns: ImmutablePropTypes.list.isRequired, |     columns: ImmutablePropTypes.list.isRequired, | ||||||
|     multiColumn: PropTypes.bool, |     multiColumn: PropTypes.bool, | ||||||
|     showSearch: PropTypes.bool, |  | ||||||
|     intl: PropTypes.object.isRequired, |     intl: PropTypes.object.isRequired, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
| @ -88,7 +82,7 @@ class Compose extends PureComponent { | |||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   render () { |   render () { | ||||||
|     const { multiColumn, showSearch, intl } = this.props; |     const { multiColumn, intl } = this.props; | ||||||
| 
 | 
 | ||||||
|     if (multiColumn) { |     if (multiColumn) { | ||||||
|       const { columns } = this.props; |       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> |             <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> |           </nav> | ||||||
| 
 | 
 | ||||||
|           {multiColumn && <SearchContainer /> } |           {multiColumn && <Search /> } | ||||||
| 
 | 
 | ||||||
|           <div className='drawer__pager'> |           <div className='drawer__pager'> | ||||||
|             <div className='drawer__inner' onFocus={this.onFocus}> |             <div className='drawer__inner' onFocus={this.onFocus}> | ||||||
| @ -123,14 +117,6 @@ class Compose extends PureComponent { | |||||||
|                 <img alt='' draggable='false' src={mascot || elephantUIPlane} /> |                 <img alt='' draggable='false' src={mascot || elephantUIPlane} /> | ||||||
|               </div> |               </div> | ||||||
|             </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> | ||||||
|         </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 { changeComposing, mountCompose, unmountCompose } from 'mastodon/actions/compose'; | ||||||
| import ServerBanner from 'mastodon/components/server_banner'; | 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 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 { LinkFooter } from 'mastodon/features/ui/components/link_footer'; | ||||||
| import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; | import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; | ||||||
| 
 | 
 | ||||||
| @ -41,7 +41,7 @@ class ComposePanel extends PureComponent { | |||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|       <div className='compose-panel' onFocus={this.onFocus}> |       <div className='compose-panel' onFocus={this.onFocus}> | ||||||
|         <SearchContainer openInRoute /> |         <Search openInRoute /> | ||||||
| 
 | 
 | ||||||
|         {!signedIn && ( |         {!signedIn && ( | ||||||
|           <> |           <> | ||||||
|  | |||||||
| @ -69,6 +69,7 @@ import { | |||||||
|   OnboardingProfile, |   OnboardingProfile, | ||||||
|   OnboardingFollows, |   OnboardingFollows, | ||||||
|   Explore, |   Explore, | ||||||
|  |   Search, | ||||||
|   About, |   About, | ||||||
|   PrivacyPolicy, |   PrivacyPolicy, | ||||||
|   TermsOfService, |   TermsOfService, | ||||||
| @ -225,7 +226,8 @@ class SwitchingColumnsArea extends PureComponent { | |||||||
|             <WrappedRoute path={['/start', '/start/profile']} exact component={OnboardingProfile} content={children} /> |             <WrappedRoute path={['/start', '/start/profile']} exact component={OnboardingProfile} content={children} /> | ||||||
|             <WrappedRoute path='/start/follows' component={OnboardingFollows} content={children} /> |             <WrappedRoute path='/start/follows' component={OnboardingFollows} content={children} /> | ||||||
|             <WrappedRoute path='/directory' component={Directory} 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={['/publish', '/statuses/new']} component={Compose} content={children} /> | ||||||
| 
 | 
 | ||||||
|             <WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} 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'); |   return import(/* webpackChunkName: "features/explore" */'../../explore'); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | export function Search () { | ||||||
|  |   return import(/* webpackChunkName: "features/explore" */'../../search'); | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export function FilterModal () { | export function FilterModal () { | ||||||
|   return import(/*webpackChunkName: "modals/filter_modal" */'../components/filter_modal'); |   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.", |   "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.copy_stacktrace": "Copy stacktrace to clipboard", | ||||||
|   "errors.unexpected_crash.report_issue": "Report issue", |   "errors.unexpected_crash.report_issue": "Report issue", | ||||||
|   "explore.search_results": "Search results", |  | ||||||
|   "explore.suggested_follows": "People", |   "explore.suggested_follows": "People", | ||||||
|   "explore.title": "Explore", |   "explore.title": "Explore", | ||||||
|   "explore.trending_links": "News", |   "explore.trending_links": "News", | ||||||
| @ -783,10 +782,11 @@ | |||||||
|   "search_results.accounts": "Profiles", |   "search_results.accounts": "Profiles", | ||||||
|   "search_results.all": "All", |   "search_results.all": "All", | ||||||
|   "search_results.hashtags": "Hashtags", |   "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.see_all": "See all", | ||||||
|   "search_results.statuses": "Posts", |   "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.about_active_users": "People using this server during the last 30 days (Monthly Active Users)", | ||||||
|   "server_banner.active_users": "active users", |   "server_banner.active_users": "active users", | ||||||
|   "server_banner.administered_by": "Administered by:", |   "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 { pollsReducer } from './polls'; | ||||||
| import push_notifications from './push_notifications'; | import push_notifications from './push_notifications'; | ||||||
| import { relationshipsReducer } from './relationships'; | import { relationshipsReducer } from './relationships'; | ||||||
| import search from './search'; | import { searchReducer } from './search'; | ||||||
| import server from './server'; | import server from './server'; | ||||||
| import settings from './settings'; | import settings from './settings'; | ||||||
| import status_lists from './status_lists'; | import status_lists from './status_lists'; | ||||||
| @ -60,7 +60,7 @@ const reducers = { | |||||||
|   server, |   server, | ||||||
|   contexts, |   contexts, | ||||||
|   compose, |   compose, | ||||||
|   search, |   search: searchReducer, | ||||||
|   media_attachments, |   media_attachments, | ||||||
|   notifications, |   notifications, | ||||||
|   notificationGroups: notificationGroupsReducer, |   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, |     .column > .scrollable, | ||||||
|     .tabs-bar__wrapper .column-header, |     .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-left: 0; | ||||||
|       border-right: 0; |       border-right: 0; | ||||||
|     } |     } | ||||||
| @ -3060,10 +3062,6 @@ $ui-header-logo-wordmark-width: 99px; | |||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .explore__search-header { |  | ||||||
|   display: none; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| .explore__suggestions__card { | .explore__suggestions__card { | ||||||
|   padding: 12px 16px; |   padding: 12px 16px; | ||||||
|   gap: 8px; |   gap: 8px; | ||||||
| @ -3137,10 +3135,6 @@ $ui-header-logo-wordmark-width: 99px; | |||||||
|   .columns-area__panels__pane--compositional { |   .columns-area__panels__pane--compositional { | ||||||
|     display: none; |     display: none; | ||||||
|   } |   } | ||||||
| 
 |  | ||||||
|   .explore__search-header { |  | ||||||
|     display: flex; |  | ||||||
|   } |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .icon-with-badge { | .icon-with-badge { | ||||||
| @ -5446,6 +5440,17 @@ a.status-card { | |||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .search__icon { | .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 { |   &::-moz-focus-inner { | ||||||
|     border: 0; |     border: 0; | ||||||
|   } |   } | ||||||
| @ -5457,17 +5462,14 @@ a.status-card { | |||||||
| 
 | 
 | ||||||
|   .icon { |   .icon { | ||||||
|     position: absolute; |     position: absolute; | ||||||
|     top: 12px + 2px; |     top: 0; | ||||||
|     display: inline-block; |     inset-inline-start: 0; | ||||||
|     opacity: 0; |     opacity: 0; | ||||||
|     transition: all 100ms linear; |     transition: all 100ms linear; | ||||||
|     transition-property: transform, opacity; |     transition-property: transform, opacity; | ||||||
|     width: 20px; |     width: 20px; | ||||||
|     height: 20px; |     height: 20px; | ||||||
|     color: $darker-text-color; |     color: $darker-text-color; | ||||||
|     cursor: default; |  | ||||||
|     pointer-events: none; |  | ||||||
|     margin-inline-start: 16px - 2px; |  | ||||||
| 
 | 
 | ||||||
|     &.active { |     &.active { | ||||||
|       pointer-events: auto; |       pointer-events: auto; | ||||||
| @ -8645,6 +8647,9 @@ noscript { | |||||||
| .explore__search-header { | .explore__search-header { | ||||||
|   justify-content: center; |   justify-content: center; | ||||||
|   align-items: center; |   align-items: center; | ||||||
|  |   border: 1px solid var(--background-border-color); | ||||||
|  |   border-top: 0; | ||||||
|  |   border-bottom: 0; | ||||||
|   padding: 16px; |   padding: 16px; | ||||||
|   padding-bottom: 8px; |   padding-bottom: 8px; | ||||||
| 
 | 
 | ||||||
| @ -8663,13 +8668,21 @@ noscript { | |||||||
|     border: 1px solid var(--background-border-color); |     border: 1px solid var(--background-border-color); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   .search .icon { |   .search__icon { | ||||||
|     top: 12px; |     top: 12px; | ||||||
|     inset-inline-end: 12px; |     inset-inline-end: 12px; | ||||||
|     color: $dark-text-color; |     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 { | .explore__search-results { | ||||||
|   flex: 1 1 auto; |   flex: 1 1 auto; | ||||||
|   display: flex; |   display: flex; | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user