results.jsx 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. import PropTypes from 'prop-types';
  2. import { PureComponent } from 'react';
  3. import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
  4. import { Helmet } from 'react-helmet';
  5. import { List as ImmutableList } from 'immutable';
  6. import ImmutablePropTypes from 'react-immutable-proptypes';
  7. import { connect } from 'react-redux';
  8. import FindInPageIcon from '@/material-icons/400-24px/find_in_page.svg?react';
  9. import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
  10. import TagIcon from '@/material-icons/400-24px/tag.svg?react';
  11. import { submitSearch, expandSearch } from 'mastodon/actions/search';
  12. import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
  13. import { Icon } from 'mastodon/components/icon';
  14. import ScrollableList from 'mastodon/components/scrollable_list';
  15. import Account from 'mastodon/containers/account_container';
  16. import Status from 'mastodon/containers/status_container';
  17. import { SearchSection } from './components/search_section';
  18. const messages = defineMessages({
  19. title: { id: 'search_results.title', defaultMessage: 'Search for {q}' },
  20. });
  21. const mapStateToProps = state => ({
  22. isLoading: state.getIn(['search', 'isLoading']),
  23. results: state.getIn(['search', 'results']),
  24. q: state.getIn(['search', 'searchTerm']),
  25. submittedType: state.getIn(['search', 'type']),
  26. });
  27. const INITIAL_PAGE_LIMIT = 10;
  28. const INITIAL_DISPLAY = 4;
  29. const hidePeek = list => {
  30. if (list.size > INITIAL_PAGE_LIMIT && list.size % INITIAL_PAGE_LIMIT === 1) {
  31. return list.skipLast(1);
  32. } else {
  33. return list;
  34. }
  35. };
  36. const renderAccounts = accounts => hidePeek(accounts).map(id => (
  37. <Account key={id} id={id} />
  38. ));
  39. const renderHashtags = hashtags => hidePeek(hashtags).map(hashtag => (
  40. <Hashtag key={hashtag.get('name')} hashtag={hashtag} />
  41. ));
  42. const renderStatuses = statuses => hidePeek(statuses).map(id => (
  43. <Status key={id} id={id} />
  44. ));
  45. class Results extends PureComponent {
  46. static propTypes = {
  47. results: ImmutablePropTypes.contains({
  48. accounts: ImmutablePropTypes.orderedSet,
  49. statuses: ImmutablePropTypes.orderedSet,
  50. hashtags: ImmutablePropTypes.orderedSet,
  51. }),
  52. isLoading: PropTypes.bool,
  53. multiColumn: PropTypes.bool,
  54. dispatch: PropTypes.func.isRequired,
  55. q: PropTypes.string,
  56. intl: PropTypes.object,
  57. submittedType: PropTypes.oneOf(['accounts', 'statuses', 'hashtags']),
  58. };
  59. state = {
  60. type: this.props.submittedType || 'all',
  61. };
  62. static getDerivedStateFromProps(props, state) {
  63. if (props.submittedType !== state.type) {
  64. return {
  65. type: props.submittedType || 'all',
  66. };
  67. }
  68. return null;
  69. }
  70. handleSelectAll = () => {
  71. const { submittedType, dispatch } = this.props;
  72. // If we originally searched for a specific type, we need to resubmit
  73. // the query to get all types of results
  74. if (submittedType) {
  75. dispatch(submitSearch());
  76. }
  77. this.setState({ type: 'all' });
  78. };
  79. handleSelectAccounts = () => {
  80. const { submittedType, dispatch } = this.props;
  81. // If we originally searched for something else (but not everything),
  82. // we need to resubmit the query for this specific type
  83. if (submittedType !== 'accounts') {
  84. dispatch(submitSearch('accounts'));
  85. }
  86. this.setState({ type: 'accounts' });
  87. };
  88. handleSelectHashtags = () => {
  89. const { submittedType, dispatch } = this.props;
  90. // If we originally searched for something else (but not everything),
  91. // we need to resubmit the query for this specific type
  92. if (submittedType !== 'hashtags') {
  93. dispatch(submitSearch('hashtags'));
  94. }
  95. this.setState({ type: 'hashtags' });
  96. };
  97. handleSelectStatuses = () => {
  98. const { submittedType, dispatch } = this.props;
  99. // If we originally searched for something else (but not everything),
  100. // we need to resubmit the query for this specific type
  101. if (submittedType !== 'statuses') {
  102. dispatch(submitSearch('statuses'));
  103. }
  104. this.setState({ type: 'statuses' });
  105. };
  106. handleLoadMoreAccounts = () => this._loadMore('accounts');
  107. handleLoadMoreStatuses = () => this._loadMore('statuses');
  108. handleLoadMoreHashtags = () => this._loadMore('hashtags');
  109. _loadMore (type) {
  110. const { dispatch } = this.props;
  111. dispatch(expandSearch(type));
  112. }
  113. handleLoadMore = () => {
  114. const { type } = this.state;
  115. if (type !== 'all') {
  116. this._loadMore(type);
  117. }
  118. };
  119. render () {
  120. const { intl, isLoading, q, results } = this.props;
  121. const { type } = this.state;
  122. // We request 1 more result than we display so we can tell if there'd be a next page
  123. const hasMore = type !== 'all' ? results.get(type, ImmutableList()).size > INITIAL_PAGE_LIMIT && results.get(type).size % INITIAL_PAGE_LIMIT === 1 : false;
  124. let filteredResults;
  125. const accounts = results.get('accounts', ImmutableList());
  126. const hashtags = results.get('hashtags', ImmutableList());
  127. const statuses = results.get('statuses', ImmutableList());
  128. switch(type) {
  129. case 'all':
  130. filteredResults = (accounts.size + hashtags.size + statuses.size) > 0 ? (
  131. <>
  132. {accounts.size > 0 && (
  133. <SearchSection key='accounts' title={<><Icon id='users' icon={PeopleIcon} /><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></>} onClickMore={this.handleLoadMoreAccounts}>
  134. {accounts.take(INITIAL_DISPLAY).map(id => <Account key={id} id={id} />)}
  135. </SearchSection>
  136. )}
  137. {hashtags.size > 0 && (
  138. <SearchSection key='hashtags' title={<><Icon id='hashtag' icon={TagIcon} /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></>} onClickMore={this.handleLoadMoreHashtags}>
  139. {hashtags.take(INITIAL_DISPLAY).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
  140. </SearchSection>
  141. )}
  142. {statuses.size > 0 && (
  143. <SearchSection key='statuses' title={<><Icon id='quote-right' icon={FindInPageIcon} /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></>} onClickMore={this.handleLoadMoreStatuses}>
  144. {statuses.take(INITIAL_DISPLAY).map(id => <Status key={id} id={id} />)}
  145. </SearchSection>
  146. )}
  147. </>
  148. ) : [];
  149. break;
  150. case 'accounts':
  151. filteredResults = renderAccounts(accounts);
  152. break;
  153. case 'hashtags':
  154. filteredResults = renderHashtags(hashtags);
  155. break;
  156. case 'statuses':
  157. filteredResults = renderStatuses(statuses);
  158. break;
  159. }
  160. return (
  161. <>
  162. <div className='account__section-headline'>
  163. <button onClick={this.handleSelectAll} className={type === 'all' ? 'active' : undefined}><FormattedMessage id='search_results.all' defaultMessage='All' /></button>
  164. <button onClick={this.handleSelectAccounts} className={type === 'accounts' ? 'active' : undefined}><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></button>
  165. <button onClick={this.handleSelectHashtags} className={type === 'hashtags' ? 'active' : undefined}><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></button>
  166. <button onClick={this.handleSelectStatuses} className={type === 'statuses' ? 'active' : undefined}><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></button>
  167. </div>
  168. <div className='explore__search-results' data-nosnippet>
  169. <ScrollableList
  170. scrollKey='search-results'
  171. isLoading={isLoading}
  172. onLoadMore={this.handleLoadMore}
  173. hasMore={hasMore}
  174. emptyMessage={<FormattedMessage id='search_results.nothing_found' defaultMessage='Could not find anything for these search terms' />}
  175. bindToDocument
  176. >
  177. {filteredResults}
  178. </ScrollableList>
  179. </div>
  180. <Helmet>
  181. <title>{intl.formatMessage(messages.title, { q })}</title>
  182. </Helmet>
  183. </>
  184. );
  185. }
  186. }
  187. export default connect(mapStateToProps)(injectIntl(Results));