// @flow import type { Node } from 'react'; import classnames from 'classnames'; import React, { Fragment, useEffect, useState } from 'react'; import usePersistedState from 'effects/use-persisted-state'; 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'; import Card from 'component/common/card'; type Props = { uris: Array, subscribedChannels: Array, doClaimSearch: ({}) => void, loading: boolean, personalView: boolean, doToggleTagFollowDesktop: string => void, meta?: Node, showNsfw: boolean, hideReposts: boolean, history: { action: string, push: string => void, replace: string => void }, location: { search: string, pathname: string }, claimSearchByQuery: { [string]: Array, }, claimSearchByQueryLastPageReached: { [string]: boolean }, hiddenUris: Array, hiddenNsfwMessage?: Node, channelIds?: Array, tags: string, // these are just going to be string. pass a CSV if you want multi defaultTags: string, orderBy?: Array, defaultOrderBy?: string, freshness?: string, defaultFreshness?: string, header?: Node, headerLabel?: string | Node, name?: string, hideBlock?: boolean, hideFilter?: boolean, claimType?: Array, defaultClaimType?: Array, streamType?: string | Array, defaultStreamType?: string | Array, renderProperties?: Claim => Node, includeSupportAction?: boolean, repostedClaimId?: string, pageSize?: number, followedTags?: Array, injectedItem: ?Node, infiniteScroll?: Boolean, feeAmount?: string, }; function ClaimListDiscover(props: Props) { const { doClaimSearch, claimSearchByQuery, claimSearchByQueryLastPageReached, tags, defaultTags, loading, meta, channelIds, showNsfw, hideReposts, history, location, hiddenUris, hiddenNsfwMessage, defaultOrderBy, orderBy, headerLabel, header, name, claimType, pageSize, hideBlock, defaultClaimType, streamType, defaultStreamType, freshness, defaultFreshness = CS.FRESH_WEEK, renderProperties, includeSupportAction, repostedClaimId, hideFilter, infiniteScroll = true, followedTags, injectedItem, feeAmount, uris, } = props; const didNavigateForward = history.action === 'PUSH'; const { search } = location; const [page, setPage] = useState(1); const [forceRefresh, setForceRefresh] = useState(); const [expanded, setExpanded] = usePersistedState(`expanded-${location.pathname}`, false); const [orderParamEntry, setOrderParamEntry] = usePersistedState(`entry-${location.pathname}`, CS.ORDER_BY_TRENDING); const [orderParamUser, setOrderParamUser] = usePersistedState(`orderUser-${location.pathname}`, CS.ORDER_BY_TRENDING); const followed = (followedTags && followedTags.map(t => t.name)) || []; 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 freshnessParam = freshness || urlParams.get(CS.FRESH_KEY) || defaultFreshness; 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 channelIdsInUrl = urlParams.get(CS.CHANNEL_IDS_KEY); const channelIdsParam = channelIdsInUrl ? channelIdsInUrl.split(',') : channelIds; const feeAmountParam = urlParams.get('fee_amount') || feeAmount || CS.FEE_AMOUNT_ANY; const showDuration = !(claimType && claimType === CS.CLAIM_CHANNEL); const isFiltered = () => Boolean( urlParams.get(CS.FRESH_KEY) || urlParams.get(CS.CONTENT_KEY) || urlParams.get(CS.DURATION_KEY) || urlParams.get(CS.TAGS_KEY) || urlParams.get(CS.FEE_AMOUNT_KEY) ); useEffect(() => { if (history.action !== 'POP' && isFiltered()) { setExpanded(true); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); let orderParam = orderBy || urlParams.get(CS.ORDER_BY_KEY) || defaultOrderBy; if (!orderParam) { if (history.action === 'POP') { // Reaching here means user have popped back to the page's entry point (e.g. '/$/tags' without any '?order='). orderParam = orderParamEntry; } else { // This is the direct entry into the page, so we load the user's previous value. orderParam = orderParamUser; } } useEffect(() => { setOrderParamUser(orderParam); }, [orderParam]); useEffect(() => { // One-time update to stash the finalized 'orderParam' at entry. if (history.action !== 'POP') { setOrderParamEntry(orderParam); } }, []); let options: { page_size: number, page: number, no_totals: boolean, any_tags?: Array, not_tags: Array, channel_ids: Array, not_channel_ids: Array, order_by: Array, release_time?: string, claim_type?: Array, name?: string, duration?: string, reposted_claim_id?: string, stream_types?: any, fee_amount?: string, } = { 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, channel_ids: channelIdsParam || [], not_channel_ids: // If channelIdsParam were passed in, we don't need not_channel_ids !channelIdsParam && 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 (repostedClaimId) { // SDK chokes on reposted_claim_id of null or false, needs to not be present if no value options.reposted_claim_id = repostedClaimId; } 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 && options.channel_ids.length > 20) || (options.any_tags && options.any_tags.length > 20) ) { options.release_time = `>${Math.floor( moment() .subtract(3, CS.FRESH_MONTH) .startOf('week') .unix() )}`; } else if ( (options.channel_ids && options.channel_ids.length > 10) || (options.any_tags && 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 (feeAmountParam) { options.fee_amount = feeAmountParam; } 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) { if (Array.isArray(claimTypeParam)) { options.claim_type = claimTypeParam; } else { options.claim_type = [claimTypeParam]; } } } if (tagsParam) { if (tagsParam !== CS.TAGS_ALL && tagsParam !== '') { if (tagsParam === CS.TAGS_FOLLOWED) { options.any_tags = followed; } else if (Array.isArray(tagsParam)) { options.any_tags = tagsParam; } else { options.any_tags = tagsParam.split(','); } } } // https://github.com/lbryio/lbry-desktop/issues/3774 if (hideReposts && !options.reposted_claim_id) { // and not claimrepostid if (Array.isArray(options.claim_type)) { if (options.claim_type.length > 1) { options.claim_type = options.claim_type.filter(claimType => claimType !== 'repost'); } } else { options.claim_type = ['stream', 'channel']; } } const hasMatureTags = tagsParam && tagsParam.split(',').some(t => MATURE_TAGS.includes(t)); const claimSearchCacheQuery = createNormalizedClaimSearchKey(options); const claimSearchResult = claimSearchByQuery[claimSearchCacheQuery]; const claimSearchResultLastPageReached = claimSearchByQueryLastPageReached[claimSearchCacheQuery]; const [prevOptions, setPrevOptions] = useState(null); if (!isJustScrollingToNewPage(prevOptions, options)) { // --- New search, or search options changed. setPrevOptions(options); if (didNavigateForward) { // --- Reset to top. window.scrollTo(0, 0); // Prevents onScrollBottom() from re-hitting while waiting for doClaimQuery(): options.page = 1; setPage(options.page); } else if (claimSearchResult) { // --- Update 'page' based on retrieved 'claimSearchResult'. options.page = Math.ceil(claimSearchResult.length / CS.PAGE_SIZE); if (options.page !== page) { setPage(options.page); } } } const shouldPerformSearch = claimSearchResult === undefined || didNavigateForward || (!loading && claimSearchResult && claimSearchResult.length && claimSearchResult.length < CS.PAGE_SIZE * options.page && claimSearchResult.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 timedOutMessage = (

