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 <kyle.mai@wiredcraft.com>
This commit is contained in:
Evans Lyb 2022-02-16 23:01:20 +08:00 committed by GitHub
parent 2606758c0d
commit b3c4ce05fa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 400 additions and 69 deletions

View file

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

View file

@ -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<string>, 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) {
{/* <Ads type="homepage" /> */}
{!fetching && (
<ClaimListDiscover
hasSource
defaultFreshness={CS.FRESH_ALL}
showHiddenByUser={viewHiddenChannels}
forceShowReposts
fetchViewCount
hideFilters={!showFilters}
hideAdvancedFilter={!showFilters}
tileLayout={tileLayout}
uris={searchResults}
streamType={SIMPLE_SITE ? CS.CONTENT_ALL : undefined}
channelIds={[claimId]}
claimType={claimType}
feeAmount={CS.FEE_AMOUNT_ANY}
defaultOrderBy={CS.ORDER_BY_NEW}
pageSize={defaultPageSize}
infiniteScroll={defaultInfiniteScroll}
injectedItem={SHOW_ADS && !isAuthenticated && IS_WEB && <Ads type="video" />}
meta={
showFilters && (
<Form onSubmit={() => {}} className="wunderbar--inline">
<Icon icon={ICONS.SEARCH} />
<FormField
className="wunderbar__input--inline"
value={searchQuery}
onChange={handleInputChange}
type="text"
placeholder={__('Search')}
/>
</Form>
)
}
isChannel
channelIsMine={channelIsMine}
empty={empty}
/>
)}
{!fetching &&
(isSearch ? (
<ClaimListSearch
defaultFreshness={CS.FRESH_ALL}
showHiddenByUser={viewHiddenChannels}
fetchViewCount
hideFilters={!showFilters}
hideAdvancedFilter={!showFilters}
tileLayout={tileLayout}
streamType={SIMPLE_SITE ? CS.CONTENT_ALL : undefined}
channelIds={[claimId]}
claimId={claimId}
claimType={claimType}
feeAmount={CS.FEE_AMOUNT_ANY}
defaultOrderBy={CS.ORDER_BY_NEW}
pageSize={dynamicPageSize}
infiniteScroll={defaultInfiniteScroll}
injectedItem={SHOW_ADS && !isAuthenticated && IS_WEB && <Ads type="video" />}
meta={
showFilters && (
<Form onSubmit={() => {}} className="wunderbar--inline">
<Icon icon={ICONS.SEARCH} />
<FormField
className="wunderbar__input--inline"
value={searchQuery}
onChange={handleInputChange}
type="text"
placeholder={__('Search')}
/>
</Form>
)
}
channelIsMine={channelIsMine}
empty={empty}
showMature={showMature}
searchKeyword={searchQuery}
/>
) : (
<ClaimListDiscover
hasSource
defaultFreshness={CS.FRESH_ALL}
showHiddenByUser={viewHiddenChannels}
forceShowReposts
fetchViewCount
hideFilters={!showFilters}
hideAdvancedFilter={!showFilters}
tileLayout={tileLayout}
streamType={SIMPLE_SITE ? CS.CONTENT_ALL : undefined}
channelIds={[claimId]}
claimType={claimType}
feeAmount={CS.FEE_AMOUNT_ANY}
defaultOrderBy={CS.ORDER_BY_NEW}
pageSize={defaultPageSize}
infiniteScroll={defaultInfiniteScroll}
injectedItem={SHOW_ADS && !isAuthenticated && IS_WEB && <Ads type="video" />}
meta={
showFilters && (
<Form onSubmit={() => {}} className="wunderbar--inline">
<Icon icon={ICONS.SEARCH} />
<FormField
className="wunderbar__input--inline"
value={searchQuery}
onChange={handleInputChange}
type="text"
placeholder={__('Search')}
/>
</Form>
)
}
isChannel
channelIsMine={channelIsMine}
empty={empty}
/>
))}
</Fragment>
);
}

View file

@ -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);

View file

@ -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<string>,
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<string>,
streamType?: string | Array<string>,
defaultStreamType?: string | Array<string>,
empty?: string,
feeAmount?: string,
repostedClaimId?: string,
maxPages?: number,
channelIds?: Array<string>,
claimId: string,
header?: Node,
headerLabel?: string | Node,
injectedItem: ?Node,
meta?: Node,
location: { search: string, pathname: string },
// --- select ---
claimsByUri: { [string]: any },
loading: boolean,
searchResult: Array<string>,
searchResultLastPageReached: boolean,
// --- perform ---
doFetchViewCount: (claimIdCsv: string) => void,
doSearch: (query: string, options: SearchOptions) => void,
hideLayoutButton?: boolean,
maxClaimRender?: number,
useSkeletonScreen?: boolean,
excludeUris?: Array<string>,
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 || (
<ClaimListHeader
channelIds={channelIds}
defaultTags={defaultTags}
tags={tags}
defaultFreshness={defaultFreshness}
claimType={claimType}
streamType={streamType}
defaultStreamType={defaultStreamType}
feeAmount={feeAmount} // ENABLE_PAID_CONTENT_DISCOVER or something
defaultOrderBy={defaultOrderBy}
hideAdvancedFilter={hideAdvancedFilter}
hasMatureTags={hasMatureTags}
setPage={setPage}
tileLayout={tileLayout}
hideLayoutButton={hideLayoutButton}
hideFilters={hideFilters}
/>
);
return (
<React.Fragment>
{headerLabel && <label className="claim-list__header-label">{headerLabel}</label>}
{tileLayout ? (
<div>
{!repostedClaimId && (
<div className="section__header--actions">
{headerToUse}
{meta && <div className="section__actions--no-margin">{meta}</div>}
</div>
)}
<ClaimList
tileLayout
loading={loading}
uris={renderUris}
onScrollBottom={handleScrollBottom}
page={page}
pageSize={pageSize}
injectedItem={injectedItem}
showHiddenByUser={showHiddenByUser}
empty={empty}
maxClaimRender={maxClaimRender}
excludeUris={excludeUris}
swipeLayout={swipeLayout}
/>
{loading && useSkeletonScreen && (
<div className="claim-grid">
{new Array(pageSize).fill(1).map((x, i) => (
<ClaimPreviewTile key={i} placeholder="loading" />
))}
</div>
)}
</div>
) : (
<div>
{showHeader && (
<div className="section__header--actions">
{headerToUse}
{meta && <div className="section__actions--no-margin">{meta}</div>}
</div>
)}
<ClaimList
type={type}
loading={loading}
uris={renderUris}
onScrollBottom={handleScrollBottom}
page={page}
pageSize={pageSize}
injectedItem={injectedItem}
showHiddenByUser={showHiddenByUser}
empty={empty}
maxClaimRender={maxClaimRender}
excludeUris={excludeUris}
swipeLayout={swipeLayout}
/>
{loading &&
useSkeletonScreen &&
new Array(pageSize).fill(1).map((x, i) => <ClaimPreview key={i} placeholder="loading" type={type} />)}
</div>
)}
</React.Fragment>
);
}
export default withRouter(ClaimListSearch);

View file

@ -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;

View file

@ -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) => {