lbry-desktop/ui/component/claimListDiscover/view.jsx
jessop 9dc9d50e19 add options card to claim search
changes

almost done

wip

wip

more

changes after comment

detect custom qs and show options

ux and mobile styling

bugfix

console logs

appstrings
2020-02-28 13:30:29 -05:00

479 lines
16 KiB
JavaScript

// @flow
import type { Node } from 'react';
import classnames from 'classnames';
import React, { Fragment, useEffect, useState } from 'react';
import { withRouter } from 'react-router';
import * as CS from 'constants/claim_search';
import { createNormalizedClaimSearchKey, MATURE_TAGS } from 'lbry-redux';
import { FormField } from 'component/common/form';
import Button from 'component/button';
import moment from 'moment';
import ClaimList from 'component/claimList';
import ClaimPreview from 'component/claimPreview';
import { toCapitalCase } from 'util/string';
import I18nMessage from 'component/i18nMessage';
import * as ICONS from 'constants/icons';
type Props = {
uris: Array<string>,
subscribedChannels: Array<Subscription>,
doClaimSearch: ({}) => void,
loading: boolean,
personalView: boolean,
doToggleTagFollow: string => void,
meta?: Node,
showNsfw: boolean,
showReposts: boolean,
history: { action: string, push: string => void, replace: string => void },
location: { search: string, pathname: string },
claimSearchByQuery: {
[string]: Array<string>,
},
hiddenUris: Array<string>,
hiddenNsfwMessage?: Node,
channelIds?: Array<string>,
tags: Array<string>,
orderBy?: Array<string>,
defaultOrderBy?: string,
freshness?: string,
defaultFreshness?: string,
header?: Node,
headerLabel?: string | Node,
name?: string,
hideBlock?: boolean,
claimType?: string | Array<string>,
defaultClaimType?: string | Array<string>,
streamType?: string | Array<string>,
defaultStreamType?: string | Array<string>,
renderProperties?: Claim => Node,
includeSupportAction?: boolean,
noCustom?: boolean,
};
function ClaimListDiscover(props: Props) {
const {
doClaimSearch,
claimSearchByQuery,
tags,
loading,
meta,
channelIds,
showNsfw,
showReposts,
history,
location,
hiddenUris,
hiddenNsfwMessage,
defaultOrderBy,
orderBy,
headerLabel,
header,
name,
claimType,
pageSize,
hideBlock,
defaultClaimType,
streamType,
defaultStreamType,
freshness,
defaultFreshness,
renderProperties,
includeSupportAction,
noCustom,
} = props;
const didNavigateForward = history.action === 'PUSH';
const { search } = location;
const [page, setPage] = useState(1);
const [forceRefresh, setForceRefresh] = useState();
const [expanded, setExpanded] = useState(false);
const urlParams = new URLSearchParams(search);
const tagsParam = tags || urlParams.get(CS.TAGS_KEY) || null;
const orderParam = orderBy || urlParams.get(CS.ORDER_BY_KEY) || defaultOrderBy || CS.ORDER_BY_TRENDING;
const freshnessParam = freshness || urlParams.get(CS.FRESH_KEY) || defaultFreshness || CS.FRESH_WEEK;
const contentTypeParam = urlParams.get(CS.CONTENT_KEY);
const claimTypeParam =
claimType || (CS.CLAIM_TYPES.includes(contentTypeParam) && contentTypeParam) || defaultClaimType || null;
const streamTypeParam =
streamType || (CS.FILE_TYPES.includes(contentTypeParam) && contentTypeParam) || defaultStreamType || null;
const durationParam = urlParams.get(CS.DURATION_KEY) || null;
const isFiltered = () =>
Boolean(urlParams.get(CS.FRESH_KEY) || urlParams.get(CS.CONTENT_KEY) || urlParams.get(CS.DURATION_KEY));
useEffect(() => {
if (isFiltered()) setExpanded(true);
}, []);
const options: {
page_size: number,
page: number,
no_totals: boolean,
any_tags: Array<string>,
not_tags: Array<string>,
channel_ids: Array<string>,
not_channel_ids: Array<string>,
order_by: Array<string>,
release_time?: string,
claim_type?: Array<string>,
name?: string,
claim_type?: Array<string>,
duration?: string,
claim_type?: string | Array<string>,
stream_types?: any,
} = {
page_size: pageSize || CS.PAGE_SIZE,
page,
name,
claim_type: claimType || undefined,
// no_totals makes it so the sdk doesn't have to calculate total number pages for pagination
// it's faster, but we will need to remove it if we start using total_pages
no_totals: true,
any_tags: tagsParam || [],
channel_ids: channelIds || [],
not_channel_ids:
// If channelIds were passed in, we don't need not_channel_ids
!channelIds && hiddenUris && hiddenUris.length ? hiddenUris.map(hiddenUri => hiddenUri.split('#')[1]) : [],
not_tags: !showNsfw ? MATURE_TAGS : [],
order_by:
orderParam === CS.ORDER_BY_TRENDING
? CS.ORDER_BY_TRENDING_VALUE
: orderParam === CS.ORDER_BY_NEW
? CS.ORDER_BY_NEW_VALUE
: CS.ORDER_BY_TOP_VALUE, // Sort by top
};
if (orderParam === CS.ORDER_BY_TOP && freshnessParam !== CS.FRESH_ALL) {
options.release_time = `>${Math.floor(
moment()
.subtract(1, freshnessParam)
.startOf('hour')
.unix()
)}`;
} else if (orderParam === CS.ORDER_BY_NEW || orderParam === CS.ORDER_BY_TRENDING) {
// Warning - hack below
// If users are following more than 10 channels or tags, limit results to stuff less than a year old
// For more than 20, drop it down to 6 months
// This helps with timeout issues for users that are following a ton of stuff
// https://github.com/lbryio/lbry-sdk/issues/2420
if (options.channel_ids.length > 20 || options.any_tags.length > 20) {
options.release_time = `>${Math.floor(
moment()
.subtract(3, CS.FRESH_MONTH)
.startOf('week')
.unix()
)}`;
} else if (options.channel_ids.length > 10 || options.any_tags.length > 10) {
options.release_time = `>${Math.floor(
moment()
.subtract(1, CS.FRESH_YEAR)
.startOf('week')
.unix()
)}`;
} else {
// Hack for at least the New page until https://github.com/lbryio/lbry-sdk/issues/2591 is fixed
options.release_time = `<${Math.floor(
moment()
.startOf('minute')
.unix()
)}`;
}
}
if (durationParam) {
if (durationParam === CS.DURATION_SHORT) {
options.duration = '<=1800';
} else if (durationParam === CS.DURATION_LONG) {
options.duration = '>=1800';
}
}
if (streamTypeParam) {
if (streamTypeParam !== CS.CONTENT_ALL) {
options.stream_types = [streamTypeParam];
}
}
if (claimTypeParam) {
if (claimTypeParam !== CS.CONTENT_ALL) {
options.claim_type = [claimTypeParam];
}
}
if (!showReposts) {
options.claim_type =
options.claim_type === undefined
? ['stream', 'channel']
: options.claim_type.filter(claimType => claimType !== 'repost');
}
const hasMatureTags = tags && tags.some(t => MATURE_TAGS.includes(t));
const claimSearchCacheQuery = createNormalizedClaimSearchKey(options);
const uris = claimSearchByQuery[claimSearchCacheQuery] || [];
const shouldPerformSearch =
uris.length === 0 ||
didNavigateForward ||
(!loading && uris.length < CS.PAGE_SIZE * page && uris.length % CS.PAGE_SIZE === 0);
// Don't use the query from createNormalizedClaimSearchKey for the effect since that doesn't include page & release_time
const optionsStringForEffect = JSON.stringify(options);
const noResults = (
<div>
<p>
<I18nMessage
tokens={{
again: (
<Button
button="link"
label={__('try again in a few seconds.')}
onClick={() => setForceRefresh(Date.now())}
/>
),
}}
>
Sorry, your request timed out. Modify your options or %again%
</I18nMessage>
</p>
<p>
<I18nMessage
tokens={{
contact_support: <Button button="link" label={__('contact support')} href="https://lbry.com/faq/support" />,
}}
>
If you continue to have issues, please %contact_support%.
</I18nMessage>
</p>
</div>
);
function handleChange(change) {
const url = buildUrl(change);
setPage(1);
history.push(url);
}
function buildUrl(delta) {
const newUrlParams = new URLSearchParams();
CS.KEYS.forEach(k => {
// $FlowFixMe append() can't take null as second arg, but get() can return null
if (urlParams.get(k) !== null) newUrlParams.append(k, urlParams.get(k));
});
switch (delta.key) {
case CS.ORDER_BY_KEY:
newUrlParams.set(CS.ORDER_BY_KEY, delta.value);
break;
case CS.FRESH_KEY:
newUrlParams.set(CS.FRESH_KEY, delta.value);
break;
case CS.CONTENT_KEY:
if (delta.value === CS.CLAIM_CHANNEL || delta.value === CS.CLAIM_REPOST) {
newUrlParams.delete(CS.DURATION_KEY);
newUrlParams.set(CS.CONTENT_KEY, delta.value);
} else if (delta.value === CS.CONTENT_ALL) {
newUrlParams.delete(CS.CONTENT_KEY);
} else {
newUrlParams.set(CS.CONTENT_KEY, delta.value);
}
break;
case CS.DURATION_KEY:
if (delta.value === CS.DURATION_ALL) {
newUrlParams.delete(CS.DURATION_KEY);
} else {
newUrlParams.set(CS.DURATION_KEY, delta.value);
}
break;
}
return `?${newUrlParams.toString()}`;
}
function handleScrollBottom() {
if (!loading) {
setPage(page + 1);
}
}
useEffect(() => {
if (shouldPerformSearch) {
const searchOptions = JSON.parse(optionsStringForEffect);
doClaimSearch(searchOptions);
}
}, [doClaimSearch, shouldPerformSearch, optionsStringForEffect, forceRefresh]);
const defaultHeader = (
<Fragment>
<div className={'claim-search__wrapper'}>
<div className={'claim-search__top'}>
<div className={'claim-search__top-row'}>
{CS.ORDER_BY_TYPES.map(type => (
<Button
key={type}
button="alt"
onClick={e =>
handleChange({
key: CS.ORDER_BY_KEY,
value: type,
})
}
className={classnames(`button-toggle button-toggle--${type}`, {
'button-toggle--active': orderParam === type,
})}
disabled={orderBy}
icon={toCapitalCase(type)}
label={__(toCapitalCase(type))}
/>
))}
</div>
<div>
{!noCustom && (
<Button
button={'alt'}
aria-label={__('More')}
className={classnames(`button-toggle button-toggle--top button-toggle--more`, {
'button-toggle--custom': isFiltered(),
})}
icon={toCapitalCase(ICONS.SLIDERS)}
onClick={() => setExpanded(!expanded)}
/>
)}
</div>
</div>
{expanded && (
<>
<div className={classnames('card--inline', `claim-search__menus`)}>
{/* FRESHNESS FIELD */}
{orderParam === CS.ORDER_BY_TOP && (
<div className={'claim-search__input-container'}>
<FormField
className="claim-search__dropdown"
type="select"
name="trending_time"
label={__('How Fresh')}
value={freshnessParam}
onChange={e =>
handleChange({
key: CS.FRESH_KEY,
value: e.target.value,
})
}
>
{CS.FRESH_TYPES.map(time => (
<option key={time} value={time}>
{/* i18fixme */}
{time === CS.FRESH_DAY && __('Today')}
{time !== CS.FRESH_ALL &&
time !== CS.FRESH_DAY &&
__('This ' + toCapitalCase(time)) /* yes, concat before i18n, since it is read from const */}
{time === CS.FRESH_ALL && __('All time')}
</option>
))}
</FormField>
</div>
)}
{/* CONTENT_TYPES FIELD */}
<div
className={classnames('claim-search__input-container', {
'claim-search__input-container--selected': contentTypeParam,
})}
>
<FormField
className={classnames('claim-search__dropdown', {
'claim-search__dropdown--selected': contentTypeParam,
})}
type="select"
name="claimType"
label={__('Content Type')}
value={contentTypeParam || CS.CONTENT_ALL}
onChange={e =>
handleChange({
key: CS.CONTENT_KEY,
value: e.target.value,
})
}
>
{CS.CONTENT_TYPES.map(type => {
if (type !== CS.CLAIM_CHANNEL || (type === CS.CLAIM_CHANNEL && !channelIds)) {
return (
<option key={type} value={type}>
{/* i18fixme */}
{type === CS.CLAIM_CHANNEL && __('Channel')}
{type === CS.CLAIM_REPOST && __('Repost')}
{type === CS.FILE_VIDEO && __('Video')}
{type === CS.FILE_AUDIO && __('Audio')}
{type === CS.FILE_DOCUMENT && __('Document')}
{type === CS.CONTENT_ALL && __('Any')}
</option>
);
}
})}
</FormField>
</div>
{options.claim_type !== CS.CLAIM_CHANNEL &&
options.claim_type !== CS.CLAIM_REPOST &&
JSON.stringify(options.stream_types) !== JSON.stringify([CS.FILE_DOCUMENT]) && (
<>
{/* DURATIONS FIELD */}
<div className={'claim-search__input-container'}>
<FormField
className={classnames('claim-search__dropdown', {
'claim-search__dropdown--selected': durationParam,
})}
label={__('Duration')}
type="select"
name="duration"
value={durationParam || CS.DURATION_ALL}
onChange={e =>
handleChange({
key: CS.DURATION_KEY,
value: e.target.value,
})
}
>
{CS.DURATION_TYPES.map(dur => (
<option key={dur} value={dur}>
{/* i18fixme */}
{dur === CS.DURATION_SHORT && __('Short')}
{dur === CS.DURATION_LONG && __('Long')}
{dur === CS.DURATION_ALL && __('Any')}
</option>
))}
</FormField>
</div>
</>
)}
</div>
</>
)}
</div>
{hasMatureTags && hiddenNsfwMessage}
</Fragment>
);
return (
<React.Fragment>
<ClaimList
id={claimSearchCacheQuery}
loading={loading}
uris={uris}
header={header || defaultHeader}
headerLabel={headerLabel}
headerAltControls={meta}
onScrollBottom={handleScrollBottom}
page={page}
pageSize={CS.PAGE_SIZE}
empty={noResults}
renderProperties={renderProperties}
includeSupportAction={includeSupportAction}
hideBlock={hideBlock}
/>
<div className="card">
{loading && new Array(CS.PAGE_SIZE).fill(1).map((x, i) => <ClaimPreview key={i} placeholder="loading" />)}
</div>
</React.Fragment>
);
}
export default withRouter(ClaimListDiscover);