setForceRefresh(Date.now())} /> ), }} > Sorry, your request timed out. Modify your options or %again%

, }} > If you continue to have issues, please %contact_support%.

); // Returns true if the change in 'options' indicate that we are simply scrolling // down to a new page; false otherwise. function isJustScrollingToNewPage(prevOptions, options) { if (!prevOptions) { // It's a new search, or we just popped back from a different view. return false; } // Compare every field except for 'page' and 'release_time'. // There might be better ways to achieve this. let tmpPrevOptions = { ...prevOptions }; tmpPrevOptions.page = -1; tmpPrevOptions.release_time = ''; let tmpOptions = { ...options }; tmpOptions.page = -1; tmpOptions.release_time = ''; return JSON.stringify(tmpOptions) === JSON.stringify(tmpPrevOptions); } function handleChange(change) { const url = buildUrl(change); setPage(1); history.push(url); } function handleAdvancedReset() { const newUrlParams = new URLSearchParams(search); newUrlParams.delete('claim_type'); newUrlParams.delete('channel_ids'); const newSearch = `?${newUrlParams.toString()}`; history.push(newSearch); } function getParamFromTags(t) { if (t === CS.TAGS_ALL || t === CS.TAGS_FOLLOWED) { return t; } else if (Array.isArray(t)) { return t.join(','); } } function buildUrl(delta) { const newUrlParams = new URLSearchParams(location.search); 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: if (delta.value === defaultFreshness || delta.value === CS.FRESH_DEFAULT) { newUrlParams.delete(CS.FRESH_KEY); } else { 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; case CS.TAGS_KEY: if (delta.value === CS.TAGS_ALL) { if (defaultTags === CS.TAGS_ALL) { newUrlParams.delete(CS.TAGS_KEY); } else { newUrlParams.set(CS.TAGS_KEY, delta.value); } } else if (delta.value === CS.TAGS_FOLLOWED) { if (defaultTags === CS.TAGS_FOLLOWED) { newUrlParams.delete(CS.TAGS_KEY); } else { newUrlParams.set(CS.TAGS_KEY, delta.value); // redundant but special } } else { newUrlParams.set(CS.TAGS_KEY, delta.value); } break; case CS.FEE_AMOUNT_KEY: if (delta.value === CS.FEE_AMOUNT_ANY) { newUrlParams.delete(CS.FEE_AMOUNT_KEY); } else { newUrlParams.set(CS.FEE_AMOUNT_KEY, delta.value); } break; } return `?${newUrlParams.toString()}`; } function handleScrollBottom() { if (!loading && infiniteScroll) { if (claimSearchResult && !claimSearchResultLastPageReached) { setPage(page + 1); } } } useEffect(() => { if (shouldPerformSearch) { const searchOptions = JSON.parse(optionsStringForEffect); doClaimSearch(searchOptions); } }, [doClaimSearch, shouldPerformSearch, optionsStringForEffect, forceRefresh]); const defaultHeader = repostedClaimId ? null : (
{CS.ORDER_BY_TYPES.map(type => (
{!hideFilter && (
{expanded && ( <>
{/* FRESHNESS FIELD */} {orderParam === CS.ORDER_BY_TOP && (
handleChange({ key: CS.FRESH_KEY, value: e.target.value, }) } > {CS.FRESH_TYPES.map(time => ( ))}
)} {/* CONTENT_TYPES FIELD */} {!claimType && (
handleChange({ key: CS.CONTENT_KEY, value: e.target.value, }) } > {CS.CONTENT_TYPES.map(type => { if (type !== CS.CLAIM_CHANNEL || (type === CS.CLAIM_CHANNEL && !channelIdsParam)) { return ( ); } })}
)} {/* DURATIONS FIELD */} {showDuration && (
handleChange({ key: CS.DURATION_KEY, value: e.target.value, }) } > {CS.DURATION_TYPES.map(dur => ( ))}
)} {/* TAGS FIELD */} {!tags && (
handleChange({ key: CS.TAGS_KEY, value: e.target.value, }) } > {[ CS.TAGS_ALL, CS.TAGS_FOLLOWED, ...followed, ...(followed.includes(tagsParam) || tagsParam === CS.TAGS_ALL || tagsParam === CS.TAGS_FOLLOWED ? [] : [tagsParam]), // if they unfollow while filtered, add Other ].map(tag => ( ))}
)} {/* PAID FIELD */}
handleChange({ key: CS.FEE_AMOUNT_KEY, value: e.target.value, }) } > ))}
{channelIdsInUrl && (
)}
)}
{hasMatureTags && hiddenNsfwMessage}
); return ( {headerLabel && } {meta}} isBodyList body={ <> {loading && new Array(pageSize || CS.PAGE_SIZE).fill(1).map((x, i) => )} } /> ); } export default withRouter(ClaimListDiscover);