Add infinite scrolling for search results in web UI (#26784)
This commit is contained in:
		
					parent
					
						
							
								548c032dbb
							
						
					
				
			
			
				commit
				
					
						5d20733d8d
					
				
			
		
					 7 changed files with 255 additions and 171 deletions
				
			
		|  | @ -37,17 +37,17 @@ export function submitSearch(type) { | |||
|     const signedIn = !!getState().getIn(['meta', 'me']); | ||||
| 
 | ||||
|     if (value.length === 0) { | ||||
|       dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, '')); | ||||
|       dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, '', type)); | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     dispatch(fetchSearchRequest()); | ||||
|     dispatch(fetchSearchRequest(type)); | ||||
| 
 | ||||
|     api(getState).get('/api/v2/search', { | ||||
|       params: { | ||||
|         q: value, | ||||
|         resolve: signedIn, | ||||
|         limit: 5, | ||||
|         limit: 11, | ||||
|         type, | ||||
|       }, | ||||
|     }).then(response => { | ||||
|  | @ -59,7 +59,7 @@ export function submitSearch(type) { | |||
|         dispatch(importFetchedStatuses(response.data.statuses)); | ||||
|       } | ||||
| 
 | ||||
|       dispatch(fetchSearchSuccess(response.data, value)); | ||||
|       dispatch(fetchSearchSuccess(response.data, value, type)); | ||||
|       dispatch(fetchRelationships(response.data.accounts.map(item => item.id))); | ||||
|     }).catch(error => { | ||||
|       dispatch(fetchSearchFail(error)); | ||||
|  | @ -67,16 +67,18 @@ export function submitSearch(type) { | |||
|   }; | ||||
| } | ||||
| 
 | ||||
| export function fetchSearchRequest() { | ||||
| export function fetchSearchRequest(searchType) { | ||||
|   return { | ||||
|     type: SEARCH_FETCH_REQUEST, | ||||
|     searchType, | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| export function fetchSearchSuccess(results, searchTerm) { | ||||
| export function fetchSearchSuccess(results, searchTerm, searchType) { | ||||
|   return { | ||||
|     type: SEARCH_FETCH_SUCCESS, | ||||
|     results, | ||||
|     searchType, | ||||
|     searchTerm, | ||||
|   }; | ||||
| } | ||||
|  | @ -90,15 +92,16 @@ export function fetchSearchFail(error) { | |||
| 
 | ||||
| export const expandSearch = type => (dispatch, getState) => { | ||||
|   const value  = getState().getIn(['search', 'value']); | ||||
|   const offset = getState().getIn(['search', 'results', type]).size; | ||||
|   const offset = getState().getIn(['search', 'results', type]).size - 1; | ||||
| 
 | ||||
|   dispatch(expandSearchRequest()); | ||||
|   dispatch(expandSearchRequest(type)); | ||||
| 
 | ||||
|   api(getState).get('/api/v2/search', { | ||||
|     params: { | ||||
|       q: value, | ||||
|       type, | ||||
|       offset, | ||||
|       limit: 11, | ||||
|     }, | ||||
|   }).then(({ data }) => { | ||||
|     if (data.accounts) { | ||||
|  | @ -116,8 +119,9 @@ export const expandSearch = type => (dispatch, getState) => { | |||
|   }); | ||||
| }; | ||||
| 
 | ||||
| export const expandSearchRequest = () => ({ | ||||
| export const expandSearchRequest = (searchType) => ({ | ||||
|   type: SEARCH_EXPAND_REQUEST, | ||||
|   searchType, | ||||
| }); | ||||
| 
 | ||||
| export const expandSearchSuccess = (results, searchTerm, searchType) => ({ | ||||
|  |  | |||
|  | @ -1,46 +1,36 @@ | |||
| import PropTypes from 'prop-types'; | ||||
| 
 | ||||
| import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; | ||||
| import { FormattedMessage } from 'react-intl'; | ||||
| 
 | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| 
 | ||||
| import { Icon }  from 'mastodon/components/icon'; | ||||
| import { LoadMore } from 'mastodon/components/load_more'; | ||||
| import { SearchSection } from 'mastodon/features/explore/components/search_section'; | ||||
| 
 | ||||
| import { ImmutableHashtag as Hashtag } from '../../../components/hashtag'; | ||||
| import AccountContainer from '../../../containers/account_container'; | ||||
| import StatusContainer from '../../../containers/status_container'; | ||||
| import { searchEnabled } from '../../../initial_state'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' }, | ||||
| }); | ||||
| 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; | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| class SearchResults extends ImmutablePureComponent { | ||||
| 
 | ||||
|   static propTypes = { | ||||
|     results: ImmutablePropTypes.map.isRequired, | ||||
|     suggestions: ImmutablePropTypes.list.isRequired, | ||||
|     fetchSuggestions: PropTypes.func.isRequired, | ||||
|     expandSearch: PropTypes.func.isRequired, | ||||
|     dismissSuggestion: PropTypes.func.isRequired, | ||||
|     searchTerm: PropTypes.string, | ||||
|     intl: PropTypes.object.isRequired, | ||||
|   }; | ||||
| 
 | ||||
|   componentDidMount () { | ||||
|     if (this.props.searchTerm === '') { | ||||
|       this.props.fetchSuggestions(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   componentDidUpdate () { | ||||
|     if (this.props.searchTerm === '') { | ||||
|       this.props.fetchSuggestions(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   handleLoadMoreAccounts = () => this.props.expandSearch('accounts'); | ||||
| 
 | ||||
|   handleLoadMoreStatuses = () => this.props.expandSearch('statuses'); | ||||
|  | @ -48,97 +38,52 @@ class SearchResults extends ImmutablePureComponent { | |||
|   handleLoadMoreHashtags = () => this.props.expandSearch('hashtags'); | ||||
| 
 | ||||
|   render () { | ||||
|     const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props; | ||||
| 
 | ||||
|     if (searchTerm === '' && !suggestions.isEmpty()) { | ||||
|       return ( | ||||
|         <div className='search-results'> | ||||
|           <div className='trends'> | ||||
|             <div className='trends__header'> | ||||
|               <Icon id='user-plus' fixedWidth /> | ||||
|               <FormattedMessage id='suggestions.header' defaultMessage='You might be interested in…' /> | ||||
|             </div> | ||||
| 
 | ||||
|             {suggestions && suggestions.map(suggestion => ( | ||||
|               <AccountContainer | ||||
|                 key={suggestion.get('account')} | ||||
|                 id={suggestion.get('account')} | ||||
|                 actionIcon={suggestion.get('source') === 'past_interactions' ? 'times' : null} | ||||
|                 actionTitle={suggestion.get('source') === 'past_interactions' ? intl.formatMessage(messages.dismissSuggestion) : null} | ||||
|                 onActionClick={dismissSuggestion} | ||||
|               /> | ||||
|             ))} | ||||
|           </div> | ||||
|         </div> | ||||
|       ); | ||||
|     } | ||||
|     const { results } = this.props; | ||||
| 
 | ||||
|     let accounts, statuses, hashtags; | ||||
|     let count = 0; | ||||
| 
 | ||||
|     if (results.get('accounts') && results.get('accounts').size > 0) { | ||||
|       count   += results.get('accounts').size; | ||||
|       accounts = ( | ||||
|         <div className='search-results__section'> | ||||
|           <h5><Icon id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></h5> | ||||
| 
 | ||||
|           {results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)} | ||||
| 
 | ||||
|           {results.get('accounts').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreAccounts} />} | ||||
|         </div> | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     if (results.get('statuses') && results.get('statuses').size > 0) { | ||||
|       count   += results.get('statuses').size; | ||||
|       statuses = ( | ||||
|         <div className='search-results__section'> | ||||
|           <h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></h5> | ||||
| 
 | ||||
|           {results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)} | ||||
| 
 | ||||
|           {results.get('statuses').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreStatuses} />} | ||||
|         </div> | ||||
|       ); | ||||
|     } else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) { | ||||
|       statuses = ( | ||||
|         <div className='search-results__section'> | ||||
|           <h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></h5> | ||||
| 
 | ||||
|           <div className='search-results__info'> | ||||
|             <FormattedMessage id='search_results.statuses_fts_disabled' defaultMessage='Searching posts by their content is not enabled on this Mastodon server.' /> | ||||
|           </div> | ||||
|         </div> | ||||
|         <SearchSection title={<><Icon id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></>}> | ||||
|           {withoutLastResult(results.get('accounts')).map(accountId => <AccountContainer key={accountId} id={accountId} />)} | ||||
|           {(results.get('accounts').size > INITIAL_PAGE_LIMIT && results.get('accounts').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={this.handleLoadMoreAccounts} />} | ||||
|         </SearchSection> | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     if (results.get('hashtags') && results.get('hashtags').size > 0) { | ||||
|       count += results.get('hashtags').size; | ||||
|       hashtags = ( | ||||
|         <div className='search-results__section'> | ||||
|           <h5><Icon id='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5> | ||||
| 
 | ||||
|           {results.get('hashtags').map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)} | ||||
| 
 | ||||
|           {results.get('hashtags').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreHashtags} />} | ||||
|         </div> | ||||
|         <SearchSection title={<><Icon id='hashtag' fixedWidth /><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={this.handleLoadMoreHashtags} />} | ||||
|         </SearchSection> | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     if (results.get('statuses') && results.get('statuses').size > 0) { | ||||
|       statuses = ( | ||||
|         <SearchSection title={<><Icon id='quote-right' fixedWidth /><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={this.handleLoadMoreStatuses} />} | ||||
|         </SearchSection> | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     return ( | ||||
|       <div className='search-results'> | ||||
|         <div className='search-results__header'> | ||||
|           <Icon id='search' fixedWidth /> | ||||
|           <FormattedMessage id='search_results.total' defaultMessage='{count, plural, one {# result} other {# results}}' values={{ count }} /> | ||||
|           <FormattedMessage id='explore.search_results' defaultMessage='Search results' /> | ||||
|         </div> | ||||
| 
 | ||||
|         {accounts} | ||||
|         {statuses} | ||||
|         {hashtags} | ||||
|         {statuses} | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| export default injectIntl(SearchResults); | ||||
| export default SearchResults; | ||||
|  |  | |||
|  | @ -0,0 +1,20 @@ | |||
| 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, | ||||
| }; | ||||
|  | @ -9,13 +9,15 @@ import { List as ImmutableList } from 'immutable'; | |||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import { connect } from 'react-redux'; | ||||
| 
 | ||||
| import { expandSearch } from 'mastodon/actions/search'; | ||||
| import { submitSearch, expandSearch } from 'mastodon/actions/search'; | ||||
| import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag'; | ||||
| import { LoadMore } from 'mastodon/components/load_more'; | ||||
| import { LoadingIndicator } from 'mastodon/components/loading_indicator'; | ||||
| import { Icon } from 'mastodon/components/icon'; | ||||
| import ScrollableList from 'mastodon/components/scrollable_list'; | ||||
| import Account from 'mastodon/containers/account_container'; | ||||
| import Status from 'mastodon/containers/status_container'; | ||||
| 
 | ||||
| import { SearchSection } from './components/search_section'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   title: { id: 'search_results.title', defaultMessage: 'Search for {q}' }, | ||||
| }); | ||||
|  | @ -24,85 +26,175 @@ const mapStateToProps = state => ({ | |||
|   isLoading: state.getIn(['search', 'isLoading']), | ||||
|   results: state.getIn(['search', 'results']), | ||||
|   q: state.getIn(['search', 'searchTerm']), | ||||
|   submittedType: state.getIn(['search', 'type']), | ||||
| }); | ||||
| 
 | ||||
| const appendLoadMore = (id, list, onLoadMore) => { | ||||
|   if (list.size >= 5) { | ||||
|     return list.push(<LoadMore key={`${id}-load-more`} visible onClick={onLoadMore} />); | ||||
| 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 = (results, onLoadMore) => appendLoadMore('accounts', results.get('accounts', ImmutableList()).map(item => ( | ||||
|   <Account key={`account-${item}`} id={item} /> | ||||
| )), onLoadMore); | ||||
| const renderAccounts = accounts => hidePeek(accounts).map(id => ( | ||||
|   <Account key={id} id={id} /> | ||||
| )); | ||||
| 
 | ||||
| const renderHashtags = (results, onLoadMore) => appendLoadMore('hashtags', results.get('hashtags', ImmutableList()).map(item => ( | ||||
|   <Hashtag key={`tag-${item.get('name')}`} hashtag={item} /> | ||||
| )), onLoadMore); | ||||
| const renderHashtags = hashtags => hidePeek(hashtags).map(hashtag => ( | ||||
|   <Hashtag key={hashtag.get('name')} hashtag={hashtag} /> | ||||
| )); | ||||
| 
 | ||||
| const renderStatuses = (results, onLoadMore) => appendLoadMore('statuses', results.get('statuses', ImmutableList()).map(item => ( | ||||
|   <Status key={`status-${item}`} id={item} /> | ||||
| )), onLoadMore); | ||||
| const renderStatuses = statuses => hidePeek(statuses).map(id => ( | ||||
|   <Status key={id} id={id} /> | ||||
| )); | ||||
| 
 | ||||
| class Results extends PureComponent { | ||||
| 
 | ||||
|   static propTypes = { | ||||
|     results: ImmutablePropTypes.map, | ||||
|     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: 'all', | ||||
|     type: this.props.submittedType || 'all', | ||||
|   }; | ||||
| 
 | ||||
|   handleSelectAll = () => this.setState({ type: 'all' }); | ||||
|   handleSelectAccounts = () => this.setState({ type: 'accounts' }); | ||||
|   handleSelectHashtags = () => this.setState({ type: 'hashtags' }); | ||||
|   handleSelectStatuses = () => this.setState({ type: 'statuses' }); | ||||
|   handleLoadMoreAccounts = () => this.loadMore('accounts'); | ||||
|   handleLoadMoreStatuses = () => this.loadMore('statuses'); | ||||
|   handleLoadMoreHashtags = () => this.loadMore('hashtags'); | ||||
|   static getDerivedStateFromProps(props, state) { | ||||
|     if (props.submittedType !== state.type) { | ||||
|       return { | ||||
|         type: props.submittedType || 'all', | ||||
|       }; | ||||
|     } | ||||
| 
 | ||||
|   loadMore (type) { | ||||
|     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; | ||||
| 
 | ||||
|     let filteredResults = ImmutableList(); | ||||
|     // 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; | ||||
| 
 | ||||
|     if (!isLoading) { | ||||
|       const accounts = results.get('accounts', ImmutableList()); | ||||
|       const hashtags = results.get('hashtags', ImmutableList()); | ||||
|       const statuses = results.get('statuses', ImmutableList()); | ||||
| 
 | ||||
|       switch(type) { | ||||
|       case 'all': | ||||
|         filteredResults = filteredResults.concat(renderAccounts(results, this.handleLoadMoreAccounts), renderHashtags(results, this.handleLoadMoreHashtags), renderStatuses(results, this.handleLoadMoreStatuses)); | ||||
|         filteredResults = (accounts.size + hashtags.size + statuses.size) > 0 ? ( | ||||
|           <> | ||||
|             {accounts.size > 0 && ( | ||||
|               <SearchSection key='accounts' title={<><Icon id='users' fixedWidth /><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' fixedWidth /><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' fixedWidth /><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 = filteredResults.concat(renderAccounts(results, this.handleLoadMoreAccounts)); | ||||
|         filteredResults = renderAccounts(accounts); | ||||
|         break; | ||||
|       case 'hashtags': | ||||
|         filteredResults = filteredResults.concat(renderHashtags(results, this.handleLoadMoreHashtags)); | ||||
|         filteredResults = renderHashtags(hashtags); | ||||
|         break; | ||||
|       case 'statuses': | ||||
|         filteredResults = filteredResults.concat(renderStatuses(results, this.handleLoadMoreStatuses)); | ||||
|         filteredResults = renderStatuses(statuses); | ||||
|         break; | ||||
|       } | ||||
| 
 | ||||
|       if (filteredResults.size === 0) { | ||||
|         filteredResults = ( | ||||
|           <div className='empty-column-indicator'> | ||||
|             <FormattedMessage id='search_results.nothing_found' defaultMessage='Could not find anything for these search terms' /> | ||||
|           </div> | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|  | @ -115,7 +207,16 @@ class Results extends PureComponent { | |||
|         </div> | ||||
| 
 | ||||
|         <div className='explore__search-results'> | ||||
|           {isLoading ? <LoadingIndicator /> : filteredResults} | ||||
|           <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> | ||||
|  |  | |||
|  | @ -600,10 +600,9 @@ | |||
|   "search_results.all": "All", | ||||
|   "search_results.hashtags": "Hashtags", | ||||
|   "search_results.nothing_found": "Could not find anything for these search terms", | ||||
|   "search_results.see_all": "See all", | ||||
|   "search_results.statuses": "Posts", | ||||
|   "search_results.statuses_fts_disabled": "Searching posts by their content is not enabled on this Mastodon server.", | ||||
|   "search_results.title": "Search for {q}", | ||||
|   "search_results.total": "{count, number} {count, plural, one {result} other {results}}", | ||||
|   "server_banner.about_active_users": "People using this server during the last 30 days (Monthly Active Users)", | ||||
|   "server_banner.active_users": "active users", | ||||
|   "server_banner.administered_by": "Administered by:", | ||||
|  | @ -675,8 +674,6 @@ | |||
|   "subscribed_languages.lead": "Only posts in selected languages will appear on your home and list timelines after the change. Select none to receive posts in all languages.", | ||||
|   "subscribed_languages.save": "Save changes", | ||||
|   "subscribed_languages.target": "Change subscribed languages for {target}", | ||||
|   "suggestions.dismiss": "Dismiss suggestion", | ||||
|   "suggestions.header": "You might be interested in…", | ||||
|   "tabs_bar.home": "Home", | ||||
|   "tabs_bar.notifications": "Notifications", | ||||
|   "time_remaining.days": "{number, plural, one {# day} other {# days}} left", | ||||
|  |  | |||
|  | @ -1,4 +1,4 @@ | |||
| import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; | ||||
| import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; | ||||
| 
 | ||||
| import { | ||||
|   COMPOSE_MENTION, | ||||
|  | @ -12,6 +12,7 @@ import { | |||
|   SEARCH_FETCH_FAIL, | ||||
|   SEARCH_FETCH_SUCCESS, | ||||
|   SEARCH_SHOW, | ||||
|   SEARCH_EXPAND_REQUEST, | ||||
|   SEARCH_EXPAND_SUCCESS, | ||||
|   SEARCH_RESULT_CLICK, | ||||
|   SEARCH_RESULT_FORGET, | ||||
|  | @ -24,6 +25,7 @@ const initialState = ImmutableMap({ | |||
|   results: ImmutableMap(), | ||||
|   isLoading: false, | ||||
|   searchTerm: '', | ||||
|   type: null, | ||||
|   recent: ImmutableOrderedSet(), | ||||
| }); | ||||
| 
 | ||||
|  | @ -37,6 +39,8 @@ export default function search(state = initialState, action) { | |||
|       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); | ||||
|  | @ -48,23 +52,27 @@ export default function search(state = initialState, action) { | |||
|     return state.withMutations(map => { | ||||
|       map.set('isLoading', true); | ||||
|       map.set('submitted', true); | ||||
|       map.set('type', action.searchType); | ||||
|     }); | ||||
|   case SEARCH_FETCH_FAIL: | ||||
|     return state.set('isLoading', false); | ||||
|   case SEARCH_FETCH_SUCCESS: | ||||
|     return state.withMutations(map => { | ||||
|       map.set('results', ImmutableMap({ | ||||
|         accounts: ImmutableList(action.results.accounts.map(item => item.id)), | ||||
|         statuses: ImmutableList(action.results.statuses.map(item => item.id)), | ||||
|         hashtags: fromJS(action.results.hashtags), | ||||
|         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); | ||||
|   case SEARCH_EXPAND_SUCCESS: | ||||
|     const results = action.searchType === 'hashtags' ? fromJS(action.results.hashtags) : action.results[action.searchType].map(item => item.id); | ||||
|     return state.updateIn(['results', action.searchType], list => list.concat(results)); | ||||
|     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)); | ||||
|   case SEARCH_RESULT_CLICK: | ||||
|     return state.update('recent', set => set.add(fromJS(action.result))); | ||||
|   case SEARCH_RESULT_FORGET: | ||||
|  |  | |||
|  | @ -5172,22 +5172,39 @@ a.status-card { | |||
| } | ||||
| 
 | ||||
| .search-results__section { | ||||
|   margin-bottom: 5px; | ||||
|   border-bottom: 1px solid lighten($ui-base-color, 8%); | ||||
| 
 | ||||
|   h5 { | ||||
|   &:last-child { | ||||
|     border-bottom: 0; | ||||
|   } | ||||
| 
 | ||||
|   &__header { | ||||
|     background: darken($ui-base-color, 4%); | ||||
|     border-bottom: 1px solid lighten($ui-base-color, 8%); | ||||
|     cursor: default; | ||||
|     display: flex; | ||||
|     padding: 15px; | ||||
|     font-weight: 500; | ||||
|     font-size: 16px; | ||||
|     color: $dark-text-color; | ||||
|     font-size: 14px; | ||||
|     color: $darker-text-color; | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
| 
 | ||||
|     .fa { | ||||
|       display: inline-block; | ||||
|     h3 .fa { | ||||
|       margin-inline-end: 5px; | ||||
|     } | ||||
| 
 | ||||
|     button { | ||||
|       color: $highlight-text-color; | ||||
|       padding: 0; | ||||
|       border: 0; | ||||
|       background: 0; | ||||
|       font: inherit; | ||||
| 
 | ||||
|       &:hover, | ||||
|       &:active, | ||||
|       &:focus { | ||||
|         text-decoration: underline; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   .account:last-child, | ||||
|  | @ -6815,14 +6832,14 @@ a.status-card { | |||
| 
 | ||||
| .notification__filter-bar, | ||||
| .account__section-headline { | ||||
|   background: darken($ui-base-color, 4%); | ||||
|   background: $ui-base-color; | ||||
|   border-bottom: 1px solid lighten($ui-base-color, 8%); | ||||
|   cursor: default; | ||||
|   display: flex; | ||||
|   flex-shrink: 0; | ||||
| 
 | ||||
|   button { | ||||
|     background: darken($ui-base-color, 4%); | ||||
|     background: transparent; | ||||
|     border: 0; | ||||
|     margin: 0; | ||||
|   } | ||||
|  | @ -6842,26 +6859,18 @@ a.status-card { | |||
|     white-space: nowrap; | ||||
| 
 | ||||
|     &.active { | ||||
|       color: $secondary-text-color; | ||||
|       color: $primary-text-color; | ||||
| 
 | ||||
|       &::before, | ||||
|       &::after { | ||||
|       &::before { | ||||
|         display: block; | ||||
|         content: ''; | ||||
|         position: absolute; | ||||
|         bottom: 0; | ||||
|         left: 50%; | ||||
|         width: 0; | ||||
|         height: 0; | ||||
|         transform: translateX(-50%); | ||||
|         border-style: solid; | ||||
|         border-width: 0 10px 10px; | ||||
|         border-color: transparent transparent lighten($ui-base-color, 8%); | ||||
|       } | ||||
| 
 | ||||
|       &::after { | ||||
|         bottom: -1px; | ||||
|         border-color: transparent transparent $ui-base-color; | ||||
|         left: 0; | ||||
|         width: 100%; | ||||
|         height: 3px; | ||||
|         border-radius: 4px; | ||||
|         background: $highlight-text-color; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue