diff --git a/ui/component/recommendedContent/index.js b/ui/component/recommendedContent/index.js index efd71ec50..4538d3305 100644 --- a/ui/component/recommendedContent/index.js +++ b/ui/component/recommendedContent/index.js @@ -1,31 +1,24 @@ import { connect } from 'react-redux'; -import { makeSelectClaimIsNsfw, makeSelectClaimForUri } from 'lbry-redux'; -import { doRecommendationUpdate, doRecommendationClicked } from 'redux/actions/content'; +import { makeSelectClaimForUri } from 'lbry-redux'; import { doFetchRecommendedContent } from 'redux/actions/search'; import { makeSelectRecommendedContentForUri, selectIsSearching } from 'redux/selectors/search'; import { selectUserVerifiedEmail } from 'redux/selectors/user'; -import { makeSelectNextUnplayedRecommended } from 'redux/selectors/content'; import RecommendedContent from './view'; const select = (state, props) => { const claim = makeSelectClaimForUri(props.uri)(state); const { claim_id: claimId } = claim; + const recommendedContentUris = makeSelectRecommendedContentForUri(props.uri)(state); + const nextRecommendedUri = recommendedContentUris && recommendedContentUris[0]; + return { - mature: makeSelectClaimIsNsfw(props.uri)(state), - recommendedContentUris: makeSelectRecommendedContentForUri(props.uri)(state), - nextRecommendedUri: makeSelectNextUnplayedRecommended(props.uri)(state), - isSearching: selectIsSearching(state), - isAuthenticated: selectUserVerifiedEmail(state), claim, claimId, + recommendedContentUris, + nextRecommendedUri, + isSearching: selectIsSearching(state), + isAuthenticated: selectUserVerifiedEmail(state), }; }; -const perform = (dispatch) => ({ - doFetchRecommendedContent: (uri, mature) => dispatch(doFetchRecommendedContent(uri, mature)), - doRecommendationUpdate: (claimId, urls, id, parentId) => - dispatch(doRecommendationUpdate(claimId, urls, id, parentId)), - doRecommendationClicked: (claimId, index) => dispatch(doRecommendationClicked(claimId, index)), -}); - -export default connect(select, perform)(RecommendedContent); +export default connect(select, { doFetchRecommendedContent })(RecommendedContent); diff --git a/ui/component/recommendedContent/view.jsx b/ui/component/recommendedContent/view.jsx index fdf366272..2de10bd10 100644 --- a/ui/component/recommendedContent/view.jsx +++ b/ui/component/recommendedContent/view.jsx @@ -18,11 +18,9 @@ type Props = { recommendedContentUris: Array, nextRecommendedUri: string, isSearching: boolean, - doFetchRecommendedContent: (string, boolean) => void, - mature: boolean, + doFetchRecommendedContent: (string) => void, isAuthenticated: boolean, claim: ?StreamClaim, - doRecommendationUpdate: (claimId: string, urls: Array, id: string, parentId: string) => void, claimId: string, }; @@ -30,7 +28,6 @@ export default React.memo(function RecommendedContent(props: Props) { const { uri, doFetchRecommendedContent, - mature, recommendedContentUris, nextRecommendedUri, isSearching, @@ -39,53 +36,29 @@ export default React.memo(function RecommendedContent(props: Props) { claimId, } = props; const [viewMode, setViewMode] = React.useState(VIEW_ALL_RELATED); - const [recommendationUrls, setRecommendationUrls] = React.useState(); const signingChannel = claim && claim.signing_channel; const channelName = signingChannel ? signingChannel.name : null; const isMobile = useIsMobile(); const isMedium = useIsMediumScreen(); const { onRecsLoaded: onRecommendationsLoaded, onClickedRecommended: onRecommendationClicked } = RecSys; - React.useEffect(() => { - function moveAutoplayNextItemToTop(recommendedContent) { - let newList = recommendedContent; - if (newList) { - const index = newList.indexOf(nextRecommendedUri); - if (index > 0) { - const a = newList[0]; - newList[0] = nextRecommendedUri; - newList[index] = a; - } - } - return newList; - } - - function listEq(prev, next) { - if (prev && next) { - return prev.length === next.length && prev.every((value, index) => value === next[index]); - } else { - return prev === next; - } - } - - const newRecommendationUrls = moveAutoplayNextItemToTop(recommendedContentUris); - - if (claim && !listEq(recommendationUrls, newRecommendationUrls)) { - setRecommendationUrls(newRecommendationUrls); - } - }, [recommendedContentUris, nextRecommendedUri, recommendationUrls, setRecommendationUrls, claim]); React.useEffect(() => { - doFetchRecommendedContent(uri, mature); - }, [uri, mature, doFetchRecommendedContent]); + doFetchRecommendedContent(uri); + }, [uri, doFetchRecommendedContent]); React.useEffect(() => { // Right now we only want to record the recs if they actually saw them. - if (recommendationUrls && recommendationUrls.length && nextRecommendedUri && viewMode === VIEW_ALL_RELATED) { - onRecommendationsLoaded(claimId, recommendationUrls); + if ( + recommendedContentUris && + recommendedContentUris.length && + nextRecommendedUri && + viewMode === VIEW_ALL_RELATED + ) { + onRecommendationsLoaded(claimId, recommendedContentUris); } - }, [recommendationUrls, onRecommendationsLoaded, claimId, nextRecommendedUri, viewMode]); + }, [recommendedContentUris, onRecommendationsLoaded, claimId, nextRecommendedUri, viewMode]); - function handleRecommendationClicked(e, clickedClaim, index: number) { + function handleRecommendationClicked(e, clickedClaim) { if (claim) { onRecommendationClicked(claim.claim_id, clickedClaim.claim_id); } @@ -124,7 +97,7 @@ export default React.memo(function RecommendedContent(props: Props) { } empty={__('No related content found')} @@ -164,7 +137,6 @@ function areEqual(prevProps: Props, nextProps: Props) { a.nextRecommendedUri !== b.nextRecommendedUri || a.isAuthenticated !== b.isAuthenticated || a.isSearching !== b.isSearching || - a.mature !== b.mature || (a.recommendedContentUris && !b.recommendedContentUris) || (!a.recommendedContentUris && b.recommendedContentUris) || (a.claim && !b.claim) || diff --git a/ui/component/viewers/videoViewer/index.js b/ui/component/viewers/videoViewer/index.js index 2ca09af22..d9b6ddce1 100644 --- a/ui/component/viewers/videoViewer/index.js +++ b/ui/component/viewers/videoViewer/index.js @@ -16,12 +16,8 @@ import { } from 'redux/actions/app'; import { selectVolume, selectMute } from 'redux/selectors/app'; import { savePosition, clearPosition, doPlayUri, doSetPlayingUri } from 'redux/actions/content'; -import { - makeSelectContentPositionForUri, - makeSelectIsPlayerFloating, - makeSelectNextUnplayedRecommended, - selectPlayingUri, -} from 'redux/selectors/content'; +import { makeSelectContentPositionForUri, makeSelectIsPlayerFloating, selectPlayingUri } from 'redux/selectors/content'; +import { makeSelectRecommendedContentForUri } from 'redux/selectors/search'; import VideoViewer from './view'; import { withRouter } from 'react-router'; import { doClaimEligiblePurchaseRewards } from 'redux/actions/rewards'; @@ -47,7 +43,8 @@ const select = (state, props) => { nextRecommendedUri = makeSelectNextUrlForCollectionAndUrl(collectionId, uri)(state); previousListUri = makeSelectPreviousUrlForCollectionAndUrl(collectionId, uri)(state); } else { - nextRecommendedUri = makeSelectNextUnplayedRecommended(uri)(state); + const recommendedContent = makeSelectRecommendedContentForUri(uri)(state); + nextRecommendedUri = recommendedContent && recommendedContent[0]; } return { diff --git a/ui/constants/search.js b/ui/constants/search.js index ff33a4df0..31c9d02d1 100644 --- a/ui/constants/search.js +++ b/ui/constants/search.js @@ -13,9 +13,11 @@ export const SEARCH_TYPES = { export const SEARCH_OPTIONS = { RESULT_COUNT: 'size', CLAIM_TYPE: 'claimType', + RELATED_TO: 'related_to', INCLUDE_FILES: 'file', INCLUDE_CHANNELS: 'channel', INCLUDE_FILES_AND_CHANNELS: 'file,channel', + INCLUDE_MATURE: 'nsfw', MEDIA_AUDIO: 'audio', MEDIA_VIDEO: 'video', MEDIA_TEXT: 'text', @@ -25,6 +27,7 @@ export const SEARCH_OPTIONS = { SORT_ACCENDING: '^release_time', SORT_DESCENDING: 'release_time', EXACT: 'exact', + PRICE_FILTER_FREE: 'free_only', TIME_FILTER: 'time_filter', TIME_FILTER_LAST_HOUR: 'lasthour', TIME_FILTER_TODAY: 'today', diff --git a/ui/redux/actions/search.js b/ui/redux/actions/search.js index 28b1a724c..52aa50b04 100644 --- a/ui/redux/actions/search.js +++ b/ui/redux/actions/search.js @@ -1,7 +1,15 @@ // @flow import * as ACTIONS from 'constants/action_types'; import { SEARCH_OPTIONS } from 'constants/search'; -import { buildURI, doResolveUris, batchActions, isURIValid, makeSelectClaimForUri } from 'lbry-redux'; +import { selectShowMatureContent } from 'redux/selectors/settings'; +import { + buildURI, + doResolveUris, + batchActions, + isURIValid, + makeSelectClaimForUri, + makeSelectClaimIsNsfw, +} from 'lbry-redux'; import { makeSelectSearchUrisForQuery, selectSearchValue } from 'redux/selectors/search'; import handleFetchResponse from 'util/handle-fetch'; import { getSearchQueryString } from 'util/query-params'; @@ -125,21 +133,26 @@ export const doUpdateSearchOptions = (newOptions: SearchOptions, additionalOptio } }; -export const doFetchRecommendedContent = (uri: string, mature: boolean) => (dispatch: Dispatch, getState: GetState) => { +export const doFetchRecommendedContent = (uri: string) => (dispatch: Dispatch, getState: GetState) => { const state = getState(); const claim = makeSelectClaimForUri(uri)(state); + const matureEnabled = selectShowMatureContent(state); + const claimIsMature = makeSelectClaimIsNsfw(uri)(state); if (claim && claim.value && claim.claim_id) { - const options: SearchOptions = { size: 20, related_to: claim.claim_id, isBackgroundSearch: true }; - if (!mature) { - options['nsfw'] = false; - } + const options: SearchOptions = { size: 20, nsfw: matureEnabled, isBackgroundSearch: true }; if (SIMPLE_SITE) { options[SEARCH_OPTIONS.CLAIM_TYPE] = SEARCH_OPTIONS.INCLUDE_FILES; options[SEARCH_OPTIONS.MEDIA_VIDEO] = true; + options[SEARCH_OPTIONS.PRICE_FILTER_FREE] = true; } + if (matureEnabled || !claimIsMature) { + options[SEARCH_OPTIONS.RELATED_TO] = claim.claim_id; + } + const { title } = claim.value; + if (title && options) { dispatch(doSearch(title, options)); } diff --git a/ui/redux/selectors/content.js b/ui/redux/selectors/content.js index 4fdc57bfa..c3e4fde82 100644 --- a/ui/redux/selectors/content.js +++ b/ui/redux/selectors/content.js @@ -7,13 +7,10 @@ import { makeSelectClaimIsMine, makeSelectMediaTypeForUri, selectBalance, - parseURI, makeSelectContentTypeForUri, makeSelectFileNameForUri, } from 'lbry-redux'; -import { makeSelectRecommendedContentForUri } from 'redux/selectors/search'; -import { selectMutedChannels } from 'redux/selectors/blocked'; -import { selectAllCostInfoByUri, makeSelectCostInfoForUri } from 'lbryinc'; +import { makeSelectCostInfoForUri } from 'lbryinc'; import { selectShowMatureContent } from 'redux/selectors/settings'; import * as RENDER_MODES from 'constants/file_render_modes'; import path from 'path'; @@ -78,78 +75,6 @@ export const makeSelectHistoryForUri = (uri: string) => export const makeSelectHasVisitedUri = (uri: string) => createSelector(makeSelectHistoryForUri(uri), (history) => Boolean(history)); -export const makeSelectNextUnplayedRecommended = (uri: string) => - createSelector( - makeSelectRecommendedContentForUri(uri), - selectHistory, - selectClaimsByUri, - selectAllCostInfoByUri, - selectMutedChannels, - ( - recommendedForUri: Array, - history: Array<{ uri: string }>, - claimsByUri: { [string]: ?Claim }, - costInfoByUri: { [string]: { cost: 0 | string } }, - blockedChannels: Array - ) => { - if (recommendedForUri) { - // Make sure we don't autoplay paid content, channels, or content from blocked channels - for (let i = 0; i < recommendedForUri.length; i++) { - const recommendedUri = recommendedForUri[i]; - const claim = claimsByUri[recommendedUri]; - - if (!claim) { - continue; - } - - const { isChannel } = parseURI(recommendedUri); - if (isChannel) { - continue; - } - - const costInfo = costInfoByUri[recommendedUri]; - if (!costInfo || costInfo.cost !== 0) { - continue; - } - - // We already check if it's a channel above - // $FlowFixMe - const isVideo = claim.value && claim.value.stream_type === 'video'; - // $FlowFixMe - const isAudio = claim.value && claim.value.stream_type === 'audio'; - if (!isVideo && !isAudio) { - continue; - } - - const channel = claim && claim.signing_channel; - if (channel && blockedChannels.some((blockedUri) => blockedUri === channel.permanent_url)) { - continue; - } - - const recommendedUriInfo = parseURI(recommendedUri); - const recommendedUriShort = recommendedUriInfo.claimName + '#' + recommendedUriInfo.claimId.substring(0, 1); - - if (claimsByUri[uri] && claimsByUri[uri].claim_id === recommendedUriInfo.claimId) { - // Skip myself (same claim ID) - continue; - } - - if ( - !history.some((h) => { - const directMatch = h.uri === recommendedForUri[i]; - const shortUriMatch = h.uri.includes(recommendedUriShort); - const idMatch = claimsByUri[h.uri] && claimsByUri[h.uri].claim_id === recommendedUriInfo.claimId; - - return directMatch || shortUriMatch || idMatch; - }) - ) { - return recommendedForUri[i]; - } - } - } - } - ); - export const selectRecentHistory = createSelector(selectHistory, (history) => { return history.slice(0, RECENT_HISTORY_AMOUNT); }); diff --git a/ui/redux/selectors/search.js b/ui/redux/selectors/search.js index 5005eadfc..5d12f01d0 100644 --- a/ui/redux/selectors/search.js +++ b/ui/redux/selectors/search.js @@ -1,18 +1,23 @@ // @flow import { getSearchQueryString } from 'util/query-params'; import { selectShowMatureContent } from 'redux/selectors/settings'; +import { SEARCH_OPTIONS } from 'constants/search'; import { parseURI, + selectClaimsByUri, makeSelectClaimForUri, makeSelectClaimForClaimId, makeSelectClaimIsNsfw, - buildURI, isClaimNsfw, makeSelectPendingClaimForUri, makeSelectIsUriResolving, } from 'lbry-redux'; import { createSelector } from 'reselect'; import { createNormalizedSearchKey } from 'util/search'; +import { selectMutedChannels } from 'redux/selectors/blocked'; +import { selectHistory } from 'redux/selectors/content'; +import { selectAllCostInfoByUri } from 'lbryinc'; +import { SIMPLE_SITE } from 'config'; type State = { search: SearchState }; @@ -38,14 +43,12 @@ export const selectHasReachedMaxResultsLength: (state: State) => { [boolean]: Ar ); export const makeSelectSearchUrisForQuery = (query: string): ((state: State) => Array) => - // replace statement below is kind of ugly, and repeated in doSearch action createSelector(selectSearchResultByQuery, (byQuery) => { - if (query) { - query = query.replace(/^lbry:\/\//i, '').replace(/\//, ' '); - const normalizedQuery = createNormalizedSearchKey(query); - return byQuery[normalizedQuery] && byQuery[normalizedQuery]['uris']; - } - return byQuery[query] && byQuery[query]['uris']; + if (!query) return; + // replace statement below is kind of ugly, and repeated in doSearch action + query = query.replace(/^lbry:\/\//i, '').replace(/\//, ' '); + const normalizedQuery = createNormalizedSearchKey(query); + return byQuery[normalizedQuery] && byQuery[normalizedQuery]['uris']; }); export const makeSelectHasReachedMaxResultsLength = (query: string): ((state: State) => boolean) => @@ -60,34 +63,91 @@ export const makeSelectHasReachedMaxResultsLength = (query: string): ((state: St export const makeSelectRecommendedContentForUri = (uri: string) => createSelector( - makeSelectClaimForUri(uri), + selectHistory, + selectClaimsByUri, + selectShowMatureContent, + selectMutedChannels, + selectAllCostInfoByUri, selectSearchResultByQuery, makeSelectClaimIsNsfw(uri), - (claim, searchUrisByQuery, isMature) => { + (history, claimsByUri, matureEnabled, blockedChannels, costInfoByUri, searchUrisByQuery, isMature) => { + const claim = claimsByUri[uri]; + + if (!claim) return; + let recommendedContent; - if (claim) { - // always grab full URL - this can change once search returns canonical - const currentUri = buildURI({ streamClaimId: claim.claim_id, streamName: claim.name }); + // always grab the claimId - this value won't change for filtering + const currentClaimId = claim.claim_id; - const { title } = claim.value; + const { title } = claim.value; - if (!title) { - return; - } + if (!title) return; - const options: { - related_to?: string, - nsfw?: boolean, - isBackgroundSearch?: boolean, - } = { related_to: claim.claim_id, isBackgroundSearch: true }; + const options: { + size: number, + nsfw?: boolean, + isBackgroundSearch?: boolean, + } = { size: 20, nsfw: matureEnabled, isBackgroundSearch: true }; - options['nsfw'] = isMature; - const searchQuery = getSearchQueryString(title.replace(/\//, ' '), options); - const normalizedSearchQuery = createNormalizedSearchKey(searchQuery); + if (SIMPLE_SITE) { + options[SEARCH_OPTIONS.CLAIM_TYPE] = SEARCH_OPTIONS.INCLUDE_FILES; + options[SEARCH_OPTIONS.MEDIA_VIDEO] = true; + options[SEARCH_OPTIONS.PRICE_FILTER_FREE] = true; + } + if (matureEnabled || (!matureEnabled && !isMature)) { + options[SEARCH_OPTIONS.RELATED_TO] = claim.claim_id; + } - let searchResult = searchUrisByQuery[normalizedSearchQuery]; - if (searchResult) { - recommendedContent = searchResult['uris'].filter((searchUri) => searchUri !== currentUri); + const searchQuery = getSearchQueryString(title.replace(/\//, ' '), options); + const normalizedSearchQuery = createNormalizedSearchKey(searchQuery); + + let searchResult = searchUrisByQuery[normalizedSearchQuery]; + + if (searchResult) { + // Filter from recommended: The same claim and blocked channels + recommendedContent = searchResult['uris'].filter((searchUri) => { + const searchClaim = claimsByUri[searchUri]; + + if (!searchClaim) return; + + const signingChannel = searchClaim && searchClaim.signing_channel; + const channelUri = signingChannel && signingChannel.canonical_url; + const blockedMatch = blockedChannels.some((blockedUri) => blockedUri.includes(channelUri)); + + let isEqualUri; + try { + const { claimId: searchId } = parseURI(searchUri); + isEqualUri = searchId === currentClaimId; + } catch (e) {} + + return !isEqualUri && !blockedMatch; + }); + + // Claim to play next: playable and free claims not played before in history + const nextUriToPlay = recommendedContent.filter((nextRecommendedUri) => { + const costInfo = costInfoByUri[nextRecommendedUri] && costInfoByUri[nextRecommendedUri].cost; + const recommendedClaim = claimsByUri[nextRecommendedUri]; + const isVideo = recommendedClaim && recommendedClaim.value && recommendedClaim.value.stream_type === 'video'; + const isAudio = recommendedClaim && recommendedClaim.value && recommendedClaim.value.stream_type === 'audio'; + + let historyMatch = false; + try { + const { claimId: nextRecommendedId } = parseURI(nextRecommendedUri); + + historyMatch = history.some( + (historyItem) => + (claimsByUri[historyItem.uri] && claimsByUri[historyItem.uri].claim_id) === nextRecommendedId + ); + } catch (e) {} + + return !historyMatch && costInfo === 0 && (isVideo || isAudio); + })[0]; + + const index = recommendedContent.indexOf(nextUriToPlay); + if (index > 0) { + const a = recommendedContent[0]; + recommendedContent[0] = nextUriToPlay; + recommendedContent[index] = a; } } return recommendedContent; @@ -206,6 +266,6 @@ export const makeSelectIsResolvingWinningUri = (query: string = '') => { }; export const makeSelectUrlForClaimId = (claimId: string) => - createSelector( - makeSelectClaimForClaimId(claimId), (claim) => claim ? claim.canonical_url || claim.permanent_url : null + createSelector(makeSelectClaimForClaimId(claimId), (claim) => + claim ? claim.canonical_url || claim.permanent_url : null ); diff --git a/ui/util/query-params.js b/ui/util/query-params.js index bf4fa6bcc..ce28861ff 100644 --- a/ui/util/query-params.js +++ b/ui/util/query-params.js @@ -1,6 +1,5 @@ // @flow import { SEARCH_OPTIONS } from 'constants/search'; -import { SIMPLE_SITE } from 'config'; const DEFAULT_SEARCH_RESULT_FROM = 0; const DEFAULT_SEARCH_SIZE = 20; @@ -33,7 +32,6 @@ export function updateQueryParam(uri: string, key: string, value: string) { } export const getSearchQueryString = (query: string, options: any = {}) => { - const FORCE_FREE_ONLY = SIMPLE_SITE; const isSurroundedByQuotes = (str) => str[0] === '"' && str[str.length - 1] === '"'; const encodedQuery = encodeURIComponent(query); const queryParams = [ @@ -81,9 +79,11 @@ export const getSearchQueryString = (query: string, options: any = {}) => { const additionalOptions = {}; const { related_to } = options; const { nsfw } = options; + const { free_only } = options; - if (related_to) additionalOptions['related_to'] = related_to; - if (nsfw === false) additionalOptions['nsfw'] = false; + if (related_to) additionalOptions[SEARCH_OPTIONS.RELATED_TO] = related_to; + if (free_only) additionalOptions[SEARCH_OPTIONS.PRICE_FILTER_FREE] = true; + if (nsfw === false) additionalOptions[SEARCH_OPTIONS.INCLUDE_MATURE] = false; if (additionalOptions) { Object.keys(additionalOptions).forEach((key) => { @@ -92,13 +92,5 @@ export const getSearchQueryString = (query: string, options: any = {}) => { }); } - if (FORCE_FREE_ONLY) { - const index = queryParams.findIndex((q) => q.startsWith('free_only')); - if (index > -1) { - queryParams.splice(index, 1); - } - queryParams.push(`free_only=true`); - } - return queryParams.join('&'); };