Fill in remaining Recsys fields

## Issue
6366 Recsys Evaluation Telemetry

The recommended list from lighthouse is obtained from `makeSelectRecommendedContentForUri`. This list is further tweaked by the GUI (e.g. move autoplay next item to top, remove blocked content, etc.). Recsys wants the final recommendation list and the clicked index (in exact order), so we need pass these info to the videojs recsys plugin somehow. Also, Recsys wants a recommendation list ID as well as the parent (referrer) ID, we so need to track the clicks and navigation.

## General Approach
- It seems easiest to just spew back the final (displayed) list and all the required info to Redux, and the recsys plugin (or anyone else in the future) can grab it.
- Try to touch few files as possible. The dirty work should all reside in `<RecommendedContent>` only.

## Changes
- `ClaimPreview`: add optional parameters to store an ID of the container that it is in (for this case, it is `ClaimList`) as well as the index within the container.
  - When clicked, we store the container ID in the navigation history `state` object.
    - For general cases, anyone can check this state from  `history.location.state` to know which container referred/navigated to the current page. For the recsys use case, we can use this as the `parentUUID`.
- `ClaimList`: just relay `onClick` and set IDs.
- `RecommendedContent`: now handles the uuid generation (for both parent and child) and stores the data in Redux.
This commit is contained in:
infinite-persistence 2021-07-28 20:07:49 +08:00 committed by jessopb
parent f8796e2950
commit 34368760de
10 changed files with 230 additions and 55 deletions

View file

