Skip to content

Commit

Permalink
Add infinite scrolling for search results in web UI (mastodon#26784)
Browse files Browse the repository at this point in the history
  • Loading branch information
Gargron authored Sep 5, 2023
1 parent 548c032 commit 5d20733
Show file tree
Hide file tree
Showing 7 changed files with 253 additions and 169 deletions.
22 changes: 13 additions & 9 deletions app/javascript/mastodon/actions/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -59,24 +59,26 @@ 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));
});
};
}

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,
};
}
Expand All @@ -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) {
Expand All @@ -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) => ({
Expand Down
117 changes: 31 additions & 86 deletions app/javascript/mastodon/features/compose/components/search_results.jsx
Original file line number Diff line number Diff line change
@@ -1,144 +1,89 @@
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');

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} />)}
<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>
);
}

{results.get('accounts').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreAccounts} />}
</div>
if (results.get('hashtags') && results.get('hashtags').size > 0) {
hashtags = (
<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) {
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='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>
);
}

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>
);
}

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;
Original file line number Diff line number Diff line change
@@ -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,
};
Loading

0 comments on commit 5d20733

Please sign in to comment.