Search: infinite scroll

This commit is contained in:
infinite-persistence 2021-03-26 16:33:30 +08:00 committed by Sean Yesmunt
parent 5421a64b65
commit 855ae15a27
9 changed files with 110 additions and 29 deletions

View file

@ -29,6 +29,7 @@ declare type SearchOptions = {
declare type SearchState = {
options: SearchOptions,
urisByQuery: {},
hasReachedMaxResultsLength: {},
searching: boolean,
};
@ -36,6 +37,7 @@ declare type SearchSuccess = {
type: ACTIONS.SEARCH_SUCCESS,
data: {
query: string,
size: number,
uris: Array<string>,
},
};

View file

@ -1,5 +1,5 @@
// @flow
import { SEARCH_OPTIONS } from 'constants/search';
import { SEARCH_OPTIONS, SEARCH_PAGE_SIZE } from 'constants/search';
import * as ICONS from 'constants/icons';
import React, { useMemo } from 'react';
import { Form, FormField } from 'component/common/form';
@ -48,7 +48,6 @@ const SearchOptions = (props: Props) => {
const { options, simple, setSearchOption, expanded, toggleSearchExpanded } = props;
const stringifiedOptions = JSON.stringify(options);
const resultCount = options[SEARCH_OPTIONS.RESULT_COUNT];
const isFilteringByChannel = useMemo(() => {
const jsonOptions = JSON.parse(stringifiedOptions);
@ -60,6 +59,14 @@ const SearchOptions = (props: Props) => {
delete TYPES_ADVANCED[SEARCH_OPTIONS.MEDIA_APPLICATION];
}
React.useEffect(() => {
// We no longer let the user set the search results count, but the value
// will be in local storage for existing users. Override that.
if (options[SEARCH_OPTIONS.RESULT_COUNT] !== SEARCH_PAGE_SIZE) {
setSearchOption(SEARCH_OPTIONS.RESULT_COUNT, SEARCH_PAGE_SIZE);
}
}, []);
function addRow(label: string, value: any) {
return (
<tr>
@ -153,21 +160,6 @@ const SearchOptions = (props: Props) => {
)}
/>
</div>
{!simple && (
<FormField
type="select"
name="result-count"
value={resultCount}
onChange={(e) => setSearchOption(SEARCH_OPTIONS.RESULT_COUNT, e.target.value)}
blockWrap={false}
label={__('Returned Results')}
>
<option value={10}>10</option>
<option value={30}>30</option>
<option value={50}>50</option>
<option value={100}>100</option>
</FormField>
)}
</>
);

View file

@ -32,3 +32,5 @@ export const SEARCH_OPTIONS = {
TIME_FILTER_THIS_MONTH: 'thismonth',
TIME_FILTER_THIS_YEAR: 'thisyear',
};
export const SEARCH_PAGE_SIZE = 20;

View file

@ -7,6 +7,7 @@ import {
makeSelectSearchUris,
makeSelectQueryWithOptions,
selectSearchOptions,
makeSelectHasReachedMaxResultsLength,
} from 'redux/selectors/search';
import { selectShowMatureContent } from 'redux/selectors/settings';
import { selectUserVerifiedEmail } from 'redux/selectors/user';
@ -26,6 +27,7 @@ const select = (state, props) => {
showMature === false ? { nsfw: false, isBackgroundSearch: false } : { isBackgroundSearch: false }
)(state);
const uris = makeSelectSearchUris(query)(state);
const hasReachedMaxResultsLength = makeSelectHasReachedMaxResultsLength(query)(state);
return {
isSearching: selectIsSearching(state),
@ -33,6 +35,7 @@ const select = (state, props) => {
uris: uris,
isAuthenticated: selectUserVerifiedEmail(state),
searchOptions: selectSearchOptions(state),
hasReachedMaxResultsLength: hasReachedMaxResultsLength,
};
};

View file

@ -12,10 +12,12 @@ import SearchTopClaim from 'component/searchTopClaim';
import { formatLbryUrlForWeb } from 'util/url';
import { useHistory } from 'react-router';
import ClaimPreview from 'component/claimPreview';
import { SEARCH_PAGE_SIZE } from 'constants/search';
type AdditionalOptions = {
isBackgroundSearch: boolean,
nsfw?: boolean,
from?: number,
};
type Props = {
@ -28,6 +30,7 @@ type Props = {
onFeedbackPositive: (string) => void,
showNsfw: boolean,
isAuthenticated: boolean,
hasReachedMaxResultsLength: boolean,
};
export default function SearchPage(props: Props) {
@ -41,13 +44,16 @@ export default function SearchPage(props: Props) {
showNsfw,
isAuthenticated,
searchOptions,
hasReachedMaxResultsLength,
} = props;
const { push } = useHistory();
const urlParams = new URLSearchParams(location.search);
const urlQuery = urlParams.get('q') || '';
const additionalOptions: AdditionalOptions = { isBackgroundSearch: false };
const [from, setFrom] = React.useState(0);
additionalOptions['nsfw'] = showNsfw;
additionalOptions['from'] = from;
const modifiedUrlQuery = urlQuery.trim().replace(/\s+/g, '').replace(/:/g, '#');
const uriFromQuery = `lbry://${modifiedUrlQuery}`;
@ -88,6 +94,12 @@ export default function SearchPage(props: Props) {
}
}, [search, urlQuery, stringifiedOptions, stringifiedSearchOptions]);
function loadMore() {
if (!isSearching && !hasReachedMaxResultsLength) {
setFrom(from + SEARCH_PAGE_SIZE);
}
}
return (
<Page>
<section className="search">
@ -97,6 +109,11 @@ export default function SearchPage(props: Props) {
<ClaimList
uris={uris}
loading={isSearching}
onScrollBottom={loadMore}
// 'page' is 1-indexed; It's not the same as 'from', but it just
// needs to be unique to indicate when a fetch is needed.
page={from + 1}
pageSize={SEARCH_PAGE_SIZE}
header={<SearchOptions simple={SIMPLE_SITE} additionalOptions={additionalOptions} />}
injectedItem={
SHOW_ADS && IS_WEB ? (SIMPLE_SITE ? false : !isAuthenticated && <Ads small type={'video'} />) : false

View file

@ -1,7 +1,11 @@
// @flow
import * as ACTIONS from 'constants/action_types';
import { buildURI, doResolveUris, batchActions, isURIValid, makeSelectClaimForUri } from 'lbry-redux';
import { makeSelectSearchUris, makeSelectQueryWithOptions, selectSearchValue } from 'redux/selectors/search';
import {
makeSelectSearchUris,
makeSelectQueryWithOptions,
selectSearchValue,
selectSearchOptions} from 'redux/selectors/search';
import handleFetchResponse from 'util/handle-fetch';
type Dispatch = (action: any) => any;
@ -39,13 +43,19 @@ export const doSearch = (rawQuery: string, searchOptions: SearchOptions) => (
const state = getState();
let queryWithOptions = makeSelectQueryWithOptions(query, searchOptions)(state);
const mainOptions: any = selectSearchOptions(state);
const queryWithOptions = makeSelectQueryWithOptions(query, searchOptions)(state);
const size = mainOptions.size;
const from = searchOptions.from;
// If we have already searched for something, we don't need to do anything
const urisForQuery = makeSelectSearchUris(queryWithOptions)(state);
if (urisForQuery && !!urisForQuery.length) {
if (!size || !from || from + size < urisForQuery.length) {
return;
}
}
dispatch({
type: ACTIONS.SEARCH_START,
@ -83,6 +93,7 @@ export const doSearch = (rawQuery: string, searchOptions: SearchOptions) => (
type: ACTIONS.SEARCH_SUCCESS,
data: {
query: queryWithOptions,
size: size,
uris,
},
});

View file

@ -1,12 +1,13 @@
// @flow
import * as ACTIONS from 'constants/action_types';
import { handleActions } from 'util/redux-utils';
import { SEARCH_OPTIONS } from 'constants/search';
import { SEARCH_OPTIONS, SEARCH_PAGE_SIZE } from 'constants/search';
import { createNormalizedSearchKey } from 'util/search';
const defaultState: SearchState = {
// $FlowFixMe
options: {
[SEARCH_OPTIONS.RESULT_COUNT]: 30,
[SEARCH_OPTIONS.RESULT_COUNT]: SEARCH_PAGE_SIZE,
[SEARCH_OPTIONS.CLAIM_TYPE]: SEARCH_OPTIONS.INCLUDE_FILES_AND_CHANNELS,
[SEARCH_OPTIONS.MEDIA_AUDIO]: true,
[SEARCH_OPTIONS.MEDIA_VIDEO]: true,
@ -15,6 +16,7 @@ const defaultState: SearchState = {
[SEARCH_OPTIONS.MEDIA_APPLICATION]: true,
},
urisByQuery: {},
hasReachedMaxResultsLength: {},
searching: false,
};
@ -25,12 +27,24 @@ export default handleActions(
searching: true,
}),
[ACTIONS.SEARCH_SUCCESS]: (state: SearchState, action: SearchSuccess): SearchState => {
const { query, uris } = action.data;
const { query, uris, size } = action.data;
const normalizedQuery = createNormalizedSearchKey(query);
let newUris = uris;
if (state.urisByQuery[normalizedQuery]) {
newUris = Array.from(new Set(state.urisByQuery[normalizedQuery].concat(uris)));
}
// The returned number of urls is less than the page size, so we're on the last page
const noMoreResults = size && uris.length < size;
return {
...state,
searching: false,
urisByQuery: Object.assign({}, state.urisByQuery, { [query]: uris }),
urisByQuery: Object.assign({}, state.urisByQuery, { [normalizedQuery]: newUris }),
hasReachedMaxResultsLength: Object.assign({}, state.hasReachedMaxResultsLength, {
[normalizedQuery]: noMoreResults,
}),
};
},

View file

@ -11,6 +11,7 @@ import {
makeSelectIsUriResolving,
} from 'lbry-redux';
import { createSelector } from 'reselect';
import { createNormalizedSearchKey } from 'util/search';
type State = { search: SearchState };
@ -30,12 +31,31 @@ export const selectSearchUrisByQuery: (state: State) => { [string]: Array<string
(state) => state.urisByQuery
);
export const selectHasReachedMaxResultsLength: (state: State) => { [boolean]: Array<boolean> } = createSelector(
selectState,
(state) => state.hasReachedMaxResultsLength
);
export const makeSelectSearchUris = (query: string): ((state: State) => Array<string>) =>
// replace statement below is kind of ugly, and repeated in doSearch action
createSelector(
selectSearchUrisByQuery,
(byQuery) => byQuery[query ? query.replace(/^lbry:\/\//i, '').replace(/\//, ' ') : query]
);
createSelector(selectSearchUrisByQuery, (byQuery) => {
if (query) {
query = query.replace(/^lbry:\/\//i, '').replace(/\//, ' ');
const normalizedQuery = createNormalizedSearchKey(query);
return byQuery[normalizedQuery];
}
return byQuery[query];
});
export const makeSelectHasReachedMaxResultsLength = (query: string): ((state: State) => boolean) =>
createSelector(selectHasReachedMaxResultsLength, (hasReachedMaxResultsLength) => {
if (query) {
query = query.replace(/^lbry:\/\//i, '').replace(/\//, ' ');
const normalizedQuery = createNormalizedSearchKey(query);
return hasReachedMaxResultsLength[normalizedQuery];
}
return hasReachedMaxResultsLength[query];
});
// Creates a query string based on the state in the search reducer
// Can be overrided by passing in custom sizes/from values for other areas pagination
@ -81,8 +101,9 @@ export const makeSelectRecommendedContentForUri = (uri: string) =>
options['nsfw'] = isMature;
const searchQuery = getSearchQueryString(title.replace(/\//, ' '), options);
const normalizedSearchQuery = createNormalizedSearchKey(searchQuery);
let searchUris = searchUrisByQuery[searchQuery];
let searchUris = searchUrisByQuery[normalizedSearchQuery];
if (searchUris) {
searchUris = searchUris.filter((searchUri) => searchUri !== currentUri);
recommendedContent = searchUris;

19
ui/util/search.js Normal file
View file

@ -0,0 +1,19 @@
// @flow
export function createNormalizedSearchKey(query: string) {
const FROM = '&from=';
// Ignore the "page" (`from`) because we don't care what the last page
// searched was, we want everything.
let normalizedQuery = query;
if (normalizedQuery.includes(FROM)) {
const a = normalizedQuery.indexOf(FROM);
const b = normalizedQuery.indexOf('&', a + FROM.length);
if (b > a) {
normalizedQuery = normalizedQuery.substring(0, a) + normalizedQuery.substring(b);
} else {
normalizedQuery = normalizedQuery.substring(0, a);
}
}
return normalizedQuery;
}