@ -47,6 +47,7 @@ type Props = {
searchOptions?: any, searchOptions?: any,
collectionId?: string, collectionId?: string,
showNoSourceClaims?: boolean, showNoSourceClaims?: boolean,
onClick?: (e: any, index?: number) => void,
}; };
export default function ClaimList(props: Props) { export default function ClaimList(props: Props) {
@ -55,6 +56,7 @@ export default function ClaimList(props: Props) {
uris, uris,
headerAltControls, headerAltControls,
loading, loading,
id,
persistedStorageKey, persistedStorageKey,
empty, empty,
defaultSort, defaultSort,
@ -80,6 +82,7 @@ export default function ClaimList(props: Props) {
searchOptions, searchOptions,
collectionId, collectionId,
showNoSourceClaims, showNoSourceClaims,
onClick,
} = props; } = props;
const [currentSort, setCurrentSort] = usePersistedState(persistedStorageKey, SORT_NEW); 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); setCurrentSort(currentSort === SORT_NEW ? SORT_OLD : SORT_NEW);
} }
function handleClaimClicked(e, index) {
if (onClick) {
onClick(e, index);
}
}
useEffect(() => { useEffect(() => {
const handleScroll = debounce((e) => { const handleScroll = debounce((e) => {
if (page && pageSize && onScrollBottom) { if (page && pageSize && onScrollBottom) {
@ -191,6 +200,8 @@ export default function ClaimList(props: Props) {
{injectedItem && index === 4 && <li>{injectedItem}</li>} {injectedItem && index === 4 && <li>{injectedItem}</li>}
<ClaimPreview <ClaimPreview
uri={uri} uri={uri}
containerId={id}
indexInContainer={index}
type={type} type={type}
active={activeUri && uri === activeUri} active={activeUri && uri === activeUri}
hideMenu={hideMenu} hideMenu={hideMenu}
@ -209,6 +220,7 @@ export default function ClaimList(props: Props) {
return claim.name.length === 24 && !claim.name.includes(' ') && claim.value.author === 'Spee.ch'; return claim.name.length === 24 && !claim.name.includes(' ') && claim.value.author === 'Spee.ch';
}} }}
live={resolveLive(index)} live={resolveLive(index)}
onClick={handleClaimClicked}
/> />
</React.Fragment> </React.Fragment>
))} ))}

View file

@ -29,6 +29,7 @@ import ClaimPreviewNoContent from './claim-preview-no-content';
import { ENABLE_NO_SOURCE_CLAIMS } from 'config'; import { ENABLE_NO_SOURCE_CLAIMS } from 'config';
import Button from 'component/button'; import Button from 'component/button';
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import { CONTAINER_ID } from 'constants/navigation';
const AbandonedChannelPreview = lazyImport(() => const AbandonedChannelPreview = lazyImport(() =>
import('component/abandonedChannelPreview' /* webpackChunkName: "abandonedChannelPreview" */) import('component/abandonedChannelPreview' /* webpackChunkName: "abandonedChannelPreview" */)
@ -45,7 +46,7 @@ type Props = {
reflectingProgress?: any, // fxme reflectingProgress?: any, // fxme
resolveUri: (string) => void, resolveUri: (string) => void,
isResolvingUri: boolean, isResolvingUri: boolean,
history: { push: (string) => void }, history: { push: (string | any) => void },
title: string, title: string,
nsfw: boolean, nsfw: boolean,
placeholder: string, placeholder: string,
@ -65,7 +66,7 @@ type Props = {
actions: boolean | Node | string | number, actions: boolean | Node | string | number,
properties: boolean | Node | string | number | ((Claim) => Node), properties: boolean | Node | string | number | ((Claim) => Node),
empty?: Node, empty?: Node,
onClick?: (any) => any, onClick?: (e: any, index?: number) => any,
streamingUrl: ?string, streamingUrl: ?string,
getFile: (string) => void, getFile: (string) => void,
customShouldHide?: (Claim) => boolean, customShouldHide?: (Claim) => boolean,
@ -88,6 +89,8 @@ type Props = {
disableNavigation?: boolean, disableNavigation?: boolean,
mediaDuration?: string, mediaDuration?: string,
date?: any, 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<any, {}>((props: Props, ref: any) => { const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
@ -149,6 +152,8 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
isCollectionMine, isCollectionMine,
collectionUris, collectionUris,
disableNavigation, disableNavigation,
containerId,
indexInContainer,
} = props; } = props;
const isCollection = claim && claim.value_type === 'collection'; const isCollection = claim && claim.value_type === 'collection';
const collectionClaimId = isCollection && claim && claim.claim_id; const collectionClaimId = isCollection && claim && claim.claim_id;
@ -202,9 +207,21 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
collectionParams.set(COLLECTIONS_CONSTS.COLLECTION_ID, listId); collectionParams.set(COLLECTIONS_CONSTS.COLLECTION_ID, listId);
navigateUrl = navigateUrl + `?` + collectionParams.toString(); navigateUrl = navigateUrl + `?` + collectionParams.toString();
} }
const handleNavLinkClick = (e) => {
if (onClick) {
onClick(e, indexInContainer);
}
e.stopPropagation();
};
const navLinkProps = { const navLinkProps = {
to: navigateUrl, to: {
onClick: (e) => e.stopPropagation(), 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 // do not block abandoned and nsfw claims if showUserBlocked is passed
@ -250,11 +267,14 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
function handleOnClick(e) { function handleOnClick(e) {
if (onClick) { if (onClick) {
onClick(e); onClick(e, indexInContainer);
} }
if (claim && !pending && !disableNavigation) { if (claim && !pending && !disableNavigation) {
history.push(navigateUrl); history.push({
pathname: navigateUrl,
state: containerId ? { [CONTAINER_ID]: containerId } : undefined,
});
} }
} }

View file

@ -1,5 +1,6 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { makeSelectClaimIsNsfw, makeSelectClaimForUri } from 'lbry-redux'; import { makeSelectClaimIsNsfw, makeSelectClaimForUri } from 'lbry-redux';
import { doRecommendationUpdate, doRecommendationClicked } from 'redux/actions/content';
import { doFetchRecommendedContent } from 'redux/actions/search'; import { doFetchRecommendedContent } from 'redux/actions/search';
import { makeSelectRecommendedContentForUri, selectIsSearching } from 'redux/selectors/search'; import { makeSelectRecommendedContentForUri, selectIsSearching } from 'redux/selectors/search';
import { selectUserVerifiedEmail } from 'redux/selectors/user'; import { selectUserVerifiedEmail } from 'redux/selectors/user';
@ -17,6 +18,9 @@ const select = (state, props) => ({
const perform = (dispatch) => ({ const perform = (dispatch) => ({
doFetchRecommendedContent: (uri, mature) => dispatch(doFetchRecommendedContent(uri, mature)), 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, perform)(RecommendedContent);

View file

@ -1,6 +1,8 @@
// @flow // @flow
import { SHOW_ADS } from 'config'; import { SHOW_ADS } from 'config';
import React from 'react'; import React from 'react';
import { useHistory } from 'react-router-dom';
import { v4 as uuidv4 } from 'uuid';
import ClaimList from 'component/claimList'; import ClaimList from 'component/claimList';
import ClaimListDiscover from 'component/claimListDiscover'; import ClaimListDiscover from 'component/claimListDiscover';
import Ads from 'web/component/ads'; import Ads from 'web/component/ads';
@ -8,6 +10,7 @@ import Card from 'component/common/card';
import { useIsMobile, useIsMediumScreen } from 'effects/use-screensize'; import { useIsMobile, useIsMediumScreen } from 'effects/use-screensize';
import Button from 'component/button'; import Button from 'component/button';
import classnames from 'classnames'; import classnames from 'classnames';
import { CONTAINER_ID } from 'constants/navigation';
const VIEW_ALL_RELATED = 'view_all_related'; const VIEW_ALL_RELATED = 'view_all_related';
const VIEW_MORE_FROM = 'view_more_from'; const VIEW_MORE_FROM = 'view_more_from';
@ -21,6 +24,8 @@ type Props = {
mature: boolean, mature: boolean,
isAuthenticated: boolean, isAuthenticated: boolean,
claim: ?StreamClaim, claim: ?StreamClaim,
doRecommendationUpdate: (claimId: string, urls: Array<string>, id: string, parentId: string) => void,
doRecommendationClicked: (claimId: string, index: number) => void,
}; };
export default React.memo<Props>(function RecommendedContent(props: Props) { export default React.memo<Props>(function RecommendedContent(props: Props) {
@ -33,21 +38,24 @@ export default React.memo<Props>(function RecommendedContent(props: Props) {
isSearching, isSearching,
isAuthenticated, isAuthenticated,
claim, claim,
doRecommendationUpdate,
doRecommendationClicked,
} = props; } = props;
const [viewMode, setViewMode] = React.useState(VIEW_ALL_RELATED); 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 signingChannel = claim && claim.signing_channel;
const channelName = signingChannel ? signingChannel.name : null; const channelName = signingChannel ? signingChannel.name : null;
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isMedium = useIsMediumScreen(); const isMedium = useIsMediumScreen();
function reorderList(recommendedContent) { React.useEffect(() => {
function moveAutoplayNextItemToTop(recommendedContent) {
let newList = recommendedContent; let newList = recommendedContent;
if (newList) { if (newList) {
const index = newList.indexOf(nextRecommendedUri); const index = newList.indexOf(nextRecommendedUri);
if (index === -1) { if (index > 0) {
// 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]; const a = newList[0];
newList[0] = nextRecommendedUri; newList[0] = nextRecommendedUri;
newList[index] = a; newList[index] = a;
@ -56,10 +64,44 @@ export default React.memo<Props>(function RecommendedContent(props: Props) {
return newList; 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(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(() => { React.useEffect(() => {
doFetchRecommendedContent(uri, mature); doFetchRecommendedContent(uri, mature);
}, [uri, mature, doFetchRecommendedContent]); }, [uri, mature, doFetchRecommendedContent]);
function handleRecommendationClicked(e: any, index: number) {
if (claim) {
doRecommendationClicked(claim.claim_id, index);
}
}
return ( return (
<Card <Card
isBodyList isBodyList
@ -91,12 +133,14 @@ export default React.memo<Props>(function RecommendedContent(props: Props) {
<div> <div>
{viewMode === VIEW_ALL_RELATED && ( {viewMode === VIEW_ALL_RELATED && (
<ClaimList <ClaimList
id={recommendationId}
type="small" type="small"
loading={isSearching} loading={isSearching}
uris={reorderList(recommendedContent)} uris={recommendationUrls}
hideMenu={isMobile} hideMenu={isMobile}
injectedItem={SHOW_ADS && IS_WEB && !isAuthenticated && <Ads small type={'video'} />} injectedItem={SHOW_ADS && IS_WEB && !isAuthenticated && <Ads small type={'video'} />}
empty={__('No related content found')} empty={__('No related content found')}
onClick={handleRecommendationClicked}
/> />
)} )}
{viewMode === VIEW_MORE_FROM && signingChannel && ( {viewMode === VIEW_MORE_FROM && signingChannel && (

View file

@ -1,6 +1,12 @@
// Created by xander on 6/21/2021 // Created by xander on 6/21/2021
import videojs from 'video.js'; 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 VERSION = '0.0.1';
const recsysEndpoint = 'https://clickstream.odysee.com/log/video/view'; const recsysEndpoint = 'https://clickstream.odysee.com/log/video/view';
@ -17,25 +23,30 @@ const RecsysData = {
}; };
function createRecsys(claimId, userId, events, loadedAt, isEmbed) { function createRecsys(claimId, userId, events, loadedAt, isEmbed) {
// TODO: use a UUID generator
const uuid = uuidV4();
const pageLoadedAt = loadedAt; const pageLoadedAt = loadedAt;
const pageExitedAt = Date.now(); const pageExitedAt = Date.now();
if (window.store) {
const state = window.store.getState();
return { return {
uuid: uuid, uuid: makeSelectRecommendationId(claimId)(state),
parentUuid: null, parentUuid: makeSelectRecommendationParentId(claimId)(state),
uid: userId, uid: userId,
claimId: claimId, claimId: claimId,
pageLoadedAt: pageLoadedAt, pageLoadedAt: pageLoadedAt,
pageExitedAt: pageExitedAt, pageExitedAt: pageExitedAt,
recsysId: recsysId, recsysId: recsysId,
recClaimIds: null, recClaimIds: makeSelectRecommendedClaimIds(claimId)(state),
recClickedVideoIdx: null, recClickedVideoIdx: makeSelectRecommendationClicks(claimId)(state),
events: events, events: events,
isEmbed: isEmbed, isEmbed: isEmbed,
}; };
} }
return undefined;
}
function newRecsysEvent(eventType, offset, arg) { function newRecsysEvent(eventType, offset, arg) {
if (arg) { if (arg) {
return { return {
@ -130,8 +141,11 @@ class RecsysPlugin extends Component {
this.loadedAt, this.loadedAt,
false false
); );
if (event) {
sendRecsysEvents(event); sendRecsysEvents(event);
} }
}
onPlay(event) { onPlay(event) {
const recsysEvent = newRecsysEvent(RecsysData.event.start, this.player.currentTime()); const recsysEvent = newRecsysEvent(RecsysData.event.start, this.player.currentTime());

View file

@ -98,6 +98,8 @@ export const CLEAR_CONTENT_POSITION = 'CLEAR_CONTENT_POSITION';
export const SET_CONTENT_LAST_VIEWED = 'SET_CONTENT_LAST_VIEWED'; 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_URI = 'CLEAR_CONTENT_HISTORY_URI';
export const CLEAR_CONTENT_HISTORY_ALL = 'CLEAR_CONTENT_HISTORY_ALL'; export const CLEAR_CONTENT_HISTORY_ALL = 'CLEAR_CONTENT_HISTORY_ALL';
export const RECOMMENDATION_UPDATED = 'RECOMMENDATION_UPDATED';
export const RECOMMENDATION_CLICKED = 'RECOMMENDATION_CLICKED';
// Files // Files
export const FILE_LIST_STARTED = 'FILE_LIST_STARTED'; export const FILE_LIST_STARTED = 'FILE_LIST_STARTED';

View file

@ -0,0 +1 @@
export const CONTAINER_ID = 'CONTAINER_ID';

View file

@ -43,7 +43,7 @@ export function doUpdateLoadStatus(uri: string, outpoint: string) {
full_status: true, full_status: true,
page: 1, page: 1,
page_size: 1, page_size: 1,
}).then(result => { }).then((result) => {
const { items: fileInfos } = result; const { items: fileInfos } = result;
const fileInfo = fileInfos[0]; const fileInfo = fileInfos[0];
if (!fileInfo || fileInfo.written_bytes === 0) { if (!fileInfo || fileInfo.written_bytes === 0) {
@ -261,3 +261,21 @@ export function doClearContentHistoryAll() {
dispatch({ type: ACTIONS.CLEAR_CONTENT_HISTORY_ALL }); dispatch({ type: ACTIONS.CLEAR_CONTENT_HISTORY_ALL });
}; };
} }
export const doRecommendationUpdate = (claimId: string, urls: Array<string>, 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 },
});
}
};

View file

@ -7,6 +7,10 @@ const defaultState = {
channelClaimCounts: {}, channelClaimCounts: {},
positions: {}, positions: {},
history: [], history: [],
recommendationId: {}, // { "claimId": "recommendationId" }
recommendationParentId: {}, // { "claimId": "referrerId" }
recommendationUrls: {}, // { "claimId": [lbryUrls...] }
recommendationClicks: {}, // { "claimId": [clicked indices...] }
}; };
reducers[ACTIONS.SET_PRIMARY_URI] = (state, action) => 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 { uri, lastViewed } = action.data;
const { history } = state; const { history } = state;
const historyObj = { uri, lastViewed }; const historyObj = { uri, lastViewed };
const index = history.findIndex(i => i.uri === uri); const index = history.findIndex((i) => i.uri === uri);
const newHistory = const newHistory =
index === -1 index === -1
? [historyObj].concat(history) ? [historyObj].concat(history)
@ -84,7 +88,7 @@ reducers[ACTIONS.SET_CONTENT_LAST_VIEWED] = (state, action) => {
reducers[ACTIONS.CLEAR_CONTENT_HISTORY_URI] = (state, action) => { reducers[ACTIONS.CLEAR_CONTENT_HISTORY_URI] = (state, action) => {
const { uri } = action.data; const { uri } = action.data;
const { history } = state; const { history } = state;
const index = history.findIndex(i => i.uri === uri); const index = history.findIndex((i) => i.uri === uri);
return index === -1 return index === -1
? state ? 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) => { // reducers[LBRY_REDUX_ACTIONS.PURCHASE_URI_FAILED] = (state, action) => {
// return { // return {

View file

@ -13,6 +13,7 @@ import {
makeSelectFileNameForUri, makeSelectFileNameForUri,
normalizeURI, normalizeURI,
selectMyActiveClaims, selectMyActiveClaims,
selectClaimIdsByUri,
} from 'lbry-redux'; } from 'lbry-redux';
import { makeSelectRecommendedContentForUri } from 'redux/selectors/search'; import { makeSelectRecommendedContentForUri } from 'redux/selectors/search';
import { selectMutedChannels } from 'redux/selectors/blocked'; import { selectMutedChannels } from 'redux/selectors/blocked';
@ -263,3 +264,21 @@ export const makeSelectSigningIsMine = (rawUri: string) => {
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]);