From 855ae15a27e4a96aadb45c95eccded012d3b0335 Mon Sep 17 00:00:00 2001 From: infinite-persistence Date: Fri, 26 Mar 2021 16:33:30 +0800 Subject: [PATCH] Search: infinite scroll --- flow-typed/search.js | 2 ++ ui/component/searchOptions/view.jsx | 26 +++++++++--------------- ui/constants/search.js | 2 ++ ui/page/search/index.js | 3 +++ ui/page/search/view.jsx | 17 ++++++++++++++++ ui/redux/actions/search.js | 17 +++++++++++++--- ui/redux/reducers/search.js | 22 ++++++++++++++++---- ui/redux/selectors/search.js | 31 ++++++++++++++++++++++++----- ui/util/search.js | 19 ++++++++++++++++++ 9 files changed, 110 insertions(+), 29 deletions(-) create mode 100644 ui/util/search.js diff --git a/flow-typed/search.js b/flow-typed/search.js index 6f6317a42..7327ab997 100644 --- a/flow-typed/search.js +++ b/flow-typed/search.js @@ -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, }, }; diff --git a/ui/component/searchOptions/view.jsx b/ui/component/searchOptions/view.jsx index 3ac8d4604..f5059d2f3 100644 --- a/ui/component/searchOptions/view.jsx +++ b/ui/component/searchOptions/view.jsx @@ -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 ( @@ -153,21 +160,6 @@ const SearchOptions = (props: Props) => { )} /> - {!simple && ( - setSearchOption(SEARCH_OPTIONS.RESULT_COUNT, e.target.value)} - blockWrap={false} - label={__('Returned Results')} - > - - - - - - )} ); diff --git a/ui/constants/search.js b/ui/constants/search.js index 4a1f31253..ff33a4df0 100644 --- a/ui/constants/search.js +++ b/ui/constants/search.js @@ -32,3 +32,5 @@ export const SEARCH_OPTIONS = { TIME_FILTER_THIS_MONTH: 'thismonth', TIME_FILTER_THIS_YEAR: 'thisyear', }; + +export const SEARCH_PAGE_SIZE = 20; diff --git a/ui/page/search/index.js b/ui/page/search/index.js index 2f82e84a9..395088561 100644 --- a/ui/page/search/index.js +++ b/ui/page/search/index.js @@ -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, }; }; diff --git a/ui/page/search/view.jsx b/ui/page/search/view.jsx index 309d160e8..5311a2e7f 100644 --- a/ui/page/search/view.jsx +++ b/ui/page/search/view.jsx @@ -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 (
@@ -97,6 +109,11 @@ export default function SearchPage(props: Props) { } injectedItem={ SHOW_ADS && IS_WEB ? (SIMPLE_SITE ? false : !isAuthenticated && ) : false diff --git a/ui/redux/actions/search.js b/ui/redux/actions/search.js index aded46379..0d0eee63f 100644 --- a/ui/redux/actions/search.js +++ b/ui/redux/actions/search.js @@ -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, }, }); diff --git a/ui/redux/reducers/search.js b/ui/redux/reducers/search.js index 8d9f7d435..089cf9f7a 100644 --- a/ui/redux/reducers/search.js +++ b/ui/redux/reducers/search.js @@ -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, + }), }; }, diff --git a/ui/redux/selectors/search.js b/ui/redux/selectors/search.js index dcde45600..b36717076 100644 --- a/ui/redux/selectors/search.js +++ b/ui/redux/selectors/search.js @@ -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 state.urisByQuery ); +export const selectHasReachedMaxResultsLength: (state: State) => { [boolean]: Array } = createSelector( + selectState, + (state) => state.hasReachedMaxResultsLength +); + export const makeSelectSearchUris = (query: string): ((state: State) => Array) => // 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; diff --git a/ui/util/search.js b/ui/util/search.js new file mode 100644 index 000000000..05be648b7 --- /dev/null +++ b/ui/util/search.js @@ -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; +}