// @flow import type { Node } from 'react'; import classnames from 'classnames'; import React, { Fragment, useEffect, useState } from 'react'; import { withRouter } from 'react-router'; 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'; const PAGE_SIZE = 20; export const TIME_DAY = 'day'; export const TIME_WEEK = 'week'; export const TIME_MONTH = 'month'; export const TIME_YEAR = 'year'; export const TIME_ALL = 'all'; export const TYPE_TRENDING = 'trending'; export const TYPE_TOP = 'top'; export const TYPE_NEW = 'new'; const SEARCH_TYPES = [TYPE_TRENDING, TYPE_NEW, TYPE_TOP]; const SEARCH_TIMES = [TIME_DAY, TIME_WEEK, TIME_MONTH, TIME_YEAR, TIME_ALL]; type Props = { uris: Array<string>, subscribedChannels: Array<Subscription>, doClaimSearch: ({}) => void, tags: Array<string>, loading: boolean, personalView: boolean, doToggleTagFollow: string => void, meta?: Node, showNsfw: 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>, defaultTypeSort?: string, defaultTimeSort?: string, defaultOrderBy?: Array<string>, header?: Node, headerLabel?: string | Node, name?: string, pageSize?: number, claimType?: string | Array<string>, renderProperties?: Claim => Node, includeSupportAction?: boolean, hideBlock: boolean, }; function ClaimListDiscover(props: Props) { const { doClaimSearch, claimSearchByQuery, tags, loading, personalView, meta, channelIds, showNsfw, history, location, hiddenUris, hiddenNsfwMessage, defaultTypeSort, defaultTimeSort, defaultOrderBy, headerLabel, header, name, claimType, pageSize, renderProperties, includeSupportAction, hideBlock, } = props; const didNavigateForward = history.action === 'PUSH'; const [page, setPage] = useState(1); const { search } = location; const [forceRefresh, setForceRefresh] = useState(); const urlParams = new URLSearchParams(search); const typeSort = urlParams.get('type') || defaultTypeSort || TYPE_TRENDING; const timeSort = urlParams.get('time') || defaultTimeSort || TIME_WEEK; const tagsInUrl = urlParams.get('t') || ''; const options: { page_size: number, page: number, no_totals: boolean, any_tags: Array<string>, channel_ids: Array<string>, not_channel_ids: Array<string>, not_tags: Array<string>, order_by: Array<string>, release_time?: string, name?: string, claim_type?: string | Array<string>, } = { page_size: pageSize || PAGE_SIZE, page, name, // 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: tags || [], 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: defaultOrderBy || (typeSort === TYPE_TRENDING ? ['trending_group', 'trending_mixed'] : typeSort === TYPE_NEW ? ['release_time'] : ['effective_amount']), // Sort by top }; if (typeSort === TYPE_TOP && timeSort !== TIME_ALL) { options.release_time = `>${Math.floor( moment() .subtract(1, timeSort) .startOf('hour') .unix() )}`; } else if (typeSort === TYPE_NEW || typeSort === TYPE_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(6, TIME_MONTH) .startOf('week') .unix() )}`; } else if (options.channel_ids.length > 10 || options.any_tags.length > 10) { options.release_time = `>${Math.floor( moment() .subtract(1, TIME_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 (claimType) { options.claim_type = claimType; } 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 < PAGE_SIZE * page && uris.length % 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={__('Please try again in a few seconds.')} onClick={() => setForceRefresh(Date.now())} /> ), }} > Sorry, your request timed out. %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 getSearch() { let search = `?`; if (!personalView) { search += `t=${tagsInUrl}&`; } return search; } function handleTypeSort(newTypeSort) { let url = `${getSearch()}type=${newTypeSort}`; if (newTypeSort === TYPE_TOP) { url += `&time=${timeSort}`; } setPage(1); history.push(url); } function handleTimeSort(newTimeSort) { setPage(1); history.push(`${getSearch()}type=${typeSort}&time=${newTimeSort}`); } function handleScrollBottom() { if (!loading) { setPage(page + 1); } } useEffect(() => { if (shouldPerformSearch) { const searchOptions = JSON.parse(optionsStringForEffect); doClaimSearch(searchOptions); } }, [doClaimSearch, shouldPerformSearch, optionsStringForEffect, forceRefresh]); const defaultHeader = ( <Fragment> {SEARCH_TYPES.map(type => ( <Button key={type} button="alt" onClick={() => handleTypeSort(type)} className={classnames(`button-toggle button-toggle--${type}`, { 'button-toggle--active': typeSort === type, })} icon={toCapitalCase(type)} label={__(toCapitalCase(type))} /> ))} {typeSort === 'top' && ( <FormField className="claim-list__dropdown" type="select" name="trending_time" value={timeSort} onChange={e => handleTimeSort(e.target.value)} > {SEARCH_TIMES.map(time => ( <option key={time} value={time}> {/* i18fixme */} {time === TIME_DAY && __('Today')} {time !== TIME_ALL && time !== TIME_DAY && __('This ' + toCapitalCase(time)) /* yes, concat before i18n, since it is read from const */} {time === TIME_ALL && __('All time')} </option> ))} </FormField> )} {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={PAGE_SIZE} empty={noResults} renderProperties={renderProperties} includeSupportAction={includeSupportAction} hideBlock={hideBlock} /> <div className="card"> {loading && new Array(PAGE_SIZE).fill(1).map((x, i) => <ClaimPreview key={i} placeholder="loading" />)} </div> </React.Fragment> ); } export default withRouter(ClaimListDiscover);