diff --git a/ui/component/claimList/view.jsx b/ui/component/claimList/view.jsx index 1cf6743fe..79796ff5e 100644 --- a/ui/component/claimList/view.jsx +++ b/ui/component/claimList/view.jsx @@ -47,6 +47,7 @@ type Props = { searchOptions?: any, collectionId?: string, showNoSourceClaims?: boolean, + onClick?: (e: any, index?: number) => void, }; export default function ClaimList(props: Props) { @@ -55,6 +56,7 @@ export default function ClaimList(props: Props) { uris, headerAltControls, loading, + id, persistedStorageKey, empty, defaultSort, @@ -80,6 +82,7 @@ export default function ClaimList(props: Props) { searchOptions, collectionId, showNoSourceClaims, + onClick, } = props; const [currentSort, setCurrentSort] = usePersistedState(persistedStorageKey, SORT_NEW); @@ -107,6 +110,12 @@ export default function ClaimList(props: Props) { setCurrentSort(currentSort === SORT_NEW ? SORT_OLD : SORT_NEW); } + function handleClaimClicked(e, index) { + if (onClick) { + onClick(e, index); + } + } + useEffect(() => { const handleScroll = debounce((e) => { if (page && pageSize && onScrollBottom) { @@ -191,6 +200,8 @@ export default function ClaimList(props: Props) { {injectedItem && index === 4 &&
  • {injectedItem}
  • } ))} diff --git a/ui/component/claimPreview/view.jsx b/ui/component/claimPreview/view.jsx index 3f668d523..0f1bd76ce 100644 --- a/ui/component/claimPreview/view.jsx +++ b/ui/component/claimPreview/view.jsx @@ -29,6 +29,7 @@ import ClaimPreviewNoContent from './claim-preview-no-content'; import { ENABLE_NO_SOURCE_CLAIMS } from 'config'; import Button from 'component/button'; import * as ICONS from 'constants/icons'; +import { CONTAINER_ID } from 'constants/navigation'; const AbandonedChannelPreview = lazyImport(() => import('component/abandonedChannelPreview' /* webpackChunkName: "abandonedChannelPreview" */) @@ -45,7 +46,7 @@ type Props = { reflectingProgress?: any, // fxme resolveUri: (string) => void, isResolvingUri: boolean, - history: { push: (string) => void }, + history: { push: (string | any) => void }, title: string, nsfw: boolean, placeholder: string, @@ -65,7 +66,7 @@ type Props = { actions: boolean | Node | string | number, properties: boolean | Node | string | number | ((Claim) => Node), empty?: Node, - onClick?: (any) => any, + onClick?: (e: any, index?: number) => any, streamingUrl: ?string, getFile: (string) => void, customShouldHide?: (Claim) => boolean, @@ -88,6 +89,8 @@ type Props = { disableNavigation?: boolean, mediaDuration?: string, date?: any, + containerId?: string, // ID or name of the container (e.g. ClaimList, HOC, etc.) that this is in. + indexInContainer?: number, // The index order of this component within 'containerId'. }; const ClaimPreview = forwardRef((props: Props, ref: any) => { @@ -149,6 +152,8 @@ const ClaimPreview = forwardRef((props: Props, ref: any) => { isCollectionMine, collectionUris, disableNavigation, + containerId, + indexInContainer, } = props; const isCollection = claim && claim.value_type === 'collection'; const collectionClaimId = isCollection && claim && claim.claim_id; @@ -202,9 +207,21 @@ const ClaimPreview = forwardRef((props: Props, ref: any) => { collectionParams.set(COLLECTIONS_CONSTS.COLLECTION_ID, listId); navigateUrl = navigateUrl + `?` + collectionParams.toString(); } + + const handleNavLinkClick = (e) => { + if (onClick) { + onClick(e, indexInContainer); + } + e.stopPropagation(); + }; + const navLinkProps = { - to: navigateUrl, - onClick: (e) => e.stopPropagation(), + to: { + pathname: navigateUrl, + state: containerId ? { [CONTAINER_ID]: containerId } : undefined, + }, + onClick: (e) => handleNavLinkClick(e), + onAuxClick: (e) => handleNavLinkClick(e), }; // do not block abandoned and nsfw claims if showUserBlocked is passed @@ -250,11 +267,14 @@ const ClaimPreview = forwardRef((props: Props, ref: any) => { function handleOnClick(e) { if (onClick) { - onClick(e); + onClick(e, indexInContainer); } if (claim && !pending && !disableNavigation) { - history.push(navigateUrl); + history.push({ + pathname: navigateUrl, + state: containerId ? { [CONTAINER_ID]: containerId } : undefined, + }); } } diff --git a/ui/component/recommendedContent/index.js b/ui/component/recommendedContent/index.js index 2d9444649..ea5cf0421 100644 --- a/ui/component/recommendedContent/index.js +++ b/ui/component/recommendedContent/index.js @@ -1,5 +1,6 @@ import { connect } from 'react-redux'; import { makeSelectClaimIsNsfw, makeSelectClaimForUri } from 'lbry-redux'; +import { doRecommendationUpdate, doRecommendationClicked } from 'redux/actions/content'; import { doFetchRecommendedContent } from 'redux/actions/search'; import { makeSelectRecommendedContentForUri, selectIsSearching } from 'redux/selectors/search'; import { selectUserVerifiedEmail } from 'redux/selectors/user'; @@ -17,6 +18,9 @@ const select = (state, props) => ({ 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); diff --git a/ui/component/recommendedContent/view.jsx b/ui/component/recommendedContent/view.jsx index 9697fbda4..3adfa8fcf 100644 --- a/ui/component/recommendedContent/view.jsx +++ b/ui/component/recommendedContent/view.jsx @@ -1,6 +1,8 @@ // @flow import { SHOW_ADS } from 'config'; import React from 'react'; +import { useHistory } from 'react-router-dom'; +import { v4 as uuidv4 } from 'uuid'; import ClaimList from 'component/claimList'; import ClaimListDiscover from 'component/claimListDiscover'; import Ads from 'web/component/ads'; @@ -8,6 +10,7 @@ import Card from 'component/common/card'; import { useIsMobile, useIsMediumScreen } from 'effects/use-screensize'; import Button from 'component/button'; import classnames from 'classnames'; +import { CONTAINER_ID } from 'constants/navigation'; const VIEW_ALL_RELATED = 'view_all_related'; const VIEW_MORE_FROM = 'view_more_from'; @@ -21,6 +24,8 @@ type Props = { mature: boolean, isAuthenticated: boolean, claim: ?StreamClaim, + doRecommendationUpdate: (claimId: string, urls: Array, id: string, parentId: string) => void, + doRecommendationClicked: (claimId: string, index: number) => void, }; export default React.memo(function RecommendedContent(props: Props) { @@ -33,33 +38,70 @@ export default React.memo(function RecommendedContent(props: Props) { isSearching, isAuthenticated, claim, + doRecommendationUpdate, + doRecommendationClicked, } = props; const [viewMode, setViewMode] = React.useState(VIEW_ALL_RELATED); + const [recommendationId, setRecommendationId] = React.useState(''); + const [recommendationUrls, setRecommendationUrls] = React.useState(); + const history = useHistory(); const signingChannel = claim && claim.signing_channel; const channelName = signingChannel ? signingChannel.name : null; const isMobile = useIsMobile(); const isMedium = useIsMediumScreen(); - function reorderList(recommendedContent) { - let newList = recommendedContent; - if (newList) { - const index = newList.indexOf(nextRecommendedUri); - if (index === -1) { - // This would be weird. Shouldn't happen since it is derived from the same list. - } else if (index !== 0) { - // Swap the "next" item to the top of the list - const a = newList[0]; - newList[0] = nextRecommendedUri; - newList[index] = a; + 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; } } - return newList; - } + + const newRecommendationUrls = moveAutoplayNextItemToTop(recommendedContent); + + if (claim && !listEq(recommendationUrls, newRecommendationUrls)) { + const parentId = (history.location.state && history.location.state[CONTAINER_ID]) || ''; + const id = uuidv4(); + setRecommendationId(id); + setRecommendationUrls(newRecommendationUrls); + + doRecommendationUpdate(claim.claim_id, newRecommendationUrls, id, parentId); + } + }, [ + recommendedContent, + nextRecommendedUri, + recommendationUrls, + setRecommendationUrls, + claim, + doRecommendationUpdate, + history.location.state, + ]); React.useEffect(() => { doFetchRecommendedContent(uri, mature); }, [uri, mature, doFetchRecommendedContent]); + function handleRecommendationClicked(e: any, index: number) { + if (claim) { + doRecommendationClicked(claim.claim_id, index); + } + } + return ( (function RecommendedContent(props: Props) {
    {viewMode === VIEW_ALL_RELATED && ( } empty={__('No related content found')} + onClick={handleRecommendationClicked} /> )} {viewMode === VIEW_MORE_FROM && signingChannel && ( diff --git a/ui/component/viewers/videoViewer/internal/plugins/videojs-recsys/plugin.js b/ui/component/viewers/videoViewer/internal/plugins/videojs-recsys/plugin.js index d7bacdcce..5ee3f3c8d 100644 --- a/ui/component/viewers/videoViewer/internal/plugins/videojs-recsys/plugin.js +++ b/ui/component/viewers/videoViewer/internal/plugins/videojs-recsys/plugin.js @@ -1,6 +1,12 @@ // Created by xander on 6/21/2021 import videojs from 'video.js'; -import { v4 as uuidV4 } from 'uuid'; +import { + makeSelectRecommendationId, + makeSelectRecommendationParentId, + makeSelectRecommendedClaimIds, + makeSelectRecommendationClicks, +} from 'redux/selectors/content'; + const VERSION = '0.0.1'; const recsysEndpoint = 'https://clickstream.odysee.com/log/video/view'; @@ -17,23 +23,28 @@ const RecsysData = { }; function createRecsys(claimId, userId, events, loadedAt, isEmbed) { - // TODO: use a UUID generator - const uuid = uuidV4(); const pageLoadedAt = loadedAt; const pageExitedAt = Date.now(); - return { - uuid: uuid, - parentUuid: null, - uid: userId, - claimId: claimId, - pageLoadedAt: pageLoadedAt, - pageExitedAt: pageExitedAt, - recsysId: recsysId, - recClaimIds: null, - recClickedVideoIdx: null, - events: events, - isEmbed: isEmbed, - }; + + if (window.store) { + const state = window.store.getState(); + + return { + uuid: makeSelectRecommendationId(claimId)(state), + parentUuid: makeSelectRecommendationParentId(claimId)(state), + uid: userId, + claimId: claimId, + pageLoadedAt: pageLoadedAt, + pageExitedAt: pageExitedAt, + recsysId: recsysId, + recClaimIds: makeSelectRecommendedClaimIds(claimId)(state), + recClickedVideoIdx: makeSelectRecommendationClicks(claimId)(state), + events: events, + isEmbed: isEmbed, + }; + } + + return undefined; } function newRecsysEvent(eventType, offset, arg) { @@ -130,7 +141,10 @@ class RecsysPlugin extends Component { this.loadedAt, false ); - sendRecsysEvents(event); + + if (event) { + sendRecsysEvents(event); + } } onPlay(event) { @@ -226,7 +240,7 @@ const onPlayerReady = (player, options) => { * @function plugin * @param {Object} [options={}] */ -const plugin = function(options) { +const plugin = function (options) { this.ready(() => { onPlayerReady(this, videojs.mergeOptions(defaults, options)); }); diff --git a/ui/constants/action_types.js b/ui/constants/action_types.js index 42d638318..14f10ad6b 100644 --- a/ui/constants/action_types.js +++ b/ui/constants/action_types.js @@ -98,6 +98,8 @@ export const CLEAR_CONTENT_POSITION = 'CLEAR_CONTENT_POSITION'; export const SET_CONTENT_LAST_VIEWED = 'SET_CONTENT_LAST_VIEWED'; export const CLEAR_CONTENT_HISTORY_URI = 'CLEAR_CONTENT_HISTORY_URI'; export const CLEAR_CONTENT_HISTORY_ALL = 'CLEAR_CONTENT_HISTORY_ALL'; +export const RECOMMENDATION_UPDATED = 'RECOMMENDATION_UPDATED'; +export const RECOMMENDATION_CLICKED = 'RECOMMENDATION_CLICKED'; // Files export const FILE_LIST_STARTED = 'FILE_LIST_STARTED'; diff --git a/ui/constants/navigation.js b/ui/constants/navigation.js new file mode 100644 index 000000000..217945645 --- /dev/null +++ b/ui/constants/navigation.js @@ -0,0 +1 @@ +export const CONTAINER_ID = 'CONTAINER_ID'; diff --git a/ui/redux/actions/content.js b/ui/redux/actions/content.js index 17217a60b..b5bdc6356 100644 --- a/ui/redux/actions/content.js +++ b/ui/redux/actions/content.js @@ -43,7 +43,7 @@ export function doUpdateLoadStatus(uri: string, outpoint: string) { full_status: true, page: 1, page_size: 1, - }).then(result => { + }).then((result) => { const { items: fileInfos } = result; const fileInfo = fileInfos[0]; if (!fileInfo || fileInfo.written_bytes === 0) { @@ -261,3 +261,21 @@ export function doClearContentHistoryAll() { dispatch({ type: ACTIONS.CLEAR_CONTENT_HISTORY_ALL }); }; } + +export const doRecommendationUpdate = (claimId: string, urls: Array, id: string, parentId: string) => ( + dispatch: Dispatch +) => { + dispatch({ + type: ACTIONS.RECOMMENDATION_UPDATED, + data: { claimId, urls, id, parentId }, + }); +}; + +export const doRecommendationClicked = (claimId: string, index: number) => (dispatch: Dispatch) => { + if (index !== undefined && index !== null) { + dispatch({ + type: ACTIONS.RECOMMENDATION_CLICKED, + data: { claimId, index }, + }); + } +}; diff --git a/ui/redux/reducers/content.js b/ui/redux/reducers/content.js index 576cdf67f..7b0069f30 100644 --- a/ui/redux/reducers/content.js +++ b/ui/redux/reducers/content.js @@ -7,6 +7,10 @@ const defaultState = { channelClaimCounts: {}, positions: {}, history: [], + recommendationId: {}, // { "claimId": "recommendationId" } + recommendationParentId: {}, // { "claimId": "referrerId" } + recommendationUrls: {}, // { "claimId": [lbryUrls...] } + recommendationClicks: {}, // { "claimId": [clicked indices...] } }; reducers[ACTIONS.SET_PRIMARY_URI] = (state, action) => @@ -73,7 +77,7 @@ reducers[ACTIONS.SET_CONTENT_LAST_VIEWED] = (state, action) => { const { uri, lastViewed } = action.data; const { history } = state; const historyObj = { uri, lastViewed }; - const index = history.findIndex(i => i.uri === uri); + const index = history.findIndex((i) => i.uri === uri); const newHistory = index === -1 ? [historyObj].concat(history) @@ -84,7 +88,7 @@ reducers[ACTIONS.SET_CONTENT_LAST_VIEWED] = (state, action) => { reducers[ACTIONS.CLEAR_CONTENT_HISTORY_URI] = (state, action) => { const { uri } = action.data; const { history } = state; - const index = history.findIndex(i => i.uri === uri); + const index = history.findIndex((i) => i.uri === uri); return index === -1 ? state : { @@ -93,7 +97,44 @@ reducers[ACTIONS.CLEAR_CONTENT_HISTORY_URI] = (state, action) => { }; }; -reducers[ACTIONS.CLEAR_CONTENT_HISTORY_ALL] = state => ({ ...state, history: [] }); +reducers[ACTIONS.CLEAR_CONTENT_HISTORY_ALL] = (state) => ({ ...state, history: [] }); + +reducers[ACTIONS.RECOMMENDATION_UPDATED] = (state, action) => { + const { claimId, urls, id, parentId } = action.data; + const recommendationId = Object.assign({}, state.recommendationId); + const recommendationParentId = Object.assign({}, state.recommendationParentId); + const recommendationUrls = Object.assign({}, state.recommendationUrls); + const recommendationClicks = Object.assign({}, state.recommendationClicks); + + if (urls && urls.length > 0) { + recommendationId[claimId] = id; + recommendationParentId[claimId] = parentId; + recommendationUrls[claimId] = urls; + recommendationClicks[claimId] = []; + } else { + delete recommendationId[claimId]; + delete recommendationParentId[claimId]; + delete recommendationUrls[claimId]; + delete recommendationClicks[claimId]; + } + + return { ...state, recommendationId, recommendationParentId, recommendationUrls, recommendationClicks }; +}; + +reducers[ACTIONS.RECOMMENDATION_CLICKED] = (state, action) => { + const { claimId, index } = action.data; + const recommendationClicks = Object.assign({}, state.recommendationClicks); + + if (state.recommendationUrls[claimId] && index >= 0 && index < state.recommendationUrls[claimId].length) { + if (recommendationClicks[claimId]) { + recommendationClicks[claimId].push(index); + } else { + recommendationClicks[claimId] = [index]; + } + } + + return { ...state, recommendationClicks }; +}; // reducers[LBRY_REDUX_ACTIONS.PURCHASE_URI_FAILED] = (state, action) => { // return { diff --git a/ui/redux/selectors/content.js b/ui/redux/selectors/content.js index 570ed5c7e..faaa4acc5 100644 --- a/ui/redux/selectors/content.js +++ b/ui/redux/selectors/content.js @@ -13,6 +13,7 @@ import { makeSelectFileNameForUri, normalizeURI, selectMyActiveClaims, + selectClaimIdsByUri, } from 'lbry-redux'; import { makeSelectRecommendedContentForUri } from 'redux/selectors/search'; import { selectMutedChannels } from 'redux/selectors/blocked'; @@ -247,19 +248,37 @@ export const makeSelectInsufficientCreditsForUri = (uri: string) => ); export const makeSelectSigningIsMine = (rawUri: string) => { - let uri; + let uri; + try { + uri = normalizeURI(rawUri); + } catch (e) {} + + return createSelector(selectClaimsByUri, selectMyActiveClaims, (claims, myClaims) => { try { - uri = normalizeURI(rawUri); - } catch (e) { } + parseURI(uri); + } catch (e) { + return false; + } + const signingChannel = claims && claims[uri] && (claims[uri].signing_channel || claims[uri]); - return createSelector(selectClaimsByUri, selectMyActiveClaims, (claims, myClaims) => { - try { - parseURI(uri); - } catch (e) { - return false; - } - const signingChannel = claims && claims[uri] && (claims[uri].signing_channel || claims[uri]); + return signingChannel && myClaims.has(signingChannel.claim_id); + }); +}; - return signingChannel && myClaims.has(signingChannel.claim_id); - }); - }; +export const makeSelectRecommendationId = (claimId: string) => + createSelector(selectState, (state) => state.recommendationId[claimId]); + +export const makeSelectRecommendationParentId = (claimId: string) => + createSelector(selectState, (state) => state.recommendationParentId[claimId]); + +export const makeSelectRecommendedClaimIds = (claimId: string) => + createSelector(selectState, selectClaimIdsByUri, (state, claimIdsByUri) => { + const recommendationUrls = state.recommendationUrls[claimId]; + if (recommendationUrls) { + return recommendationUrls.map((url) => claimIdsByUri[url]); + } + return undefined; + }); + +export const makeSelectRecommendationClicks = (claimId: string) => + createSelector(selectState, (state) => state.recommendationClicks[claimId]);