1e67a5cc7f
* Factor out lighthouse-result processing code for FYP re-use. The FYP results will be in the same format as LH. * Recsys: add ability to pass in specific uuid to use For FYP, we want to pass the UUID as a param when searching for recommendations. The search comes before the recsys entry creation, so we need to generate the UUID first when searching, and then tell recsys to use that specific ID. * Redux: fetch and store FYP Note that the gid cannot be used as "hash" for the uri list -- it doesn't necessarily change when the list changes, so we can't use it to optimize redux. For now, just always update/render when re-fetched. * UI for FYP * Mark rendered FYPs * Pass the FYP ID down the same way as Collection ID Not ideal, but at least it's in the same pattern as existing code for now. The whole prop-drilling problem with the claim components will be fixed together later. * Include 'gid' and 'uuid' in recommendation search * Allow users to mark recommendations that they dislike * Pass auth-token to all FYP requests + remove beacon use beacons are unreliable and often blocked * Only show FYP for members * FYP readme page * small fixes * fyp Co-authored-by: Thomas Zarebczan <thomas.zarebczan@gmail.com>
264 lines
10 KiB
JavaScript
264 lines
10 KiB
JavaScript
// @flow
|
|
import { getSearchQueryString } from 'util/query-params';
|
|
import { selectShowMatureContent } from 'redux/selectors/settings';
|
|
import { SEARCH_OPTIONS } from 'constants/search';
|
|
import {
|
|
selectClaimsByUri,
|
|
makeSelectClaimForUri,
|
|
makeSelectClaimForClaimId,
|
|
selectClaimIsNsfwForUri,
|
|
makeSelectPendingClaimForUri,
|
|
selectIsUriResolving,
|
|
} from 'redux/selectors/claims';
|
|
import { parseURI } from 'util/lbryURI';
|
|
import { isClaimNsfw } from 'util/claim';
|
|
import { createSelector } from 'reselect';
|
|
import { createCachedSelector } from 're-reselect';
|
|
import { createNormalizedSearchKey, getRecommendationSearchOptions } 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 = { claims: any, search: SearchState, user: User };
|
|
|
|
export const selectState = (state: State): SearchState => state.search;
|
|
|
|
// $FlowFixMe - 'searchQuery' is never populated. Something lost in a merge?
|
|
export const selectSearchValue: (state: State) => string = (state) => selectState(state).searchQuery;
|
|
export const selectSearchOptions: (state: State) => SearchOptions = (state) => selectState(state).options;
|
|
export const selectIsSearching: (state: State) => boolean = (state) => selectState(state).searching;
|
|
export const selectSearchResultByQuery: (state: State) => { [string]: Array<string> } = (state) =>
|
|
selectState(state).resultsByQuery;
|
|
export const selectHasReachedMaxResultsLength: (state: State) => { [boolean]: Array<boolean> } = (state) =>
|
|
selectState(state).hasReachedMaxResultsLength;
|
|
export const selectMentionSearchResults: (state: State) => Array<string> = (state) => selectState(state).results;
|
|
export const selectMentionQuery: (state: State) => string = (state) => selectState(state).mentionQuery;
|
|
export const selectPersonalRecommendations = (state: State) => selectState(state).personalRecommendations;
|
|
|
|
export const makeSelectSearchUrisForQuery = (query: string): ((state: State) => Array<string>) =>
|
|
createSelector(selectSearchResultByQuery, (byQuery) => {
|
|
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) =>
|
|
createSelector(selectHasReachedMaxResultsLength, (hasReachedMaxResultsLength) => {
|
|
if (query) {
|
|
query = query.replace(/^lbry:\/\//i, '').replace(/\//, ' ');
|
|
const normalizedQuery = createNormalizedSearchKey(query);
|
|
return hasReachedMaxResultsLength[normalizedQuery];
|
|
}
|
|
return hasReachedMaxResultsLength[query];
|
|
});
|
|
|
|
export const selectRecommendedContentForUri = createCachedSelector(
|
|
(state, uri) => uri,
|
|
selectHistory,
|
|
selectClaimsByUri,
|
|
selectShowMatureContent,
|
|
selectMutedChannels,
|
|
selectAllCostInfoByUri,
|
|
selectSearchResultByQuery,
|
|
selectClaimIsNsfwForUri, // (state, uri)
|
|
(uri, history, claimsByUri, matureEnabled, blockedChannels, costInfoByUri, searchUrisByQuery, isMature) => {
|
|
const claim = claimsByUri[uri];
|
|
|
|
if (!claim) return;
|
|
|
|
let recommendedContent;
|
|
// always grab the claimId - this value won't change for filtering
|
|
const currentClaimId = claim.claim_id;
|
|
|
|
const { title } = claim.value;
|
|
|
|
if (!title) return;
|
|
|
|
const options: {
|
|
size: number,
|
|
nsfw?: boolean,
|
|
isBackgroundSearch?: boolean,
|
|
} = { 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 || (!matureEnabled && !isMature)) {
|
|
options[SEARCH_OPTIONS.RELATED_TO] = claim.claim_id;
|
|
}
|
|
|
|
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 true;
|
|
}
|
|
|
|
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
|
|
for (let i = 0; i < recommendedContent.length; ++i) {
|
|
const nextRecommendedUri = recommendedContent[i];
|
|
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) {}
|
|
|
|
if (!historyMatch && costInfo === 0 && (isVideo || isAudio)) {
|
|
// Better next-uri found, swap with top entry:
|
|
if (i > 0) {
|
|
const a = recommendedContent[0];
|
|
recommendedContent[0] = nextRecommendedUri;
|
|
recommendedContent[i] = a;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return recommendedContent;
|
|
}
|
|
)((state, uri) => String(uri));
|
|
|
|
export const makeSelectRecommendedRecsysIdForClaimId = (claimId: string) =>
|
|
createSelector(
|
|
makeSelectClaimForClaimId(claimId),
|
|
selectShowMatureContent,
|
|
selectSearchResultByQuery,
|
|
(claim, matureEnabled, searchUrisByQuery) => {
|
|
// TODO: DRY this out.
|
|
let poweredBy;
|
|
if (claim && claimId) {
|
|
const isMature = isClaimNsfw(claim);
|
|
const { title } = claim.value;
|
|
if (!title) {
|
|
return;
|
|
}
|
|
|
|
const options = getRecommendationSearchOptions(matureEnabled, isMature, claimId);
|
|
const searchQuery = getSearchQueryString(title.replace(/\//, ' '), options);
|
|
const normalizedSearchQuery = createNormalizedSearchKey(searchQuery);
|
|
|
|
const searchResult = searchUrisByQuery[normalizedSearchQuery];
|
|
if (searchResult) {
|
|
poweredBy = searchResult.recsys;
|
|
} else {
|
|
return normalizedSearchQuery;
|
|
}
|
|
}
|
|
return poweredBy;
|
|
}
|
|
);
|
|
|
|
export const makeSelectWinningUriForQuery = (query: string) => {
|
|
const uriFromQuery = `lbry://${query}`;
|
|
|
|
let channelUriFromQuery = '';
|
|
try {
|
|
const { isChannel } = parseURI(uriFromQuery);
|
|
if (!isChannel) {
|
|
channelUriFromQuery = `lbry://@${query}`;
|
|
}
|
|
} catch (e) {}
|
|
|
|
return createSelector(
|
|
selectShowMatureContent,
|
|
makeSelectPendingClaimForUri(uriFromQuery),
|
|
makeSelectClaimForUri(uriFromQuery),
|
|
makeSelectClaimForUri(channelUriFromQuery),
|
|
(matureEnabled, pendingClaim, claim1, claim2) => {
|
|
const claim1Mature = claim1 && isClaimNsfw(claim1);
|
|
const claim2Mature = claim2 && isClaimNsfw(claim2);
|
|
let pendingAmount = pendingClaim && pendingClaim.amount;
|
|
|
|
if (!claim1 && !claim2) {
|
|
return undefined;
|
|
} else if (!claim1 && claim2) {
|
|
return matureEnabled ? claim2.canonical_url : claim2Mature ? undefined : claim2.canonical_url;
|
|
} else if (claim1 && !claim2) {
|
|
return matureEnabled
|
|
? claim1.repost_url || claim1.canonical_url
|
|
: claim1Mature
|
|
? undefined
|
|
: claim1.repost_url || claim1.canonical_url;
|
|
}
|
|
|
|
const effectiveAmount1 = claim1 && (claim1.repost_bid_amount || claim1.meta.effective_amount);
|
|
// claim2 will never have a repost_bid_amount because reposts never start with "@"
|
|
const effectiveAmount2 = claim2 && claim2.meta.effective_amount;
|
|
|
|
if (!matureEnabled) {
|
|
if (claim1Mature && !claim2Mature) {
|
|
return claim2.canonical_url;
|
|
} else if (claim2Mature && !claim1Mature) {
|
|
return claim1.repost_url || claim1.canonical_url;
|
|
} else if (claim1Mature && claim2Mature) {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
const returnBeforePending =
|
|
Number(effectiveAmount1) > Number(effectiveAmount2)
|
|
? claim1.repost_url || claim1.canonical_url
|
|
: claim2.canonical_url;
|
|
if (pendingAmount && pendingAmount > effectiveAmount1 && pendingAmount > effectiveAmount2) {
|
|
return pendingAmount.permanent_url;
|
|
} else {
|
|
return returnBeforePending;
|
|
}
|
|
}
|
|
);
|
|
};
|
|
|
|
export const selectIsResolvingWinningUri = (state: State, query: string = '') => {
|
|
const uriFromQuery = `lbry://${query}`;
|
|
let channelUriFromQuery;
|
|
try {
|
|
const { isChannel } = parseURI(uriFromQuery);
|
|
if (!isChannel) {
|
|
channelUriFromQuery = `lbry://@${query}`;
|
|
}
|
|
} catch (e) {}
|
|
|
|
const claim1IsResolving = selectIsUriResolving(state, uriFromQuery);
|
|
const claim2IsResolving = channelUriFromQuery ? selectIsUriResolving(state, channelUriFromQuery) : false;
|
|
return claim1IsResolving || claim2IsResolving;
|
|
};
|
|
|
|
export const makeSelectUrlForClaimId = (claimId: string) =>
|
|
createSelector(makeSelectClaimForClaimId(claimId), (claim) =>
|
|
claim ? claim.canonical_url || claim.permanent_url : null
|
|
);
|