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

View file

@ -1,5 +1,5 @@
// @flow // @flow
import { SEARCH_OPTIONS } from 'constants/search'; import { SEARCH_OPTIONS, SEARCH_PAGE_SIZE } from 'constants/search';
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { Form, FormField } from 'component/common/form'; import { Form, FormField } from 'component/common/form';
@ -48,7 +48,6 @@ const SearchOptions = (props: Props) => {
const { options, simple, setSearchOption, expanded, toggleSearchExpanded } = props; const { options, simple, setSearchOption, expanded, toggleSearchExpanded } = props;
const stringifiedOptions = JSON.stringify(options); const stringifiedOptions = JSON.stringify(options);
const resultCount = options[SEARCH_OPTIONS.RESULT_COUNT];
const isFilteringByChannel = useMemo(() => { const isFilteringByChannel = useMemo(() => {
const jsonOptions = JSON.parse(stringifiedOptions); const jsonOptions = JSON.parse(stringifiedOptions);
@ -60,6 +59,14 @@ const SearchOptions = (props: Props) => {
delete TYPES_ADVANCED[SEARCH_OPTIONS.MEDIA_APPLICATION]; 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) { function addRow(label: string, value: any) {
return ( return (
<tr> <tr>
@ -153,21 +160,6 @@ const SearchOptions = (props: Props) => {
)} )}
/> />
</div> </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_MONTH: 'thismonth',
TIME_FILTER_THIS_YEAR: 'thisyear', TIME_FILTER_THIS_YEAR: 'thisyear',
}; };
export const SEARCH_PAGE_SIZE = 20;

View file

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

View file

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

View file

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

View file

@ -1,12 +1,13 @@
// @flow // @flow
import * as ACTIONS from 'constants/action_types'; import * as ACTIONS from 'constants/action_types';
import { handleActions } from 'util/redux-utils'; 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 = { const defaultState: SearchState = {
// $FlowFixMe // $FlowFixMe
options: { 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.CLAIM_TYPE]: SEARCH_OPTIONS.INCLUDE_FILES_AND_CHANNELS,
[SEARCH_OPTIONS.MEDIA_AUDIO]: true, [SEARCH_OPTIONS.MEDIA_AUDIO]: true,
[SEARCH_OPTIONS.MEDIA_VIDEO]: true, [SEARCH_OPTIONS.MEDIA_VIDEO]: true,
@ -15,6 +16,7 @@ const defaultState: SearchState = {
[SEARCH_OPTIONS.MEDIA_APPLICATION]: true, [SEARCH_OPTIONS.MEDIA_APPLICATION]: true,
}, },
urisByQuery: {}, urisByQuery: {},
hasReachedMaxResultsLength: {},
searching: false, searching: false,
}; };
@ -25,12 +27,24 @@ export default handleActions(
searching: true, searching: true,
}), }),
[ACTIONS.SEARCH_SUCCESS]: (state: SearchState, action: SearchSuccess): SearchState => { [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 { return {
...state, ...state,
searching: false, 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, makeSelectIsUriResolving,
} from 'lbry-redux'; } from 'lbry-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { createNormalizedSearchKey } from 'util/search';
type State = { search: SearchState }; type State = { search: SearchState };
@ -30,12 +31,31 @@ export const selectSearchUrisByQuery: (state: State) => { [string]: Array<string
(state) => state.urisByQuery (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>) => export const makeSelectSearchUris = (query: string): ((state: State) => Array<string>) =>
// replace statement below is kind of ugly, and repeated in doSearch action // replace statement below is kind of ugly, and repeated in doSearch action
createSelector( createSelector(selectSearchUrisByQuery, (byQuery) => {
selectSearchUrisByQuery, if (query) {
(byQuery) => byQuery[query ? query.replace(/^lbry:\/\//i, '').replace(/\//, ' ') : 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 // 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 // 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; options['nsfw'] = isMature;
const searchQuery = getSearchQueryString(title.replace(/\//, ' '), options); const searchQuery = getSearchQueryString(title.replace(/\//, ' '), options);
const normalizedSearchQuery = createNormalizedSearchKey(searchQuery);
let searchUris = searchUrisByQuery[searchQuery]; let searchUris = searchUrisByQuery[normalizedSearchQuery];
if (searchUris) { if (searchUris) {
searchUris = searchUris.filter((searchUri) => searchUri !== currentUri); searchUris = searchUris.filter((searchUri) => searchUri !== currentUri);
recommendedContent = searchUris; 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;
}