Search: infinite scroll
This commit is contained in:
parent
5421a64b65
commit
855ae15a27
9 changed files with 110 additions and 29 deletions
2
flow-typed/search.js
vendored
2
flow-typed/search.js
vendored
|
@ -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>,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
|
|
|
@ -32,3 +32,5 @@ export const SEARCH_OPTIONS = {
|
|||
TIME_FILTER_THIS_MONTH: 'thismonth',
|
||||
TIME_FILTER_THIS_YEAR: 'thisyear',
|
||||
};
|
||||
|
||||
export const SEARCH_PAGE_SIZE = 20;
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,12 +43,18 @@ 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) {
|
||||
return;
|
||||
if (!size || !from || from + size < urisForQuery.length) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
dispatch({
|
||||
|
@ -83,6 +93,7 @@ export const doSearch = (rawQuery: string, searchOptions: SearchOptions) => (
|
|||
type: ACTIONS.SEARCH_SUCCESS,
|
||||
data: {
|
||||
query: queryWithOptions,
|
||||
size: size,
|
||||
uris,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
|
|
|
@ -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
19
ui/util/search.js
Normal 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;
|
||||
}
|
Loading…
Reference in a new issue