From e624ed8c5150098b1b4b8377febc04fa3a6eda62 Mon Sep 17 00:00:00 2001 From: infinite-persistence Date: Sat, 25 Sep 2021 12:01:38 +0800 Subject: [PATCH 01/14] -- tmp revert -- This reverts commit 3b47edc3b9744418d8ea78bd30dcd2671633aebb to allow putting back in the original commits. --- 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, 528 insertions(+), 631 deletions(-) delete mode 100644 ui/effects/use-fetch-view-count.js create mode 100644 ui/effects/use-get-livestreams.js delete mode 100644 ui/util/livestream.js diff --git a/flow-typed/livestream.js b/flow-typed/livestream.js index 9bee62676..40ed13f19 100644 --- a/flow-typed/livestream.js +++ b/flow-typed/livestream.js @@ -24,18 +24,4 @@ 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 d9d20509c..67a4024f0 100644 --- a/ui/component/claimList/index.js +++ b/ui/component/claimList/index.js @@ -1,10 +1,12 @@ import { connect } from 'react-redux'; import ClaimList from './view'; -import { SETTINGS } from 'lbry-redux'; +import { SETTINGS, selectClaimSearchByQuery, selectClaimsByUri } 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 bdfd5450a..4da171d4f 100644 --- a/ui/component/claimList/view.jsx +++ b/ui/component/claimList/view.jsx @@ -9,6 +9,7 @@ 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'; @@ -16,7 +17,6 @@ const SORT_OLD = 'old'; type Props = { uris: Array, - prefixUris?: Array, header: Node | boolean, headerAltControls: Node, loading: boolean, @@ -41,6 +41,9 @@ 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, @@ -50,7 +53,6 @@ export default function ClaimList(props: Props) { const { activeUri, uris, - prefixUris, headerAltControls, loading, persistedStorageKey, @@ -71,25 +73,37 @@ 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 tileUris = (prefixUris || []).concat(uris); - const sortedUris = (urisLength > 0 && (currentSort === SORT_NEW ? tileUris : tileUris.slice().reverse())) || []; + const liveUris = []; + if (liveLivestreamsFirst && livestreamMap) { + prioritizeActiveLivestreams(uris, liveUris, livestreamMap, claimsByUri, claimSearchByQuery, searchOptions); + } + 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); } @@ -124,12 +138,13 @@ export default function ClaimList(props: Props) { return tileLayout && !header ? (
{urisLength > 0 && - tileUris.map((uri) => ( + uris.map((uri, index) => ( @@ -201,6 +216,7 @@ 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 e450a2c45..f04929183 100644 --- a/ui/component/claimListDiscover/view.jsx +++ b/ui/component/claimListDiscover/view.jsx @@ -13,12 +13,11 @@ 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, @@ -32,6 +31,7 @@ type Props = { includeSupportAction?: boolean, infiniteScroll?: Boolean, isChannel?: boolean, + liveLivestreamsFirst?: boolean, personalView: boolean, showHeader: boolean, showHiddenByUser?: boolean, @@ -64,6 +64,7 @@ type Props = { channelIds?: Array, claimIds?: Array, subscribedChannels: Array, + livestreamMap?: { [string]: any }, header?: Node, headerLabel?: string | Node, @@ -136,7 +137,6 @@ function ClaimListDiscover(props: Props) { injectedItem, feeAmount, uris, - prefixUris, tileLayout, hideFilters = false, claimIds, @@ -148,6 +148,8 @@ function ClaimListDiscover(props: Props) { releaseTime, scrollAnchor, showHiddenByUser = false, + liveLivestreamsFirst, + livestreamMap, hasSource, hasNoSource, isChannel = false, @@ -378,9 +380,9 @@ function ClaimListDiscover(props: Props) { const hasMatureTags = tagsParam && tagsParam.split(',').some((t) => MATURE_TAGS.includes(t)); - const searchKey = createNormalizedClaimSearchKey(options); - const claimSearchResult = claimSearchByQuery[searchKey]; - const claimSearchResultLastPageReached = claimSearchByQueryLastPageReached[searchKey]; + const mainSearchKey = createNormalizedClaimSearchKey(options); + let claimSearchResult = claimSearchByQuery[mainSearchKey]; + const claimSearchResultLastPageReached = claimSearchByQueryLastPageReached[mainSearchKey]; // uncomment to fix an item on a page // const fixUri = 'lbry://@corbettreport#0/lbryodysee#5'; @@ -398,6 +400,14 @@ 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)) { @@ -459,8 +469,6 @@ function ClaimListDiscover(props: Props) { ); - const renderUris = uris || claimSearchResult; - // ************************************************************************** // Helpers // ************************************************************************** @@ -506,6 +514,41 @@ 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 @@ -524,15 +567,38 @@ 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 343424d5f..22f3812db 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, - isLivestreamActive: boolean, + live?: 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 - isLivestreamActive, + live, collectionId, collectionIndex, editCollection, @@ -336,7 +336,7 @@ const ClaimPreview = forwardRef((props: Props, ref: any) => { } let liveProperty = null; - if (isLivestreamActive === true) { + if (live === 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': isLivestreamActive, + 'claim-preview__live': live, 'claim-preview__active': active, })} > @@ -386,7 +386,7 @@ const ClaimPreview = forwardRef((props: Props, ref: any) => { )} {/* @endif */} - {(!isLivestream || isLivestreamActive) && ( + {!isLivestream && (
diff --git a/ui/component/claimPreviewTile/index.js b/ui/component/claimPreviewTile/index.js index e9372e4eb..6bfa3760d 100644 --- a/ui/component/claimPreviewTile/index.js +++ b/ui/component/claimPreviewTile/index.js @@ -13,7 +13,6 @@ 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'; @@ -37,7 +36,6 @@ 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 235b86e4b..1372e5918 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 (isLivestreamActive === true) { + if (live === 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': isLivestreamActive, + 'claim-preview__live': live, })} > diff --git a/ui/component/claimTilesDiscover/index.js b/ui/component/claimTilesDiscover/index.js index ebdcd3acb..a12963202 100644 --- a/ui/component/claimTilesDiscover/index.js +++ b/ui/component/claimTilesDiscover/index.js @@ -1,36 +1,27 @@ 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, 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 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 perform = { doClaimSearch, @@ -38,102 +29,4 @@ const perform = { doFetchViewCount, }; -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; -} +export default connect(select, perform)(ClaimListDiscover); diff --git a/ui/component/claimTilesDiscover/view.jsx b/ui/component/claimTilesDiscover/view.jsx index e67b5c234..f28e98817 100644 --- a/ui/component/claimTilesDiscover/view.jsx +++ b/ui/component/claimTilesDiscover/view.jsx @@ -1,41 +1,83 @@ // @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 } from 'lbry-redux'; +import { createNormalizedClaimSearchKey, MATURE_TAGS, splitBySeparator } from 'lbry-redux'; import ClaimPreviewTile from 'component/claimPreviewTile'; -import useFetchViewCount from 'effects/use-fetch-view-count'; +import { useHistory } from 'react-router'; +import { getLivestreamOnlyOptions } from 'util/search'; -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, -}; +/** + * 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; -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; + 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); + } + }); + } } - // $FlowFixMe - already checked for null above. - return prev.length === next.length && prev.every((value, index) => value === next[index]); + // 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); } // **************************************************************************** @@ -46,6 +88,8 @@ type Props = { prefixUris?: Array, pinUrls?: Array, uris: Array, + liveLivestreamsFirst?: boolean, + livestreamMap?: { [string]: any }, showNoSourceClaims?: boolean, renderProperties?: (Claim) => ?Node, fetchViewCount?: boolean, @@ -65,7 +109,6 @@ type Props = { hasSource?: boolean, hasNoSource?: boolean, // --- select --- - location: { search: string }, claimSearchByQuery: { [string]: Array }, claimsByUri: { [string]: any }, fetchingClaimSearchByQuery: { [string]: boolean }, @@ -73,7 +116,6 @@ type Props = { hideReposts: boolean, mutedUris: Array, blockedUris: Array, - options: SearchOptions, // --- perform --- doClaimSearch: ({}) => void, doFetchViewCount: (claimIdCsv: string) => void, @@ -84,72 +126,234 @@ 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 searchKey = createNormalizedClaimSearchKey(options); - const fetchingClaimSearch = fetchingClaimSearchByQuery[searchKey]; - const claimSearchUris = claimSearchByQuery[searchKey] || []; + 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); + } // 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 = !fetchingClaimSearch && claimSearchUris.length === 0; + const shouldPerformSearch = !isLoading && uris.length === 0; - const uris = (prefixUris || []).concat(claimSearchUris); + 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; + } + } - if (pinUrls && uris && uris.length > 2 && window.location.pathname === '/') { - pinUrls.forEach((pin) => { - if (uris.indexOf(pin) !== -1) { - uris.splice(uris.indexOf(pin), 1); + 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); } else { - uris.pop(); + modifiedUris.pop(); } }); - uris.splice(2, 0, ...pinUrls); + modifiedUris.splice(2, 0, ...fixUris); } - 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 resolveLive(index) { + if (liveLivestreamsFirst && livestreamMap && index < liveUris.length) { + return true; + } + return undefined; } - useFetchViewCount(fetchViewCount, uris, claimsByUri, doFetchViewCount); + 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(',')); + } + } + + // ************************************************************************** + // ************************************************************************** - // Run `doClaimSearch` React.useEffect(() => { if (shouldPerformSearch) { const searchOptions = JSON.parse(optionsStringForEffect); doClaimSearch(searchOptions); + + if (liveLivestreamsFirst) { + doClaimSearch(getLivestreamOnlyOptions(searchOptions)); + } } - }, [doClaimSearch, shouldPerformSearch, optionsStringForEffect]); + }, [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 + + // ************************************************************************** + // ************************************************************************** return (
    - {uris && uris.length - ? uris.map((uri, i) => { - if (uri) { - return ( - - ); - } else { - return ; - } - }) + {modifiedUris && modifiedUris.length + ? modifiedUris.map((uri, index) => ( + + )) : new Array(pageSize) .fill(1) .map((x, i) => ( @@ -158,75 +362,4 @@ function ClaimTilesDiscover(props: Props) {
); } - -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; -} +export default ClaimTilesDiscover; diff --git a/ui/constants/action_types.js b/ui/constants/action_types.js index 17748007e..4b02a2128 100644 --- a/ui/constants/action_types.js +++ b/ui/constants/action_types.js @@ -352,7 +352,3 @@ 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 deleted file mode 100644 index b4f8b0892..000000000 --- a/ui/effects/use-fetch-view-count.js +++ /dev/null @@ -1,24 +0,0 @@ -// @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 new file mode 100644 index 000000000..fb77c1395 --- /dev/null +++ b/ui/effects/use-get-livestreams.js @@ -0,0 +1,55 @@ +// @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 c08a2107f..599cbf841 100644 --- a/ui/page/channelsFollowing/index.js +++ b/ui/page/channelsFollowing/index.js @@ -1,18 +1,13 @@ 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, { - doFetchActiveLivestreams, -})(ChannelsFollowingPage); +export default connect(select)(ChannelsFollowingPage); diff --git a/ui/page/channelsFollowing/view.jsx b/ui/page/channelsFollowing/view.jsx index c7f9d7222..71e91d2b3 100644 --- a/ui/page/channelsFollowing/view.jsx +++ b/ui/page/channelsFollowing/view.jsx @@ -9,32 +9,24 @@ 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, activeLivestreams, doFetchActiveLivestreams } = props; - + const { subscribedChannels, tileLayout } = props; const hasSubsribedChannels = subscribedChannels.length > 0; - const channelIds = subscribedChannels.map((sub) => splitBySeparator(sub.uri)[1]); - - React.useEffect(() => { - doFetchActiveLivestreams(); - }, []); + const { livestreamMap } = useGetLivestreams(); return !hasSubsribedChannels ? ( ) : ( } defaultOrderBy={CS.ORDER_BY_NEW} - channelIds={channelIds} + channelIds={subscribedChannels.map((sub) => splitBySeparator(sub.uri)[1])} meta={