From 3b47edc3b9744418d8ea78bd30dcd2671633aebb Mon Sep 17 00:00:00 2001 From: infinite-persistence <64950861+infinite-persistence@users.noreply.github.com> Date: Fri, 24 Sep 2021 22:26:21 +0800 Subject: [PATCH] Livestream category improvements (#7115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ❌ Remove old method of displaying active livestreams Completely remove it for now to make the commit deltas clearer. We'll replace it with the new method at the end. * Fetch and store active-livestream info in redux * Tiles can now query active-livestream state from redux instead of getting from parent. * ⏪ ClaimTilesDiscover: revert and cleanup ## Simplify - Simplify to just `uris` instead of having multiple arrays (`uris`, `modifiedUris`, `prevUris`) - The `prevUris` is for CLS prevention. With this removal, the CLS issue is back, but we'll handle it differently later. - Temporarily disable the view-count fetching. Code is left there so that I don't forget. ## Fix - `shouldPerformSearch` was never true when `prefixUris` is present. Corrected the logic. - Aside: prefix and pin is so similar in function. Hm .... * ClaimTilesDiscover: factor out options ## Change Move the `option` code outside and passed in as a pre-calculated prop. ## Reason To skip rendering while waiting for `claim_search`, we need to add `React.memo(areEqual)`. However, the flag that determines if we are fetching `claim_search` (fetchingClaimSearchByQuery[]) depends on the derived options as the key. Instead of calculating `options` twice, we moved it to the props so both sides can use it. It also makes the component a bit more readable. The downside is that the prop-passing might not be clear. * ClaimTilesDiscover: reduce ~17 renders at startup to just 2. * ClaimTilesDiscover: fill with placeholder while waiting for claim_search ## Issue Livestream claims are fetched seperately, so they might already exists. While claim_search is running, the list only consists of livestreams (collapsed). ## Fix Fill up the space with placeholders to prevent layout shift. * Add 'useFetchViewCount' to handle fetching from lists This effect also stashes fetched uris, so that we won't re-fetch the same uris during the same instance (e.g. during infinite scroll). * ⏪ ClaimListDiscover: revert and cleanup ## Revert - Removed the 'finalUris' stuff that was meant to "pause" visual changes when fetching. I think it'll be cleaner to use React.memo to achieve that. ## Alterations - Added `renderUri` to make it clear which array that this component will render. - Re-do the way we fetch view counts now that 'finalUris' is gone. Not the best method, but at least correct for now. * ClaimListDiscover: add prefixUris, similar to ClaimTilesDiscover This will be initially used to append livestreams at the top. * ✅ Re-enable active livestream tiles using the new method * doFetchActiveLivestreams: add interval check - Added a default minimum of 5 minutes between fetches. Clients can bypass this through `forceFetch` if needed. * doFetchActiveLivestreams: add option check We'll need to support different 'orderBy', so adding an "options check" when determining if we just made the same fetch. * WildWest: limit livestream tiles + add ability to show more Most likely this behavior will change in the future, so we'll leave `ClaimListDiscover` untouched and handle the logic at the page level. This solution uses 2 `ClaimListDiscover` -- if the reduced livestream list is visible, it handles the header; else the normal list handles the header. * Use better tile-count on larger screens. Used the same method as how the homepage does it. --- flow-typed/livestream.js | 14 + ui/component/claimList/index.js | 4 +- ui/component/claimList/view.jsx | 32 +- ui/component/claimListDiscover/view.jsx | 96 +----- ui/component/claimPreview/index.js | 2 + ui/component/claimPreview/view.jsx | 10 +- ui/component/claimPreviewTile/index.js | 2 + ui/component/claimPreviewTile/view.jsx | 8 +- ui/component/claimTilesDiscover/index.js | 127 ++++++- ui/component/claimTilesDiscover/view.jsx | 411 ++++++++--------------- ui/constants/action_types.js | 4 + ui/effects/use-fetch-view-count.js | 24 ++ ui/effects/use-get-livestreams.js | 55 --- ui/page/channelsFollowing/index.js | 9 +- ui/page/channelsFollowing/view.jsx | 18 +- ui/page/discover/index.js | 4 + ui/page/discover/view.jsx | 132 +++++--- ui/page/home/index.js | 7 +- ui/page/home/view.jsx | 23 +- ui/redux/actions/livestream.js | 88 +++++ ui/redux/reducers/livestream.js | 20 ++ ui/redux/selectors/livestream.js | 20 ++ ui/scss/component/_livestream.scss | 6 + ui/util/livestream.js | 24 ++ ui/util/search.js | 19 -- 25 files changed, 631 insertions(+), 528 deletions(-) create mode 100644 ui/effects/use-fetch-view-count.js delete mode 100644 ui/effects/use-get-livestreams.js create mode 100644 ui/util/livestream.js diff --git a/flow-typed/livestream.js b/flow-typed/livestream.js index 40ed13f19..9bee62676 100644 --- a/flow-typed/livestream.js +++ b/flow-typed/livestream.js @@ -24,4 +24,18 @@ declare type LivestreamReplayData = Array; declare type LivestreamState = { fetchingById: {}, viewersById: {}, + fetchingActiveLivestreams: boolean, + activeLivestreams: ?LivestreamInfo, + activeLivestreamsLastFetchedDate: number, + activeLivestreamsLastFetchedOptions: {}, +} + +declare type LivestreamInfo = { + [/* creatorId */ string]: { + live: boolean, + viewCount: number, + creatorId: string, + latestClaimId: string, + latestClaimUri: string, + } } diff --git a/ui/component/claimList/index.js b/ui/component/claimList/index.js index 67a4024f0..d9d20509c 100644 --- a/ui/component/claimList/index.js +++ b/ui/component/claimList/index.js @@ -1,12 +1,10 @@ import { connect } from 'react-redux'; import ClaimList from './view'; -import { SETTINGS, selectClaimSearchByQuery, selectClaimsByUri } from 'lbry-redux'; +import { SETTINGS } from 'lbry-redux'; import { makeSelectClientSetting } from 'redux/selectors/settings'; const select = (state) => ({ searchInLanguage: makeSelectClientSetting(SETTINGS.SEARCH_IN_LANGUAGE)(state), - claimSearchByQuery: selectClaimSearchByQuery(state), - claimsByUri: selectClaimsByUri(state), }); export default connect(select)(ClaimList); diff --git a/ui/component/claimList/view.jsx b/ui/component/claimList/view.jsx index 4da171d4f..bdfd5450a 100644 --- a/ui/component/claimList/view.jsx +++ b/ui/component/claimList/view.jsx @@ -9,7 +9,6 @@ import { FormField } from 'component/common/form'; import usePersistedState from 'effects/use-persisted-state'; import debounce from 'util/debounce'; import ClaimPreviewTile from 'component/claimPreviewTile'; -import { prioritizeActiveLivestreams } from 'component/claimTilesDiscover/view'; const DEBOUNCE_SCROLL_HANDLER_MS = 150; const SORT_NEW = 'new'; @@ -17,6 +16,7 @@ const SORT_OLD = 'old'; type Props = { uris: Array, + prefixUris?: Array, header: Node | boolean, headerAltControls: Node, loading: boolean, @@ -41,9 +41,6 @@ type Props = { hideMenu?: boolean, claimSearchByQuery: { [string]: Array }, claimsByUri: { [string]: any }, - liveLivestreamsFirst?: boolean, - livestreamMap?: { [string]: any }, - searchOptions?: any, collectionId?: string, showNoSourceClaims?: boolean, onClick?: (e: any, claim?: ?Claim, index?: number) => void, @@ -53,6 +50,7 @@ export default function ClaimList(props: Props) { const { activeUri, uris, + prefixUris, headerAltControls, loading, persistedStorageKey, @@ -73,37 +71,25 @@ export default function ClaimList(props: Props) { renderProperties, searchInLanguage, hideMenu, - claimSearchByQuery, - claimsByUri, - liveLivestreamsFirst, - livestreamMap, - searchOptions, collectionId, showNoSourceClaims, onClick, } = props; const [currentSort, setCurrentSort] = usePersistedState(persistedStorageKey, SORT_NEW); + + // Exclude prefix uris in these results variables. We don't want to show + // anything if the search failed or timed out. const timedOut = uris === null; const urisLength = (uris && uris.length) || 0; - const liveUris = []; - if (liveLivestreamsFirst && livestreamMap) { - prioritizeActiveLivestreams(uris, liveUris, livestreamMap, claimsByUri, claimSearchByQuery, searchOptions); - } + const tileUris = (prefixUris || []).concat(uris); + const sortedUris = (urisLength > 0 && (currentSort === SORT_NEW ? tileUris : tileUris.slice().reverse())) || []; - const sortedUris = (urisLength > 0 && (currentSort === SORT_NEW ? uris : uris.slice().reverse())) || []; const noResultMsg = searchInLanguage ? __('No results. Contents may be hidden by the Language filter.') : __('No results'); - const resolveLive = (index) => { - if (liveLivestreamsFirst && livestreamMap && index < liveUris.length) { - return true; - } - return undefined; - }; - function handleSortChange() { setCurrentSort(currentSort === SORT_NEW ? SORT_OLD : SORT_NEW); } @@ -138,13 +124,12 @@ export default function ClaimList(props: Props) { return tileLayout && !header ? (
{urisLength > 0 && - uris.map((uri, index) => ( + tileUris.map((uri) => ( @@ -216,7 +201,6 @@ export default function ClaimList(props: Props) { // https://github.com/lbryio/lbry-redux/blob/master/src/redux/actions/publish.js#L74-L79 return claim.name.length === 24 && !claim.name.includes(' ') && claim.value.author === 'Spee.ch'; }} - live={resolveLive(index)} onClick={(e, claim, index) => handleClaimClicked(e, claim, index)} /> diff --git a/ui/component/claimListDiscover/view.jsx b/ui/component/claimListDiscover/view.jsx index f04929183..e450a2c45 100644 --- a/ui/component/claimListDiscover/view.jsx +++ b/ui/component/claimListDiscover/view.jsx @@ -13,11 +13,12 @@ import ClaimPreview from 'component/claimPreview'; import ClaimPreviewTile from 'component/claimPreviewTile'; import I18nMessage from 'component/i18nMessage'; import ClaimListHeader from 'component/claimListHeader'; +import useFetchViewCount from 'effects/use-fetch-view-count'; import { useIsLargeScreen } from 'effects/use-screensize'; -import { getLivestreamOnlyOptions } from 'util/search'; type Props = { uris: Array, + prefixUris?: Array, name?: string, type: string, pageSize?: number, @@ -31,7 +32,6 @@ type Props = { includeSupportAction?: boolean, infiniteScroll?: Boolean, isChannel?: boolean, - liveLivestreamsFirst?: boolean, personalView: boolean, showHeader: boolean, showHiddenByUser?: boolean, @@ -64,7 +64,6 @@ type Props = { channelIds?: Array, claimIds?: Array, subscribedChannels: Array, - livestreamMap?: { [string]: any }, header?: Node, headerLabel?: string | Node, @@ -137,6 +136,7 @@ function ClaimListDiscover(props: Props) { injectedItem, feeAmount, uris, + prefixUris, tileLayout, hideFilters = false, claimIds, @@ -148,8 +148,6 @@ function ClaimListDiscover(props: Props) { releaseTime, scrollAnchor, showHiddenByUser = false, - liveLivestreamsFirst, - livestreamMap, hasSource, hasNoSource, isChannel = false, @@ -380,9 +378,9 @@ function ClaimListDiscover(props: Props) { const hasMatureTags = tagsParam && tagsParam.split(',').some((t) => MATURE_TAGS.includes(t)); - const mainSearchKey = createNormalizedClaimSearchKey(options); - let claimSearchResult = claimSearchByQuery[mainSearchKey]; - const claimSearchResultLastPageReached = claimSearchByQueryLastPageReached[mainSearchKey]; + const searchKey = createNormalizedClaimSearchKey(options); + const claimSearchResult = claimSearchByQuery[searchKey]; + const claimSearchResultLastPageReached = claimSearchByQueryLastPageReached[searchKey]; // uncomment to fix an item on a page // const fixUri = 'lbry://@corbettreport#0/lbryodysee#5'; @@ -400,14 +398,6 @@ function ClaimListDiscover(props: Props) { // claimSearchResult.splice(2, 0, fixUri); // } - const livestreamSearchKey = liveLivestreamsFirst - ? createNormalizedClaimSearchKey(getLivestreamOnlyOptions(options)) - : undefined; - const livestreamSearchResult = livestreamSearchKey && claimSearchByQuery[livestreamSearchKey]; - - const [finalUris, setFinalUris] = React.useState( - getFinalUrisInitialState(history.action === 'POP', claimSearchResult) - ); const [prevOptions, setPrevOptions] = React.useState(null); if (!isJustScrollingToNewPage(prevOptions, options)) { @@ -469,6 +459,8 @@ function ClaimListDiscover(props: Props) { ); + const renderUris = uris || claimSearchResult; + // ************************************************************************** // Helpers // ************************************************************************** @@ -514,41 +506,6 @@ function ClaimListDiscover(props: Props) { } } - function urisEqual(prev: Array, next: Array) { - if (!prev || !next) { - // From 'ClaimList', "null" and "undefined" have special meaning, - // so we can't just compare array length here. - // - null = "timed out" - // - undefined = "no result". - return prev === next; - } - return prev.length === next.length && prev.every((value, index) => value === next[index]); - } - - function getFinalUrisInitialState(isNavigatingBack, claimSearchResult) { - if (isNavigatingBack && claimSearchResult && claimSearchResult.length > 0) { - return claimSearchResult; - } else { - return []; - } - } - - function fetchViewCountForUris(uris) { - const claimIds = []; - - if (uris) { - uris.forEach((uri) => { - if (claimsByUri[uri]) { - claimIds.push(claimsByUri[uri].claim_id); - } - }); - } - - if (claimIds.length > 0) { - doFetchViewCount(claimIds.join(',')); - } - } - function resolveOrderByOption(orderBy: string | Array, sortBy: string | Array) { const order_by = orderBy === CS.ORDER_BY_TRENDING @@ -567,38 +524,15 @@ function ClaimListDiscover(props: Props) { // ************************************************************************** // ************************************************************************** + useFetchViewCount(fetchViewCount, renderUris, claimsByUri, doFetchViewCount); + React.useEffect(() => { if (shouldPerformSearch) { const searchOptions = JSON.parse(optionsStringForEffect); doClaimSearch(searchOptions); - - if (liveLivestreamsFirst && options.page === 1) { - doClaimSearch(getLivestreamOnlyOptions(searchOptions)); - } } }, [doClaimSearch, shouldPerformSearch, optionsStringForEffect, forceRefresh]); - // Resolve 'finalUri' - React.useEffect(() => { - if (uris) { - if (!urisEqual(uris, finalUris)) { - setFinalUris(uris); - } - } else { - // Wait until all queries are done before updating the uris to avoid layout shifts. - const pending = claimSearchResult === undefined || (liveLivestreamsFirst && livestreamSearchResult === undefined); - if (!pending && !urisEqual(claimSearchResult, finalUris)) { - setFinalUris(claimSearchResult); - } - } - }, [uris, claimSearchResult, finalUris, setFinalUris, liveLivestreamsFirst, livestreamSearchResult]); - - React.useEffect(() => { - if (fetchViewCount) { - fetchViewCountForUris(finalUris); - } - }, [finalUris]); // eslint-disable-line react-hooks/exhaustive-deps - const headerToUse = header || ( { streamingUrl: props.uri && makeSelectStreamingUrlForUri(props.uri)(state), wasPurchased: props.uri && makeSelectClaimWasPurchased(props.uri)(state), isLivestream: makeSelectClaimIsStreamPlaceholder(props.uri)(state), + isLivestreamActive: makeSelectIsActiveLivestream(props.uri)(state), isCollectionMine: makeSelectCollectionIsMine(props.collectionId)(state), collectionUris: makeSelectUrlsForCollectionId(props.collectionId)(state), collectionIndex: makeSelectIndexForUrlInCollection(props.uri, props.collectionId)(state), diff --git a/ui/component/claimPreview/view.jsx b/ui/component/claimPreview/view.jsx index 22f3812db..343424d5f 100644 --- a/ui/component/claimPreview/view.jsx +++ b/ui/component/claimPreview/view.jsx @@ -80,7 +80,7 @@ type Props = { repostUrl?: string, hideMenu?: boolean, isLivestream?: boolean, - live?: boolean, + isLivestreamActive: boolean, collectionId?: string, editCollection: (string, CollectionEditParams) => void, isCollectionMine: boolean, @@ -145,7 +145,7 @@ const ClaimPreview = forwardRef((props: Props, ref: any) => { hideMenu = false, // repostUrl, isLivestream, // need both? CHECK - live, + isLivestreamActive, collectionId, collectionIndex, editCollection, @@ -336,7 +336,7 @@ const ClaimPreview = forwardRef((props: Props, ref: any) => { } let liveProperty = null; - if (live === true) { + if (isLivestreamActive === true) { liveProperty = (claim) => <>LIVE; } @@ -349,7 +349,7 @@ const ClaimPreview = forwardRef((props: Props, ref: any) => { 'claim-preview__wrapper--channel': isChannelUri && type !== 'inline', 'claim-preview__wrapper--inline': type === 'inline', 'claim-preview__wrapper--small': type === 'small', - 'claim-preview__live': live, + 'claim-preview__live': isLivestreamActive, 'claim-preview__active': active, })} > @@ -386,7 +386,7 @@ const ClaimPreview = forwardRef((props: Props, ref: any) => { )} {/* @endif */} - {!isLivestream && ( + {(!isLivestream || isLivestreamActive) && (
diff --git a/ui/component/claimPreviewTile/index.js b/ui/component/claimPreviewTile/index.js index 6bfa3760d..e9372e4eb 100644 --- a/ui/component/claimPreviewTile/index.js +++ b/ui/component/claimPreviewTile/index.js @@ -13,6 +13,7 @@ import { } from 'lbry-redux'; import { selectMutedChannels } from 'redux/selectors/blocked'; import { selectBlackListedOutpoints, selectFilteredOutpoints } from 'lbryinc'; +import { makeSelectIsActiveLivestream } from 'redux/selectors/livestream'; import { selectShowMatureContent } from 'redux/selectors/settings'; import ClaimPreviewTile from './view'; import formatMediaDuration from 'util/formatMediaDuration'; @@ -36,6 +37,7 @@ const select = (state, props) => { showMature: selectShowMatureContent(state), isMature: makeSelectClaimIsNsfw(props.uri)(state), isLivestream: makeSelectClaimIsStreamPlaceholder(props.uri)(state), + isLivestreamActive: makeSelectIsActiveLivestream(props.uri)(state), }; }; diff --git a/ui/component/claimPreviewTile/view.jsx b/ui/component/claimPreviewTile/view.jsx index 1372e5918..235b86e4b 100644 --- a/ui/component/claimPreviewTile/view.jsx +++ b/ui/component/claimPreviewTile/view.jsx @@ -48,10 +48,10 @@ type Props = { showMature: boolean, showHiddenByUser?: boolean, properties?: (Claim) => void, - live?: boolean, collectionId?: string, showNoSourceClaims?: boolean, isLivestream: boolean, + isLivestreamActive: boolean, }; // preview image cards used in related video functionality @@ -75,9 +75,9 @@ function ClaimPreviewTile(props: Props) { showMature, showHiddenByUser, properties, - live, showNoSourceClaims, isLivestream, + isLivestreamActive, collectionId, mediaDuration, } = props; @@ -192,7 +192,7 @@ function ClaimPreviewTile(props: Props) { } let liveProperty = null; - if (live === true) { + if (isLivestreamActive === true) { liveProperty = (claim) => <>LIVE; } @@ -201,7 +201,7 @@ function ClaimPreviewTile(props: Props) { onClick={handleClick} className={classnames('card claim-preview--tile', { 'claim-preview__wrapper--channel': isChannel, - 'claim-preview__live': live, + 'claim-preview__live': isLivestreamActive, })} > diff --git a/ui/component/claimTilesDiscover/index.js b/ui/component/claimTilesDiscover/index.js index a12963202..ebdcd3acb 100644 --- a/ui/component/claimTilesDiscover/index.js +++ b/ui/component/claimTilesDiscover/index.js @@ -1,27 +1,36 @@ import { connect } from 'react-redux'; +import { withRouter } from 'react-router'; import { doClaimSearch, selectClaimSearchByQuery, selectFetchingClaimSearchByQuery, SETTINGS, selectClaimsByUri, + splitBySeparator, + MATURE_TAGS, } from 'lbry-redux'; import { doFetchViewCount } from 'lbryinc'; import { doToggleTagFollowDesktop } from 'redux/actions/tags'; import { makeSelectClientSetting, selectShowMatureContent } from 'redux/selectors/settings'; import { selectModerationBlockList } from 'redux/selectors/comments'; import { selectMutedChannels } from 'redux/selectors/blocked'; +import { ENABLE_NO_SOURCE_CLAIMS, SIMPLE_SITE } from 'config'; +import * as CS from 'constants/claim_search'; + import ClaimListDiscover from './view'; -const select = (state) => ({ - claimSearchByQuery: selectClaimSearchByQuery(state), - claimsByUri: selectClaimsByUri(state), - fetchingClaimSearchByQuery: selectFetchingClaimSearchByQuery(state), - showNsfw: selectShowMatureContent(state), - hideReposts: makeSelectClientSetting(SETTINGS.HIDE_REPOSTS)(state), - mutedUris: selectMutedChannels(state), - blockedUris: selectModerationBlockList(state), -}); +const select = (state, props) => { + return { + claimSearchByQuery: selectClaimSearchByQuery(state), + claimsByUri: selectClaimsByUri(state), + fetchingClaimSearchByQuery: selectFetchingClaimSearchByQuery(state), + showNsfw: selectShowMatureContent(state), + hideReposts: makeSelectClientSetting(SETTINGS.HIDE_REPOSTS)(state), + mutedUris: selectMutedChannels(state), + blockedUris: selectModerationBlockList(state), + options: resolveSearchOptions({ pageSize: 8, ...props }), + }; +}; const perform = { doClaimSearch, @@ -29,4 +38,102 @@ const perform = { doFetchViewCount, }; -export default connect(select, perform)(ClaimListDiscover); +export default withRouter(connect(select, perform)(ClaimListDiscover)); + +// **************************************************************************** +// **************************************************************************** + +function resolveSearchOptions(props) { + const { + pageSize, + claimType, + tags, + showNsfw, + languages, + channelIds, + mutedUris, + blockedUris, + orderBy, + streamTypes, + hasNoSource, + hasSource, + releaseTime, + feeAmount, + limitClaimsPerChannel, + hideReposts, + timestamp, + claimIds, + location, + } = props; + + const mutedAndBlockedChannelIds = Array.from( + new Set((mutedUris || []).concat(blockedUris || []).map((uri) => splitBySeparator(uri)[1])) + ); + + const urlParams = new URLSearchParams(location.search); + const feeAmountInUrl = urlParams.get('fee_amount'); + const feeAmountParam = feeAmountInUrl || feeAmount; + + let streamTypesParam; + if (streamTypes) { + streamTypesParam = streamTypes; + } else if (SIMPLE_SITE && !hasNoSource && streamTypes !== null) { + streamTypesParam = [CS.FILE_VIDEO, CS.FILE_AUDIO]; + } + + const options = { + page_size: pageSize, + claim_type: claimType || ['stream', 'repost', 'channel'], + // 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 || [], + not_tags: !showNsfw ? MATURE_TAGS : [], + any_languages: languages, + channel_ids: channelIds || [], + not_channel_ids: mutedAndBlockedChannelIds, + order_by: orderBy || ['trending_group', 'trending_mixed'], + stream_types: streamTypesParam, + }; + + if (ENABLE_NO_SOURCE_CLAIMS && hasNoSource) { + options.has_no_source = true; + } else if (hasSource || (!ENABLE_NO_SOURCE_CLAIMS && (!claimType || claimType === 'stream'))) { + options.has_source = true; + } + + if (releaseTime) { + options.release_time = releaseTime; + } + + if (feeAmountParam) { + options.fee_amount = feeAmountParam; + } + + if (limitClaimsPerChannel) { + options.limit_claims_per_channel = limitClaimsPerChannel; + } + + // https://github.com/lbryio/lbry-desktop/issues/3774 + if (hideReposts) { + if (Array.isArray(options.claim_type)) { + options.claim_type = options.claim_type.filter((claimType) => claimType !== 'repost'); + } else { + options.claim_type = ['stream', 'channel']; + } + } + + if (claimType) { + options.claim_type = claimType; + } + + if (timestamp) { + options.timestamp = timestamp; + } + + if (claimIds) { + options.claim_ids = claimIds; + } + + return options; +} diff --git a/ui/component/claimTilesDiscover/view.jsx b/ui/component/claimTilesDiscover/view.jsx index f28e98817..e67b5c234 100644 --- a/ui/component/claimTilesDiscover/view.jsx +++ b/ui/component/claimTilesDiscover/view.jsx @@ -1,83 +1,41 @@ // @flow -import { ENABLE_NO_SOURCE_CLAIMS, SIMPLE_SITE } from 'config'; -import * as CS from 'constants/claim_search'; import type { Node } from 'react'; import React from 'react'; -import { createNormalizedClaimSearchKey, MATURE_TAGS, splitBySeparator } from 'lbry-redux'; +import { createNormalizedClaimSearchKey } from 'lbry-redux'; import ClaimPreviewTile from 'component/claimPreviewTile'; -import { useHistory } from 'react-router'; -import { getLivestreamOnlyOptions } from 'util/search'; +import useFetchViewCount from 'effects/use-fetch-view-count'; -/** - * Updates 'uris' by adding and/or moving active livestreams to the front of - * list. - * 'liveUris' is also updated with any entries that were moved to the - * front, for convenience. - * - * @param uris [Ref] - * @param liveUris [Ref] - * @param livestreamMap - * @param claimsByUri - * @param claimSearchByQuery - * @param options - */ -export function prioritizeActiveLivestreams( - uris: Array, - liveUris: Array, - livestreamMap: { [string]: any }, - claimsByUri: { [string]: any }, - claimSearchByQuery: { [string]: Array }, - options: any -) { - if (!livestreamMap || !uris) return; +type SearchOptions = { + page_size: number, + no_totals: boolean, + any_tags: Array, + channel_ids: Array, + claim_ids?: Array, + not_channel_ids: Array, + not_tags: Array, + order_by: Array, + languages?: Array, + release_time?: string, + claim_type?: string | Array, + timestamp?: string, + fee_amount?: string, + limit_claims_per_channel?: number, + stream_types?: Array, + has_source?: boolean, + has_no_source?: boolean, +}; - const claimIsLive = (claim, liveChannelIds) => { - // This function relies on: - // 1. Only 1 actual livestream per channel (i.e. all other livestream-claims - // for that channel actually point to the same source). - // 2. 'liveChannelIds' needs to be pruned after being accounted for, - // otherwise all livestream-claims will be "live" (we'll only take the - // latest one as "live" ). - return ( - claim && - claim.value_type === 'stream' && - claim.value.source === undefined && - claim.signing_channel && - liveChannelIds.includes(claim.signing_channel.claim_id) - ); - }; - - let liveChannelIds = Object.keys(livestreamMap); - - // 1. Collect active livestreams from the primary search to put in front. - uris.forEach((uri) => { - const claim = claimsByUri[uri]; - if (claimIsLive(claim, liveChannelIds)) { - liveUris.push(uri); - // This live channel has been accounted for, so remove it. - liveChannelIds.splice(liveChannelIds.indexOf(claim.signing_channel.claim_id), 1); - } - }); - - // 2. Now, repeat on the secondary search. - if (options) { - const livestreamsOnlySearchCacheQuery = createNormalizedClaimSearchKey(getLivestreamOnlyOptions(options)); - const livestreamsOnlyUris = claimSearchByQuery[livestreamsOnlySearchCacheQuery]; - if (livestreamsOnlyUris) { - livestreamsOnlyUris.forEach((uri) => { - const claim = claimsByUri[uri]; - if (!uris.includes(uri) && claimIsLive(claim, liveChannelIds)) { - liveUris.push(uri); - // This live channel has been accounted for, so remove it. - liveChannelIds.splice(liveChannelIds.indexOf(claim.signing_channel.claim_id), 1); - } - }); - } +function urisEqual(prev: ?Array, next: ?Array) { + if (!prev || !next) { + // ClaimList: "null" and "undefined" have special meaning, + // so we can't just compare array length here. + // - null = "timed out" + // - undefined = "no result". + return prev === next; } - // 3. Finalize uris by putting live livestreams in front. - const newUris = liveUris.concat(uris.filter((uri) => !liveUris.includes(uri))); - uris.splice(0, uris.length, ...newUris); + // $FlowFixMe - already checked for null above. + return prev.length === next.length && prev.every((value, index) => value === next[index]); } // **************************************************************************** @@ -88,8 +46,6 @@ type Props = { prefixUris?: Array, pinUrls?: Array, uris: Array, - liveLivestreamsFirst?: boolean, - livestreamMap?: { [string]: any }, showNoSourceClaims?: boolean, renderProperties?: (Claim) => ?Node, fetchViewCount?: boolean, @@ -109,6 +65,7 @@ type Props = { hasSource?: boolean, hasNoSource?: boolean, // --- select --- + location: { search: string }, claimSearchByQuery: { [string]: Array }, claimsByUri: { [string]: any }, fetchingClaimSearchByQuery: { [string]: boolean }, @@ -116,6 +73,7 @@ type Props = { hideReposts: boolean, mutedUris: Array, blockedUris: Array, + options: SearchOptions, // --- perform --- doClaimSearch: ({}) => void, doFetchViewCount: (claimIdCsv: string) => void, @@ -126,234 +84,72 @@ function ClaimTilesDiscover(props: Props) { doClaimSearch, claimSearchByQuery, claimsByUri, - showNsfw, - hideReposts, fetchViewCount, - // Below are options to pass that are forwarded to claim_search - tags, - channelIds, - claimIds, - orderBy, - pageSize = 8, - releaseTime, - languages, - claimType, - streamTypes, - timestamp, - feeAmount, - limitClaimsPerChannel, fetchingClaimSearchByQuery, - hasSource, hasNoSource, renderProperties, - blockedUris, - mutedUris, - liveLivestreamsFirst, - livestreamMap, pinUrls, prefixUris, showNoSourceClaims, doFetchViewCount, + pageSize = 8, + options, } = props; - const { location } = useHistory(); - const urlParams = new URLSearchParams(location.search); - const feeAmountInUrl = urlParams.get('fee_amount'); - const feeAmountParam = feeAmountInUrl || feeAmount; - const mutedAndBlockedChannelIds = Array.from( - new Set(mutedUris.concat(blockedUris).map((uri) => splitBySeparator(uri)[1])) - ); - const liveUris = []; - let streamTypesParam; - if (streamTypes) { - streamTypesParam = streamTypes; - } else if (SIMPLE_SITE && !hasNoSource && streamTypes !== null) { - streamTypesParam = [CS.FILE_VIDEO, CS.FILE_AUDIO]; - } - - const [prevUris, setPrevUris] = React.useState([]); - - const options: { - page_size: number, - no_totals: boolean, - any_tags: Array, - channel_ids: Array, - claim_ids?: Array, - not_channel_ids: Array, - not_tags: Array, - order_by: Array, - languages?: Array, - release_time?: string, - claim_type?: string | Array, - timestamp?: string, - fee_amount?: string, - limit_claims_per_channel?: number, - stream_types?: Array, - has_source?: boolean, - has_no_source?: boolean, - } = { - page_size: pageSize, - claim_type: claimType || ['stream', 'repost', 'channel'], - // 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 || [], - not_tags: !showNsfw ? MATURE_TAGS : [], - any_languages: languages, - channel_ids: channelIds || [], - not_channel_ids: mutedAndBlockedChannelIds, - order_by: orderBy || ['trending_group', 'trending_mixed'], - stream_types: streamTypesParam, - }; - - if (ENABLE_NO_SOURCE_CLAIMS && hasNoSource) { - options.has_no_source = true; - } else if (hasSource || (!ENABLE_NO_SOURCE_CLAIMS && (!claimType || claimType === 'stream'))) { - options.has_source = true; - } - - if (releaseTime) { - options.release_time = releaseTime; - } - - if (feeAmountParam) { - options.fee_amount = feeAmountParam; - } - - if (limitClaimsPerChannel) { - options.limit_claims_per_channel = limitClaimsPerChannel; - } - - // https://github.com/lbryio/lbry-desktop/issues/3774 - if (hideReposts) { - if (Array.isArray(options.claim_type)) { - options.claim_type = options.claim_type.filter((claimType) => claimType !== 'repost'); - } else { - options.claim_type = ['stream', 'channel']; - } - } - - if (claimType) { - options.claim_type = claimType; - } - - if (timestamp) { - options.timestamp = timestamp; - } - - if (claimIds) { - options.claim_ids = claimIds; - } - - const mainSearchKey = createNormalizedClaimSearchKey(options); - const livestreamSearchKey = liveLivestreamsFirst - ? createNormalizedClaimSearchKey(getLivestreamOnlyOptions(options)) - : undefined; - - let uris = (prefixUris || []).concat(claimSearchByQuery[mainSearchKey] || []); - - const isLoading = fetchingClaimSearchByQuery[mainSearchKey]; - - if (liveLivestreamsFirst && livestreamMap && !isLoading) { - prioritizeActiveLivestreams(uris, liveUris, livestreamMap, claimsByUri, claimSearchByQuery, options); - } + const searchKey = createNormalizedClaimSearchKey(options); + const fetchingClaimSearch = fetchingClaimSearchByQuery[searchKey]; + const claimSearchUris = claimSearchByQuery[searchKey] || []; // Don't use the query from createNormalizedClaimSearchKey for the effect since that doesn't include page & release_time const optionsStringForEffect = JSON.stringify(options); - const shouldPerformSearch = !isLoading && uris.length === 0; + const shouldPerformSearch = !fetchingClaimSearch && claimSearchUris.length === 0; - if ( - prefixUris === undefined && - (claimSearchByQuery[mainSearchKey] === undefined || - (livestreamSearchKey && claimSearchByQuery[livestreamSearchKey] === undefined)) - ) { - // This is a new query and we don't have results yet ... - if (prevUris.length !== 0) { - // ... but we have previous results. Use it until new results are here. - uris = prevUris; - } - } + const uris = (prefixUris || []).concat(claimSearchUris); - const modifiedUris = uris ? uris.slice() : []; - const fixUris = pinUrls || []; - - if (pinUrls && modifiedUris && modifiedUris.length > 2 && window.location.pathname === '/') { - fixUris.forEach((fixUri) => { - if (modifiedUris.indexOf(fixUri) !== -1) { - modifiedUris.splice(modifiedUris.indexOf(fixUri), 1); + if (pinUrls && uris && uris.length > 2 && window.location.pathname === '/') { + pinUrls.forEach((pin) => { + if (uris.indexOf(pin) !== -1) { + uris.splice(uris.indexOf(pin), 1); } else { - modifiedUris.pop(); + uris.pop(); } }); - modifiedUris.splice(2, 0, ...fixUris); + uris.splice(2, 0, ...pinUrls); } - // ************************************************************************** - // ************************************************************************** - - function resolveLive(index) { - if (liveLivestreamsFirst && livestreamMap && index < liveUris.length) { - return true; - } - return undefined; + if (uris.length > 0 && uris.length < pageSize && shouldPerformSearch) { + // prefixUri and pinUrls might already be present while waiting for the + // remaining claim_search results. Fill the space to prevent layout shifts. + uris.push(...Array(pageSize - uris.length).fill('')); } - function fetchViewCountForUris(uris) { - const claimIds = []; - - if (uris) { - uris.forEach((uri) => { - if (claimsByUri[uri]) { - claimIds.push(claimsByUri[uri].claim_id); - } - }); - } - - if (claimIds.length > 0) { - doFetchViewCount(claimIds.join(',')); - } - } - - // ************************************************************************** - // ************************************************************************** + useFetchViewCount(fetchViewCount, uris, claimsByUri, doFetchViewCount); + // Run `doClaimSearch` React.useEffect(() => { if (shouldPerformSearch) { const searchOptions = JSON.parse(optionsStringForEffect); doClaimSearch(searchOptions); - - if (liveLivestreamsFirst) { - doClaimSearch(getLivestreamOnlyOptions(searchOptions)); - } } - }, [doClaimSearch, shouldPerformSearch, optionsStringForEffect, liveLivestreamsFirst]); - - React.useEffect(() => { - if (JSON.stringify(prevUris) !== JSON.stringify(uris) && !shouldPerformSearch) { - // Stash new results for next render cycle: - setPrevUris(uris); - // Fetch view count: - if (fetchViewCount) { - fetchViewCountForUris(uris); - } - } - }, [shouldPerformSearch, prevUris, uris]); // eslint-disable-line react-hooks/exhaustive-deps - - // ************************************************************************** - // ************************************************************************** + }, [doClaimSearch, shouldPerformSearch, optionsStringForEffect]); return (
    - {modifiedUris && modifiedUris.length - ? modifiedUris.map((uri, index) => ( - - )) + {uris && uris.length + ? uris.map((uri, i) => { + if (uri) { + return ( + + ); + } else { + return ; + } + }) : new Array(pageSize) .fill(1) .map((x, i) => ( @@ -362,4 +158,75 @@ function ClaimTilesDiscover(props: Props) {
); } -export default ClaimTilesDiscover; + +export default React.memo(ClaimTilesDiscover, areEqual); + +function debug_trace(val) { + if (process.env.DEBUG_TRACE) console.log(`Render due to: ${val}`); +} + +function areEqual(prev: Props, next: Props) { + const prevOptions: SearchOptions = prev.options; + const nextOptions: SearchOptions = next.options; + + const prevSearchKey = createNormalizedClaimSearchKey(prevOptions); + const nextSearchKey = createNormalizedClaimSearchKey(nextOptions); + + // "Pause" render when fetching to solve the layout-shift problem in #5979 + // (previous solution used a stashed copy of the rendered uris while fetching + // to make it stay still). + // This version works as long as we are not doing anything during a fetch, + // such as showing a spinner. + const nextCsFetch = next.fetchingClaimSearchByQuery[nextSearchKey]; + if (nextCsFetch) { + return true; + } + + // --- Deep-compare --- + if (prev.claimSearchByQuery[prevSearchKey] !== next.claimSearchByQuery[nextSearchKey]) { + debug_trace('claimSearchByQuery'); + return false; + } + + if (prev.fetchingClaimSearchByQuery[prevSearchKey] !== next.fetchingClaimSearchByQuery[nextSearchKey]) { + debug_trace('fetchingClaimSearchByQuery'); + return false; + } + + const ARRAY_KEYS = ['prefixUris', 'channelIds', 'mutedUris', 'blockedUris']; + + for (let i = 0; i < ARRAY_KEYS.length; ++i) { + const key = ARRAY_KEYS[i]; + if (!urisEqual(prev[key], next[key])) { + debug_trace(`${key}`); + return false; + } + } + + // --- Default the rest(*) to shallow-compare --- + // (*) including new props introduced in the future, in case developer forgets + // to update this function. Better to render more than miss an important one. + const KEYS_TO_IGNORE = [ + ...ARRAY_KEYS, + 'claimSearchByQuery', + 'fetchingClaimSearchByQuery', + 'location', + 'history', + 'match', + 'claimsByUri', + 'options', + 'doClaimSearch', + 'doToggleTagFollowDesktop', + ]; + + const propKeys = Object.keys(next); + for (let i = 0; i < propKeys.length; ++i) { + const pk = propKeys[i]; + if (!KEYS_TO_IGNORE.includes(pk) && prev[pk] !== next[pk]) { + debug_trace(`${pk}`); + return false; + } + } + + return true; +} diff --git a/ui/constants/action_types.js b/ui/constants/action_types.js index 4b02a2128..17748007e 100644 --- a/ui/constants/action_types.js +++ b/ui/constants/action_types.js @@ -352,3 +352,7 @@ export const FETCH_NO_SOURCE_CLAIMS_STARTED = 'FETCH_NO_SOURCE_CLAIMS_STARTED'; export const FETCH_NO_SOURCE_CLAIMS_COMPLETED = 'FETCH_NO_SOURCE_CLAIMS_COMPLETED'; export const FETCH_NO_SOURCE_CLAIMS_FAILED = 'FETCH_NO_SOURCE_CLAIMS_FAILED'; export const VIEWERS_RECEIVED = 'VIEWERS_RECEIVED'; +export const FETCH_ACTIVE_LIVESTREAMS_STARTED = 'FETCH_ACTIVE_LIVESTREAMS_STARTED'; +export const FETCH_ACTIVE_LIVESTREAMS_FAILED = 'FETCH_ACTIVE_LIVESTREAMS_FAILED'; +export const FETCH_ACTIVE_LIVESTREAMS_SKIPPED = 'FETCH_ACTIVE_LIVESTREAMS_SKIPPED'; +export const FETCH_ACTIVE_LIVESTREAMS_COMPLETED = 'FETCH_ACTIVE_LIVESTREAMS_COMPLETED'; diff --git a/ui/effects/use-fetch-view-count.js b/ui/effects/use-fetch-view-count.js new file mode 100644 index 000000000..b4f8b0892 --- /dev/null +++ b/ui/effects/use-fetch-view-count.js @@ -0,0 +1,24 @@ +// @flow +import { useState, useEffect } from 'react'; + +export default function useFetchViewCount( + shouldFetch: ?boolean, + uris: Array, + claimsByUri: any, + doFetchViewCount: (string) => void +) { + const [fetchedUris, setFetchedUris] = useState([]); + + useEffect(() => { + if (shouldFetch && uris && uris.length > 0) { + const urisToFetch = uris.filter((uri) => uri && !fetchedUris.includes(uri) && Boolean(claimsByUri[uri])); + + if (urisToFetch.length > 0) { + const claimIds = urisToFetch.map((uri) => claimsByUri[uri].claim_id); + doFetchViewCount(claimIds.join(',')); + setFetchedUris([...fetchedUris, ...urisToFetch]); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [uris]); +} diff --git a/ui/effects/use-get-livestreams.js b/ui/effects/use-get-livestreams.js deleted file mode 100644 index fb77c1395..000000000 --- a/ui/effects/use-get-livestreams.js +++ /dev/null @@ -1,55 +0,0 @@ -// @flow -import React from 'react'; -import { LIVESTREAM_LIVE_API } from 'constants/livestream'; - -/** - * Gets latest livestream info list. Returns null (instead of a blank object) - * when there are no active livestreams. - * - * @param minViewers - * @param refreshMs - * @returns {{livestreamMap: null, loading: boolean}} - */ -export default function useGetLivestreams(minViewers: number = 0, refreshMs: number = 0) { - const [loading, setLoading] = React.useState(true); - const [livestreamMap, setLivestreamMap] = React.useState(null); - - React.useEffect(() => { - function checkCurrentLivestreams() { - fetch(LIVESTREAM_LIVE_API) - .then((res) => res.json()) - .then((res) => { - setLoading(false); - if (!res.data) { - setLivestreamMap(null); - return; - } - - const livestreamMap = res.data.reduce((acc, curr) => { - if (curr.viewCount >= minViewers) { - acc[curr.claimId] = curr; - } - return acc; - }, {}); - - setLivestreamMap(livestreamMap); - }) - .catch((err) => { - setLoading(false); - }); - } - - checkCurrentLivestreams(); - - if (refreshMs > 0) { - let fetchInterval = setInterval(checkCurrentLivestreams, refreshMs); - return () => { - if (fetchInterval) { - clearInterval(fetchInterval); - } - }; - } - }, []); - - return { livestreamMap, loading }; -} diff --git a/ui/page/channelsFollowing/index.js b/ui/page/channelsFollowing/index.js index 599cbf841..c08a2107f 100644 --- a/ui/page/channelsFollowing/index.js +++ b/ui/page/channelsFollowing/index.js @@ -1,13 +1,18 @@ import { connect } from 'react-redux'; import { SETTINGS } from 'lbry-redux'; +import { doFetchActiveLivestreams } from 'redux/actions/livestream'; +import { selectActiveLivestreams } from 'redux/selectors/livestream'; import { selectSubscriptions } from 'redux/selectors/subscriptions'; import { makeSelectClientSetting } from 'redux/selectors/settings'; import ChannelsFollowingPage from './view'; -const select = state => ({ +const select = (state) => ({ subscribedChannels: selectSubscriptions(state), tileLayout: makeSelectClientSetting(SETTINGS.TILE_LAYOUT)(state), + activeLivestreams: selectActiveLivestreams(state), }); -export default connect(select)(ChannelsFollowingPage); +export default connect(select, { + doFetchActiveLivestreams, +})(ChannelsFollowingPage); diff --git a/ui/page/channelsFollowing/view.jsx b/ui/page/channelsFollowing/view.jsx index 71e91d2b3..c7f9d7222 100644 --- a/ui/page/channelsFollowing/view.jsx +++ b/ui/page/channelsFollowing/view.jsx @@ -9,24 +9,32 @@ import ClaimListDiscover from 'component/claimListDiscover'; import Page from 'component/page'; import Button from 'component/button'; import Icon from 'component/common/icon'; -import useGetLivestreams from 'effects/use-get-livestreams'; import { splitBySeparator } from 'lbry-redux'; +import { getLivestreamUris } from 'util/livestream'; type Props = { subscribedChannels: Array, tileLayout: boolean, + activeLivestreams: ?LivestreamInfo, + doFetchActiveLivestreams: () => void, }; function ChannelsFollowingPage(props: Props) { - const { subscribedChannels, tileLayout } = props; + const { subscribedChannels, tileLayout, activeLivestreams, doFetchActiveLivestreams } = props; + const hasSubsribedChannels = subscribedChannels.length > 0; - const { livestreamMap } = useGetLivestreams(); + const channelIds = subscribedChannels.map((sub) => splitBySeparator(sub.uri)[1]); + + React.useEffect(() => { + doFetchActiveLivestreams(); + }, []); return !hasSubsribedChannels ? ( ) : ( } defaultOrderBy={CS.ORDER_BY_NEW} - channelIds={subscribedChannels.map((sub) => splitBySeparator(sub.uri)[1])} + channelIds={channelIds} meta={