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 = {
|
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>,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -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
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