// @flow import { RECSYS_ENDPOINT } from 'config'; import { selectUser } from 'redux/selectors/user'; import { makeSelectRecommendedRecsysIdForClaimId } from 'redux/selectors/search'; import { v4 as Uuidv4 } from 'uuid'; import { parseURI } from 'util/lbryURI'; import { getAuthToken } from 'util/saved-passwords'; import * as ACTIONS from 'constants/action_types'; import * as SETTINGS from 'constants/settings'; import { X_LBRY_AUTH_TOKEN } from 'constants/token'; import { makeSelectClaimForUri } from 'redux/selectors/claims'; import { selectPlayingUri, selectPrimaryUri } from 'redux/selectors/content'; import { selectClientSetting, selectDaemonSettings } from 'redux/selectors/settings'; import { selectIsSubscribedForClaimId } from 'redux/selectors/subscriptions'; // $FlowFixMe: cannot resolve.. import { history } from 'ui/store'; const recsysEndpoint = RECSYS_ENDPOINT; const recsysId = 'lighthouse-v0'; const getClaimIdsFromUris = (uris) => { return uris ? uris.map((uri) => { try { const { claimId } = parseURI(uri); return claimId; } catch (e) { return []; } }) : []; }; const recsys: Recsys = { entries: {}, debug: false, /** * Provides for creating, updating, and sending Clickstream data object Entries. * Entries are Created either when recommendedContent loads, or when recommendedContent is clicked. * If recommended content is clicked, An Entry with parentUuid is created. * On page load, find an empty entry with your claimId, or create a new entry and record to it. */ /** * Saves existing entries to persistence storage (in this case, Redux). */ saveEntries: function () { if (window && window.store) { window.store.dispatch({ type: ACTIONS.SET_RECSYS_ENTRIES, data: recsys.entries, }); } }, /** * Called when RecommendedContent was clicked. * Adds index of clicked recommendation to parent entry * Adds new Entry with parentUuid for destination page * @param parentClaimId: string, * @param newClaimId: string, */ onClickedRecommended: function (parentClaimId, newClaimId) { const parentEntry = recsys.entries[parentClaimId] ? recsys.entries[parentClaimId] : null; const parentUuid = parentEntry ? parentEntry['uuid'] : ''; const parentRecommendedClaims = parentEntry ? parentEntry['recClaimIds'] : []; const parentClickedIndexes = parentEntry ? parentEntry['recClickedVideoIdx'] : []; const indexClicked = parentRecommendedClaims.indexOf(newClaimId); if (parentUuid) { recsys.createRecsysEntry(newClaimId, parentUuid); } parentClickedIndexes.push(indexClicked); // recsys.log('onClickedRecommended', { parentClaimId, newClaimId }); recsys.log('onClickedRecommended', newClaimId); }, /** * Page was loaded. Get or Create entry and populate it with default data, * plus recommended content, recsysId, etc. * Called from recommendedContent component */ onRecsLoaded: function (claimId, uris, uuid = '') { if (window && window.store) { const state = window.store.getState(); if (!recsys.entries[claimId]) { recsys.createRecsysEntry(claimId, null, uuid); } const claimIds = getClaimIdsFromUris(uris); recsys.entries[claimId]['recsysId'] = makeSelectRecommendedRecsysIdForClaimId(claimId)(state) || recsysId; recsys.entries[claimId]['pageLoadedAt'] = Date.now(); // It is possible that `claimIds` include `null | undefined` entries // instead of all being strings. I don't know if we should filter it, // or change the `recClaimIds` definition. Leaving as is for now since // any changes could affect existing recsys data set. // ----------- // $FlowFixMe: recsys.entries[claimId]['recClaimIds'] = claimIds; } recsys.log('onRecsLoaded', claimId); }, /** * Creates an Entry with optional parentUuid * @param: claimId: string * @param: parentUuid: string (optional) * @param: uuid: string Specific uuid to use. */ createRecsysEntry: function (claimId, parentUuid, uuid = '') { if (window && window.store && claimId) { const state = window.store.getState(); const user = selectUser(state); const userId = user ? user.id : null; // Make a stub entry that will be filled out on page load // $FlowIgnore: not everything is defined since this is a stub recsys.entries[claimId] = { uuid: uuid || Uuidv4(), claimId: claimId, recClickedVideoIdx: [], pageLoadedAt: Date.now(), events: [], incognito: !(user && user.has_verified_email), isFollowing: selectIsSubscribedForClaimId(state, claimId), }; if (parentUuid) { // $FlowFixMe: 'uid' should be a number, not null. recsys.entries[claimId].uid = userId || null; recsys.entries[claimId].parentUuid = parentUuid; } else { // $FlowFixMe: 'uid' should be a number, not null. recsys.entries[claimId].uid = userId; // $FlowFixMe: 'recsysId' should be a number, not null. recsys.entries[claimId].recsysId = null; recsys.entries[claimId].recClaimIds = []; } recsys.saveEntries(); } recsys.log('createRecsysEntry', claimId); }, /** * Send event for claimId * @param claimId * @param isTentative Visibility change rather than tab closed. */ sendRecsysEntry: function (claimId, isTentative) { const shareTelemetry = IS_WEB || (window && window.store && selectDaemonSettings(window.store.getState()).share_usage_data); if (recsys.entries[claimId] && shareTelemetry) { const { events, ...entryData } = recsys.entries[claimId]; const data = JSON.stringify(entryData); return fetch(recsysEndpoint, { method: 'POST', headers: { [X_LBRY_AUTH_TOKEN]: getAuthToken(), 'Content-Type': 'application/json', }, body: data, }) .then(() => { if (!isTentative) { delete recsys.entries[claimId]; recsys.saveEntries(); } }) .catch((err) => { console.log('RECSYS: failed to send entry', err); }); } recsys.log('sendRecsysEntry', claimId); }, sendEntries: function (entries, isResumedSend) { if (entries) { if (Object.keys(recsys.entries).length !== 0) { // Typically called on startup only. console.warn('RECSYS: sendEntries() called on non-empty state. Data will be overwritten.'); } recsys.entries = entries; } Object.keys(recsys.entries).forEach((claimId) => { recsys.entries[claimId].isResumedSend = isResumedSend; recsys.sendRecsysEntry(claimId, false); // Send and delete. }); }, /** * A player event fired. Get the Entry for the claimId, and add the events * @param claimId * @param event */ onRecsysPlayerEvent: function (claimId, event, isEmbedded) { const state = window.store.getState(); const autoPlayNext = state && selectClientSetting(state, SETTINGS.AUTOPLAY_NEXT); // Check if played through (4 = onEnded) and handle multiple events at end if (recsys.entries[claimId] && !recsys.entries[claimId]['autoplay'] === true) { if (autoPlayNext && event.event === 4) { recsys.entries[claimId]['autoplay'] = true; } else { recsys.entries[claimId]['autoplay'] = false; } } if (!recsys.entries[claimId]) { recsys.createRecsysEntry(claimId); // do something to show it's floating or autoplay } if (isEmbedded) { recsys.entries[claimId]['isEmbed'] = true; } recsys.entries[claimId].events.push(event); recsys.log('onRecsysPlayerEvent', claimId); }, log: function (callName, claimId) { if (recsys.debug) { console.log(`Call: ***${callName}***, ClaimId: ${claimId}, Recsys Entries`, Object.assign({}, recsys.entries)); } }, /** * Player closed. Check to see if primaryUri = playingUri * if so, send the Entry. */ onPlayerDispose: function (claimId, isEmbedded, totalPlayingTime) { if (window && window.store) { const state = window.store.getState(); const playingUri = selectPlayingUri(state); const primaryUri = selectPrimaryUri(state); const onFilePage = playingUri === primaryUri; if (!onFilePage || isEmbedded) { if (isEmbedded) { recsys.entries[claimId]['isEmbed'] = true; } recsys.entries[claimId]['totalPlayTime'] = totalPlayingTime; recsys.sendRecsysEntry(claimId); } } recsys.log('PlayerDispose', claimId); }, // /** // * File page unmount or change event // * Check to see if playingUri, floatingEnabled, primaryUri === playingUri // * If not, send the Entry. // * If floating enabled, leaving file page will pop out player, leading to // * more events until player is disposed. Don't send unless floatingPlayer playingUri // */ // onLeaveFilePage: function (primaryUri) { // if (window && window.store) { // const state = window.store.getState(); // const claim = makeSelectClaimForUri(primaryUri)(state); // const claimId = claim ? claim.claim_id : null; // const playingUri = selectPlayingUri(state); // const actualPlayingUri = playingUri && playingUri.uri; // // const primaryUri = selectPrimaryUri(state); // const floatingPlayer = makeSelectClientSetting(SETTINGS.FLOATING_PLAYER)(state); // // When leaving page, if floating player is enabled, play will continue. // if (claimId) { // recsys.entries[claimId]['pageExitedAt'] = Date.now(); // } // const shouldSend = // (claimId && floatingPlayer && actualPlayingUri && actualPlayingUri !== primaryUri) || !floatingPlayer || !actualPlayingUri; // if (shouldSend) { // recsys.sendRecsysEntry(claimId); // } // recsys.log('LeaveFile', claimId); // } // }, /** * Navigate event * Send all claimIds that aren't currently playing. */ onNavigate: function () { if (window && window.store) { const state = window.store.getState(); const playingUri = selectPlayingUri(state); const actualPlayingUri = playingUri && playingUri.uri; const claim = makeSelectClaimForUri(actualPlayingUri || '')(state); const playingClaimId = claim ? claim.claim_id : null; // const primaryUri = selectPrimaryUri(state); const floatingPlayer = selectClientSetting(state, SETTINGS.FLOATING_PLAYER); // When leaving page, if floating player is enabled, play will continue. Object.keys(recsys.entries).forEach((claimId) => { const shouldSkip = recsys.entries[claimId].parentUuid && !recsys.entries[claimId].recClaimIds; if (!shouldSkip && ((claimId !== playingClaimId && floatingPlayer) || !floatingPlayer)) { recsys.entries[claimId]['pageExitedAt'] = Date.now(); recsys.saveEntries(); // recsys.sendRecsysEntry(claimId); breaks pop out = off, not helping with browser close. } recsys.log('OnNavigate', claimId); }); } }, }; history.listen(() => { recsys.onNavigate(); }); export default recsys;