From b3c4ce05faaeadc93137e510d40ed68913e38659 Mon Sep 17 00:00:00 2001 From: Evans Lyb Date: Wed, 16 Feb 2022 23:01:20 +0800 Subject: [PATCH] Add pagination support to channel search (#791) * Add pagination support to channel search * fix #605 - channelContent component - replace lighthouse.search() to doSearch() * #605 - Add pagination support to channel search - create ClaimListSearch component to support channel search instead of ClaimListDiscover * #605 - Add pagination support to channel search - fix lint errors & component naming error Co-authored-by: Kyle --- ui/component/channelContent/index.js | 2 - ui/component/channelContent/view.jsx | 150 ++++++++------- ui/component/claimListSearch/index.js | 47 +++++ ui/component/claimListSearch/view.jsx | 267 ++++++++++++++++++++++++++ ui/constants/search.js | 1 + ui/util/query-params.js | 2 + 6 files changed, 400 insertions(+), 69 deletions(-) create mode 100644 ui/component/claimListSearch/index.js create mode 100644 ui/component/claimListSearch/view.jsx diff --git a/ui/component/channelContent/index.js b/ui/component/channelContent/index.js index 4c2f287e1..828ae31f3 100644 --- a/ui/component/channelContent/index.js +++ b/ui/component/channelContent/index.js @@ -7,7 +7,6 @@ import { makeSelectTotalPagesInChannelSearch, selectClaimForUri, } from 'redux/selectors/claims'; -import { doResolveUris } from 'redux/actions/claims'; import * as SETTINGS from 'constants/settings'; import { makeSelectChannelIsMuted } from 'redux/selectors/blocked'; import { withRouter } from 'react-router'; @@ -41,7 +40,6 @@ const select = (state, props) => { }; const perform = (dispatch) => ({ - doResolveUris: (uris, returnCachedUris) => dispatch(doResolveUris(uris, returnCachedUris)), doFetchChannelLiveStatus: (channelID) => dispatch(doFetchChannelLiveStatus(channelID)), }); diff --git a/ui/component/channelContent/view.jsx b/ui/component/channelContent/view.jsx index 3a5543fdc..542665d06 100644 --- a/ui/component/channelContent/view.jsx +++ b/ui/component/channelContent/view.jsx @@ -7,13 +7,14 @@ import HiddenNsfwClaims from 'component/hiddenNsfwClaims'; import { useHistory } from 'react-router-dom'; import Button from 'component/button'; import ClaimListDiscover from 'component/claimListDiscover'; +import ClaimListSearch from 'component/claimListSearch'; import Ads from 'web/component/ads'; import Icon from 'component/common/icon'; import LivestreamLink from 'component/livestreamLink'; import { Form, FormField } from 'component/common/form'; import { DEBOUNCE_WAIT_DURATION_MS } from 'constants/search'; -import { lighthouse } from 'redux/actions/search'; import ScheduledStreams from 'component/scheduledStreams'; +import { useIsLargeScreen } from 'effects/use-screensize'; const TYPES_TO_ALLOW_FILTER = ['stream', 'repost']; @@ -34,7 +35,6 @@ type Props = { showMature: boolean, tileLayout: boolean, viewHiddenChannels: boolean, - doResolveUris: (Array, boolean) => void, claimType: string, empty?: string, doFetchChannelLiveStatus: (string) => void, @@ -56,7 +56,6 @@ function ChannelContent(props: Props) { showMature, tileLayout, viewHiddenChannels, - doResolveUris, claimType, empty, doFetchChannelLiveStatus, @@ -66,7 +65,8 @@ function ChannelContent(props: Props) { // const claimsInChannel = (claim && claim.meta.claims_in_channel) || 0; const claimsInChannel = 9999; const [searchQuery, setSearchQuery] = React.useState(''); - const [searchResults, setSearchResults] = React.useState(undefined); + const [isSearch, setIsSearch] = React.useState(false); + const isLargeScreen = useIsLargeScreen(); const { location: { pathname, search }, } = useHistory(); @@ -78,6 +78,7 @@ function ChannelContent(props: Props) { (Array.isArray(claimType) ? claimType.every((ct) => TYPES_TO_ALLOW_FILTER.includes(ct)) : TYPES_TO_ALLOW_FILTER.includes(claimType)); + const dynamicPageSize = isLargeScreen ? Math.ceil(defaultPageSize * (3 / 2)) : defaultPageSize; function handleInputChange(e) { const { value } = e.target; @@ -87,38 +88,17 @@ function ChannelContent(props: Props) { React.useEffect(() => { const timer = setTimeout(() => { if (searchQuery.trim().length < 3 || !claimId) { - // In order to display original search results, search results must be set to null. A query of '' should display original results. - return setSearchResults(null); + setIsSearch(false); } else { - lighthouse - .search( - `s=${encodeURIComponent(searchQuery)}&channel_id=${encodeURIComponent(claimId)}${ - !showMature ? '&nsfw=false&size=50&from=0' : '' - }` - ) - .then(({ body: results }) => { - const urls = results.map(({ name, claimId }) => { - return `lbry://${name}#${claimId}`; - }); - - // Batch-resolve the urls before calling 'setSearchResults', as the - // latter will immediately cause the tiles to resolve, ending up - // calling doResolveUri one by one before the batched one. - doResolveUris(urls, true); - - setSearchResults(urls); - }) - .catch(() => { - setSearchResults(null); - }); + setIsSearch(true); } }, DEBOUNCE_WAIT_DURATION_MS); return () => clearTimeout(timer); - }, [claimId, searchQuery, showMature, doResolveUris]); + }, [claimId, searchQuery]); React.useEffect(() => { setSearchQuery(''); - setSearchResults(null); + setIsSearch(false); }, [url]); const isInitialized = Boolean(activeLivestreamForChannel) || activeLivestreamInitialized; @@ -177,44 +157,80 @@ function ChannelContent(props: Props) { {/* */} - {!fetching && ( - } - meta={ - showFilters && ( -
{}} className="wunderbar--inline"> - - - - ) - } - isChannel - channelIsMine={channelIsMine} - empty={empty} - /> - )} + {!fetching && + (isSearch ? ( + } + meta={ + showFilters && ( +
{}} className="wunderbar--inline"> + + + + ) + } + channelIsMine={channelIsMine} + empty={empty} + showMature={showMature} + searchKeyword={searchQuery} + /> + ) : ( + } + meta={ + showFilters && ( +
{}} className="wunderbar--inline"> + + + + ) + } + isChannel + channelIsMine={channelIsMine} + empty={empty} + /> + ))} ); } diff --git a/ui/component/claimListSearch/index.js b/ui/component/claimListSearch/index.js new file mode 100644 index 000000000..26b300b6b --- /dev/null +++ b/ui/component/claimListSearch/index.js @@ -0,0 +1,47 @@ +import { connect } from 'react-redux'; +import { selectClaimsByUri } from 'redux/selectors/claims'; +import { + selectIsSearching, + makeSelectSearchUrisForQuery, + makeSelectHasReachedMaxResultsLength, +} from 'redux/selectors/search'; +import { getSearchQueryString } from 'util/query-params'; +import { doSearch } from 'redux/actions/search'; +import ClaimListSearch from './view'; +import { doFetchViewCount } from 'lbryinc'; + +const select = (state, props) => { + const { searchKeyword, pageSize, claimId, showMature } = props; + const channel_id = encodeURIComponent(claimId); + const isBackgroundSearch = false; + const searchOptions = showMature + ? { + channel_id, + isBackgroundSearch, + } + : { + channel_id, + size: pageSize, + nsfw: false, + isBackgroundSearch, + }; + + const searchQueryString = getSearchQueryString(searchKeyword, searchOptions); + const searchResult = makeSelectSearchUrisForQuery(searchQueryString)(state); + const searchResultLastPageReached = makeSelectHasReachedMaxResultsLength(searchQueryString)(state); + + return { + claimsByUri: selectClaimsByUri(state), + loading: props.loading !== undefined ? props.loading : selectIsSearching(state), + searchOptions, + searchResult, + searchResultLastPageReached, + }; +}; + +const perform = { + doFetchViewCount, + doSearch, +}; + +export default connect(select, perform)(ClaimListSearch); diff --git a/ui/component/claimListSearch/view.jsx b/ui/component/claimListSearch/view.jsx new file mode 100644 index 000000000..0a6d20186 --- /dev/null +++ b/ui/component/claimListSearch/view.jsx @@ -0,0 +1,267 @@ +// @flow +import { SIMPLE_SITE } from 'config'; +import type { Node } from 'react'; +import * as CS from 'constants/claim_search'; +import React from 'react'; +import { withRouter } from 'react-router'; +import { MATURE_TAGS } from 'constants/tags'; +import ClaimList from 'component/claimList'; +import ClaimPreview from 'component/claimPreview'; +import ClaimPreviewTile from 'component/claimPreviewTile'; +import ClaimListHeader from 'component/claimListHeader'; +import useFetchViewCount from 'effects/use-fetch-view-count'; + +export type SearchOptions = { + size?: number, + from?: number, + related_to?: string, + nsfw?: boolean, + channel_id?: string, + isBackgroundSearch?: boolean, +}; + +type Props = { + uris: Array, + type: string, + pageSize: number, + + fetchViewCount?: boolean, + hideAdvancedFilter?: boolean, + hideFilters?: boolean, + infiniteScroll?: Boolean, + showHeader: boolean, + showHiddenByUser?: boolean, + tileLayout: boolean, + + defaultOrderBy?: string, + defaultFreshness?: string, + + tags: string, // these are just going to be string. pass a CSV if you want multi + defaultTags: string, + + claimType?: string | Array, + + streamType?: string | Array, + defaultStreamType?: string | Array, + + empty?: string, + feeAmount?: string, + repostedClaimId?: string, + maxPages?: number, + + channelIds?: Array, + claimId: string, + + header?: Node, + headerLabel?: string | Node, + injectedItem: ?Node, + meta?: Node, + + location: { search: string, pathname: string }, + + // --- select --- + claimsByUri: { [string]: any }, + loading: boolean, + searchResult: Array, + searchResultLastPageReached: boolean, + + // --- perform --- + doFetchViewCount: (claimIdCsv: string) => void, + doSearch: (query: string, options: SearchOptions) => void, + + hideLayoutButton?: boolean, + maxClaimRender?: number, + useSkeletonScreen?: boolean, + excludeUris?: Array, + + swipeLayout: boolean, + + showMature: boolean, + searchKeyword: string, + searchOptions: SearchOptions, +}; + +function ClaimListSearch(props: Props) { + const { + showHeader = true, + type, + tags, + defaultTags, + loading, + meta, + channelIds, + // eslint-disable-next-line no-unused-vars + claimId, + fetchViewCount, + location, + defaultOrderBy, + headerLabel, + header, + claimType, + pageSize, + streamType, + defaultStreamType = SIMPLE_SITE ? [CS.FILE_VIDEO, CS.FILE_AUDIO] : undefined, // add param for DEFAULT_STREAM_TYPE + defaultFreshness = CS.FRESH_WEEK, + repostedClaimId, + hideAdvancedFilter, + infiniteScroll = true, + injectedItem, + feeAmount, + uris, + tileLayout, + hideFilters = false, + maxPages, + showHiddenByUser = false, + empty, + claimsByUri, + doFetchViewCount, + hideLayoutButton = false, + maxClaimRender, + useSkeletonScreen = true, + excludeUris = [], + swipeLayout = false, + + // search + showMature, + searchKeyword, + searchOptions, + searchResult, + searchResultLastPageReached, + doSearch, + } = props; + const { search } = location; + const [page, setPage] = React.useState(1); + const urlParams = new URLSearchParams(search); + const tagsParam = // can be 'x,y,z' or 'x' or ['x','y'] or CS.CONSTANT + (tags && getParamFromTags(tags)) || + (urlParams.get(CS.TAGS_KEY) !== null && urlParams.get(CS.TAGS_KEY)) || + (defaultTags && getParamFromTags(defaultTags)); + const hasMatureTags = tagsParam && tagsParam.split(',').some((t) => MATURE_TAGS.includes(t)); + + const renderUris = uris || searchResult; + + // ************************************************************************** + // Helpers + // ************************************************************************** + function getParamFromTags(t) { + if (t === CS.TAGS_ALL || t === CS.TAGS_FOLLOWED) { + return t; + } else if (Array.isArray(t)) { + return t.join(','); + } + } + + function handleScrollBottom() { + if (maxPages !== undefined && page === maxPages) { + return; + } + + if (!loading && infiniteScroll) { + if (searchResult && !searchResultLastPageReached) { + setPage(page + 1); + } + } + } + + // ************************************************************************** + // ************************************************************************** + + useFetchViewCount(fetchViewCount, renderUris, claimsByUri, doFetchViewCount); + + React.useEffect(() => { + doSearch( + searchKeyword, + showMature + ? searchOptions + : { + ...searchOptions, + from: pageSize * (page - 1), + } + ); + }, [doSearch, searchKeyword, showMature, pageSize, page]); + + const headerToUse = header || ( + + ); + + return ( + + {headerLabel && } + {tileLayout ? ( +
+ {!repostedClaimId && ( +
+ {headerToUse} + {meta &&
{meta}
} +
+ )} + + {loading && useSkeletonScreen && ( +
+ {new Array(pageSize).fill(1).map((x, i) => ( + + ))} +
+ )} +
+ ) : ( +
+ {showHeader && ( +
+ {headerToUse} + {meta &&
{meta}
} +
+ )} + + {loading && + useSkeletonScreen && + new Array(pageSize).fill(1).map((x, i) => )} +
+ )} +
+ ); +} + +export default withRouter(ClaimListSearch); diff --git a/ui/constants/search.js b/ui/constants/search.js index 31c9d02d1..c29a0b80e 100644 --- a/ui/constants/search.js +++ b/ui/constants/search.js @@ -34,6 +34,7 @@ export const SEARCH_OPTIONS = { TIME_FILTER_THIS_WEEK: 'thisweek', TIME_FILTER_THIS_MONTH: 'thismonth', TIME_FILTER_THIS_YEAR: 'thisyear', + CHANNEL_ID: 'channel_id', }; export const SEARCH_PAGE_SIZE = 20; diff --git a/ui/util/query-params.js b/ui/util/query-params.js index ce28861ff..a688b3c11 100644 --- a/ui/util/query-params.js +++ b/ui/util/query-params.js @@ -80,10 +80,12 @@ export const getSearchQueryString = (query: string, options: any = {}) => { const { related_to } = options; const { nsfw } = options; const { free_only } = options; + const { channel_id } = options; if (related_to) additionalOptions[SEARCH_OPTIONS.RELATED_TO] = related_to; if (free_only) additionalOptions[SEARCH_OPTIONS.PRICE_FILTER_FREE] = true; if (nsfw === false) additionalOptions[SEARCH_OPTIONS.INCLUDE_MATURE] = false; + if (channel_id) additionalOptions[SEARCH_OPTIONS.CHANNEL_ID] = channel_id; if (additionalOptions) { Object.keys(additionalOptions).forEach((key) => {