/** * Created by km on 10/14/16. */ import * as validation from "@jet/environment/json/validation"; import * as models from "../../api/models"; import * as serverData from "../../foundation/json-parsing/server-data"; import * as mediaAttributes from "../../foundation/media/attributes"; import * as mediaDataStructure from "../../foundation/media/data-structure"; import * as mediaRelationship from "../../foundation/media/relationships"; import { Path, Protocol, Parameters } from "../../foundation/network/url-constants"; import * as urls from "../../foundation/network/urls"; import * as objects from "../../foundation/util/objects"; import * as contentAttributes from "../content/attributes"; import * as sad from "../content/sad"; import * as metricsHelpersLocation from "../metrics/helpers/location"; import * as metricsHelpersImpressions from "../metrics/helpers/impressions"; import * as pagination from "../builders/pagination"; import * as artworkBuilder from "../content/artwork/artwork"; import { supportedAppPlatformsFromData } from "../content/content"; import * as flowPreview from "../content/flow-preview"; import * as productPageUtil from "./product-page-util"; import { createProductReviewActions } from "./shelves/shelf-based-reviews-shelves"; import { flowPreviewActionsConfigurationForReviewSummaryFromData } from "../content/flow-preview"; import { isNothing, isSome, unwrapOptional } from "@jet/environment"; import { createEditorsChoiceModel } from "./shelves/editors-choice-shelf"; import { subtitleFromData } from "../lockups/lockups"; import { makeProductPageIntent } from "../../api/intents/product-page-intent"; import { getPlatform } from "../preview-platform"; import { getLocale } from "../locale"; import { actionFor } from "../../foundation/runtime/action-provider"; import { makeSeeAllPageIntent } from "../../api/intents/see-all-page-intent"; import { makeSeeAllPageURL } from "./intent-controller-routing"; import * as metricsHelpersClicks from "../metrics/helpers/clicks"; const displayableKindMobileSoftware = "11"; const displayableKindMobileSoftwareBundle = "43"; const displayableKindMacSoftware = "30"; const displayableKindMacSoftwareBundle = "44"; // Mapping of displayable-kind from software to bundle const softwareToBundleKindMap = { [displayableKindMobileSoftware]: displayableKindMobileSoftwareBundle, [displayableKindMacSoftware]: displayableKindMacSoftwareBundle, }; /// The default sort id to use, `Most Helpful`. const sortIdDefault = "helpful"; /** * Token used to construct a personalized reviews shelf. */ export class PersonalizedReviewsShelfToken { } /** * Creates a pagination url for reviews pagination. */ export function reviewsPaginationUrl(objectGraph, adamId, sort) { return `${Protocol.internal}:/${Path.reviews}/${Path.shelf}/${adamId}/${sort}`; } /** * Creates a page url for changing sorting order. */ export function sortedReviewsPageUrl(objectGraph, adamId, sort, sortedReviewsPageToken = null) { let url = `${Protocol.internal}:/${Path.reviews}/${adamId}/${sort}`; if (serverData.isDefinedNonNullNonEmpty(sortedReviewsPageToken)) { url = `${url}/?${Parameters.token}=${encodeURIComponent(JSON.stringify(sortedReviewsPageToken))}`; } return url; } /** * Creates a pagination token. */ export function createReviewsPageToken(objectGraph, adamId, nextHref, sort, remainingContent) { return { url: reviewsPaginationUrl(objectGraph, adamId, sort), remainingContent: remainingContent, nextHref: nextHref, profile: "lockup", maxPerPage: pagination.suggestedMaxPerPage, highestOrdinal: 0, metricsPageInformation: null, metricsLocationTracker: null, }; } /** * Whether reviews should be suppressed for the given app. * * @param {Data} data MAPI data blob describing the app. * @returns {boolean} Returns `true` if reviews are restricted. */ export function shouldSuppressReviews(objectGraph, data) { if (!serverData.isDefinedNonNull(data)) { return false; } // If we have a system deletable and force-1st-party-app-review bag key const allowsReviewsForSADApp = shouldAllowReviewsForSADApp(objectGraph, data); if (allowsReviewsForSADApp) { return false; } // Suppress reviews for first party apps const isFirstPartyHideableApp = mediaAttributes.attributeAsBooleanOrFalse(data, "isFirstPartyHideableApp"); if (isFirstPartyHideableApp) { return true; } // Suppress reviews for restricted apps const areReviewsRestricted = mediaAttributes.attributeAsBooleanOrFalse(data, "reviewsRestricted"); if (areReviewsRestricted) { return true; } return false; } /** * Whether reviews should be allowed for the given app if it is a 1st party/SAD app. * * @param {Data} data MAPI data blob describing the app. * @returns {boolean} Returns `true` if the app is a 1st party/SAD app and current bag has reviews enabled for 1st party/SAD apps. */ export function shouldAllowReviewsForSADApp(objectGraph, data) { if (serverData.isNullOrEmpty(data) || !objectGraph.bag.enableSystemAppReviews) { return false; } // Since the bag for watch and iOS have different apps included as SAD apps, it is possible that we are not showing reviews for the following scenario: // The watch is direct linked to an app that is iOS only. This means that is should be SAD app from the iOS side, but is not a SAD app on the watch bag, // so it doesn't qualify for reviews. However, these apps have the metadata tag of "isFirstPartyHideableApp" that we will use to circumvent this limitation. // For watch, we will see if an app is a SAD app for the watch, or if an app is technically a SAD app for the iPhone we are viewing on the watch. if (objectGraph.client.isWatch) { return (sad.systemApps(objectGraph).isSystemAppFromData(data) || mediaAttributes.attributeAsBooleanOrFalse(data, "isFirstPartyHideableApp")); } else { return sad.systemApps(objectGraph).isSystemAppFromData(data); } } /** * Whether reviews should be allowed for a given app, this returns true for all standard apps, for system apps * this will return true only if system app reviews are enabled. For non-shelf-based product pages we return `true` because * this logic is handled natively. * * @param objectGraph * @param {Data} data MAPI data blob describing the app. * @returns {boolean} Returns `true` if the app is not a system app or if system app reviews are allowed. */ export function isAppReviewable(objectGraph, data) { if (productPageUtil.isShelfBased(objectGraph)) { return true; } if (serverData.isNullOrEmpty(data)) { return false; } let isSystemApp; // Since the bag for watch and iOS have different apps included as SAD apps, it is possible that we are not showing reviews for the following scenario: // The watch is direct linked to an app that is iOS only. This means that is should be SAD app from the iOS side, but is not a SAD app on the watch bag, // so it doesn't qualify for reviews. However, these apps have the metadata tag of "isFirstPartyHideableApp" that we will use to circumvent this limitation. // For watch, we will see if an app is a SAD app for the watch, or if an app is technically a SAD app for the iPhone we are viewing on the watch. if (objectGraph.client.isWatch) { isSystemApp = sad.systemApps(objectGraph).isSystemAppFromData(data) || mediaAttributes.attributeAsBooleanOrFalse(data, "isFirstPartyHideableApp"); } else { isSystemApp = sad.systemApps(objectGraph).isSystemAppFromData(data); } return !isSystemApp || objectGraph.bag.enableSystemAppReviews; } /** * Generates a tap-to-rate endpoint URL for the given parameter. * @param adamId The adam id of the product to get review data for. * @param isBundle Whether this is a bundle adamId * @returns A URL for posting tap-to-rate data. */ function assembleUserRateURL(objectGraph, adamId, isBundle) { return createBaseURL(objectGraph.bag.userRateURL, isBundle).param("id", adamId).build(); } /** * Generates a writeUserReview endpoint URL for the given parameter. * @param adamId The adam id of the product to be reviewed. * @param isBundle Whether this is a bundle adamId * @returns A URL for posting write review data. */ function assembleWriteReviewURL(objectGraph, adamId, isBundle) { return createBaseURL(objectGraph.bag.writeReviewURL, isBundle).param("id", adamId).build(); } function createBaseURL(baseURL, isBundle) { const url = urls.URL.from(baseURL); const displayableKind = serverData.asString(url.query, "displayable-kind"); // Convert displayable-kind for bundles if (isBundle && (displayableKind === null || displayableKind === void 0 ? void 0 : displayableKind.length) > 0) { url.query["displayable-kind"] = softwareToBundleKindMap[displayableKind] || displayableKind; } return url; } /** * Create and return a rate action for a given adamId. * @param adamId The adamId of the app to rate * @param isBundle Whether this is a bundle adamId * @param appName the name of the app * @returns A new rate action. */ export function userRateAction(objectGraph, adamId, isBundle, appName = null) { const successToast = new models.AlertAction("toast"); successToast.title = objectGraph.loc.string("TOAST_TAP_TO_RATE_TITLE"); successToast.message = objectGraph.loc.string("TOAST_TAP_TO_RATE_DESCRIPTION"); successToast.artwork = artworkBuilder.createArtworkForResource(objectGraph, "resource://ToastStar.png", 95.0, 90.0); const action = new models.RateAction(assembleUserRateURL(objectGraph, adamId, isBundle)); action.adamId = adamId; action.method = "POST"; action.isStoreRequest = true; action.disableCache = true; action.successAction = successToast; const ratingParameter = new models.HttpTemplateParameter("rating", "urlQuery", "decimalPad"); const appVersionParameter = new models.HttpTemplateParameter("version-to-review", "urlQuery", "decimalPad"); action.parameters = [ratingParameter, appVersionParameter]; if (objectGraph.client.isTV && (appName === null || appName === void 0 ? void 0 : appName.length) > 0) { action.title = objectGraph.loc.string("TV_RATE_PRODUCT").replace("{title}", appName); } return action; } export function createProductReviewsList(objectGraph, ratingsData, reviewItems, includeMoreAction = false, editorsChoiceReview, reviewSummary, shelfMetrics) { return validation.context("createProductReviewsList", () => { const productReviews = []; if (serverData.isDefinedNonNullNonEmpty(reviewSummary) && objectGraph.client.isiOS) { if (isSome(shelfMetrics)) { const shelfMetricsOptions = { id: `${shelfMetrics.getSequenceId()}`, kind: null, softwareType: null, targetType: "reviewSummary", title: objectGraph.loc.string("ProductPage.ReviewSummary.Body.Title"), pageInformation: shelfMetrics.metricsPageInformation, locationTracker: shelfMetrics.locationTracker, idType: "sequential", }; metricsHelpersLocation.pushContentLocation(objectGraph, { pageInformation: shelfMetrics.metricsPageInformation, locationTracker: shelfMetrics.locationTracker, targetType: "reviewSummary", id: `${shelfMetrics.getSequenceId()}`, idType: "sequential", }, objectGraph.loc.string("ProductPage.ReviewSummary.Body.Title")); metricsHelpersImpressions.addImpressionFields(objectGraph, reviewSummary, shelfMetricsOptions); metricsHelpersLocation.popLocation(shelfMetrics.locationTracker); metricsHelpersLocation.nextPosition(shelfMetrics.locationTracker); } productReviews.push(reviewSummary); } if (serverData.isDefinedNonNullNonEmpty(editorsChoiceReview) && !objectGraph.client.isiOS) { productReviews.push(editorsChoiceReview); } if (serverData.isDefinedNonNull(reviewItems) && reviewItems.length > 0) { const isWatchStore = objectGraph.client.isWatch; // We omit the actions on watchOS, they aren't used. const includeActions = !isWatchStore; // We limit the number of reviews to one on watchOS. That's all // we can display inline, and we want to reduce memory usage. const fixedUpReviewItems = isWatchStore ? reviewItems.slice(0, 1) : reviewItems; const includeFeedbackActions = objectGraph.client.isVision; const userReviews = createReviewItems(objectGraph, objectGraph.client.guid, ratingsData, fixedUpReviewItems, includeMoreAction && includeActions, includeFeedbackActions && includeActions, includeActions, shelfMetrics); if (isSome(shelfMetrics)) { metricsHelpersLocation.pushContentLocation(objectGraph, { pageInformation: shelfMetrics.metricsPageInformation, locationTracker: shelfMetrics.locationTracker, targetType: "mostHelpfulReviews", id: `${shelfMetrics.getSequenceId()}`, idType: "sequential", }, null); } userReviews.forEach((userReview, index) => { const productReview = new models.ProductReview(); productReview.sourceType = "user"; productReview.review = userReview; productReviews.push(productReview); if (isSome(shelfMetrics)) { const options = { id: productReview.id, idType: "its_id", kind: null, softwareType: null, title: null, pageInformation: shelfMetrics.metricsPageInformation, locationTracker: shelfMetrics.locationTracker, targetType: "helpfulReview", }; metricsHelpersImpressions.addImpressionFields(objectGraph, productReview, options); metricsHelpersLocation.nextPosition(shelfMetrics.locationTracker); } }); if (isSome(shelfMetrics)) { metricsHelpersLocation.popLocation(shelfMetrics.locationTracker); metricsHelpersLocation.nextPosition(shelfMetrics.locationTracker); } } return productReviews; }); } export function createEditorsChoiceReview(objectGraph, data) { return validation.context("editorsChoiceItem", () => { if (objectGraph.client.isiOS) { // On iOS, Editors' Choice is a separate shelf to reviews. On all other platforms, editors choice is embedded in the reviews list. return null; } const editorsChoice = createEditorsChoiceModel(objectGraph, data); if (isSome(editorsChoice)) { const productReview = new models.ProductReview(); productReview.sourceType = "editorsChoice"; productReview.review = editorsChoice; if (objectGraph.client.isVision) { productReview.review.clickAction = createEditorsChoiceDetailActionForEditorsChoice(objectGraph, productReview.review); } return productReview; } return null; }); } /** * Creates a flow action for navigating to the expanded view of an editors choice item. * * @param objectGraph The current object graph * @param editorsChoice The editors choice item * @returns A flow action */ function createEditorsChoiceDetailActionForEditorsChoice(objectGraph, editorsChoice) { const flowAction = new models.FlowAction("editorsChoiceDetail"); flowAction.pageData = objects.shallowCopyOf(editorsChoice); return flowAction; } /** * Converts an array of user review rows into an array of review objects. * @param deviceId The UUID for the user's device. * @param rows User review rows from an API response. * @returns An array of zero or more review objects. */ export function createReviewItems(objectGraph, deviceId, ratingsData, reviewItems, includeMoreAction = false, includeFeedbackActions = false, includeFlowPreviewActionsConfiguration = true, shelfMetrics) { return validation.context("createReviewItems", () => { const reviewDateString = function (date, isEdited) { if (isEdited) { const now = new Date(); const withinAnHour = (now.getTime() - date.getTime()) * 1000 < 60 * 60; if (withinAnHour) { return objectGraph.loc.string("TimeAgo.Edited.JustNow"); } else { return objectGraph.loc .string("TimeAgo.Edited.Time") .replace("{time}", objectGraph.loc.timeAgoWithContext(date, "standalone")); } } else { return objectGraph.loc.timeAgoWithContext(date, "standalone"); } }; return reviewItems.map((item) => { var _a; const review = new models.Review(); review.id = serverData.asString(item, "id", "coercible"); review.title = mediaAttributes.attributeAsString(item, "title"); const rawDate = mediaAttributes.attributeAsString(item, "date"); if (rawDate) { review.date = new Date(rawDate); review.dateText = reviewDateString(review.date, mediaAttributes.attributeAsBooleanOrFalse(item, "isEdited")); } review.contents = mediaAttributes.attributeAsString(item, "review"); review.rating = mediaAttributes.attributeAsNumber(item, "rating"); review.reviewerName = mediaAttributes.attributeAsString(item, "userName"); review.dateAuthorText = objectGraph.loc .string("ProductPage.Section.Reviews.DateAuthor") .replace("{date}", review.dateText) .replace("{author}", review.reviewerName); let responseToReviewText = null; const responseId = mediaAttributes.attributeAsString(item, "developerResponse.id"); if ((responseId === null || responseId === void 0 ? void 0 : responseId.length) > 0) { const response = new models.Response(); response.id = responseId; response.contents = mediaAttributes.attributeAsString(item, "developerResponse.body"); const rawResponseDate = mediaAttributes.attributeAsString(item, "developerResponse.modified"); if (rawResponseDate) { response.date = new Date(rawResponseDate); response.dateText = reviewDateString(response.date, false); } review.response = response; responseToReviewText = review.response.contents; } if (includeFlowPreviewActionsConfiguration) { const adamId = serverData.asString(ratingsData, "adamId"); review.flowPreviewActionsConfiguration = flowPreview.flowPreviewActionsConfigurationForReviewFromData(objectGraph, item, deviceId, adamId, responseToReviewText); } if (includeFeedbackActions) { const voteActions = [ flowPreview.voteActionFromData(objectGraph, item, deviceId, true), flowPreview.voteActionFromData(objectGraph, item, deviceId, false), ]; if (((_a = objectGraph.bag.reportConcernUrl) === null || _a === void 0 ? void 0 : _a.length) > 0) { voteActions.push(flowPreview.reportConcernActionFromData(objectGraph, item, deviceId)); } review.voteActions = voteActions; } if (includeMoreAction) { if (objectGraph.client.isVision) { review.moreAction = createReviewDetailActionForReview(objectGraph, review); // Clear out the voteActions, as we only need them in the copy of the review // attached to the `moreAction`. review.voteActions = null; } else { review.moreAction = singleReviewPageActionFromReviewItem(objectGraph, deviceId, ratingsData, item, shelfMetrics); } } return review; }); }); } /** * Create a ratings object from the responses of the requests to get product reviews. * @param deviceId The UUID for the user's device. * @param ratingsData The basic review data response. * @returns A customer reviews object. */ export function ratingsFromApiResponses(objectGraph, deviceId, context, ratingsData) { if (!ratingsData) { return null; } return validation.context("ratingsFromApiResponses", () => { const ratings = new models.Ratings(); ratings.productId = serverData.asString(ratingsData, "adamId", "coercible"); ratings.ratingAverage = serverData.asNumber(ratingsData, "ratingAverage"); ratings.totalNumberOfRatings = serverData.asNumber(ratingsData, "ratingCount"); ratings.totalNumberOfReviews = serverData.asNumber(ratingsData, "totalNumberOfReviews"); ratings.context = context; // Translate rating counts for each star level to represent the number of ratings out of `totalNumberOfRatings`. const absoluteRatingsCounts = serverData .asArrayOrEmpty(ratingsData, "ratingCountList") .slice() .reverse(); const totalAbsoluteRatingsCounts = absoluteRatingsCounts.reduce((a, b) => a + b, 0); if (totalAbsoluteRatingsCounts > 0) { ratings.ratingCounts = absoluteRatingsCounts.map((absoluteCount) => (absoluteCount / totalAbsoluteRatingsCounts) * ratings.totalNumberOfRatings); } else { ratings.ratingCounts = absoluteRatingsCounts; } const hasRatings = ratings.ratingAverage > 0 && ratings.ratingCounts; if (!hasRatings) { const wasReset = serverData.asBooleanOrFalse(ratingsData, "wasReset"); ratings.status = wasReset ? objectGraph.loc.string("RATINGS_STATUS_DEVELOPER_RESET") : objectGraph.loc.string("RATINGS_STATUS_NOT_ENOUGH_RATINGS"); } return ratings; }); } export function createReviewSummaryProductReview(objectGraph, data, shelfMetrics) { return validation.context("createReviewSummaryProductReview", () => { const reviewSummary = reviewSummaryFromData(objectGraph, data, shelfMetrics); if (isNothing(reviewSummary)) { return null; } const reviewSummaryProductReview = new models.ProductReview(); reviewSummaryProductReview.review = reviewSummary; reviewSummaryProductReview.sourceType = "reviewSummary"; return reviewSummaryProductReview; }); } /** * Create a review summary object from the product page data * @param data Product page data * @param objectGraph Current object graph * @returns The review summary object */ export function reviewSummaryFromData(objectGraph, data, shelfMetrics) { if (!isReviewSummaryEnabled(objectGraph)) { return null; } const reviewSummaryRelationship = mediaRelationship.relationship(data, "review-summary"); if (isNothing(reviewSummaryRelationship)) { return null; } const reviewSummaryDataArray = reviewSummaryRelationship.data; if (serverData.isNullOrEmpty(reviewSummaryDataArray)) { return null; } const reviewSummaryData = reviewSummaryDataArray[0]; const reviewSummaryId = serverData.asString(reviewSummaryData, "id", "coercible"); const reviewSummaryBodyWithTitle = createReviewSummaryBody(objectGraph, reviewSummaryData, true); const reviewSummaryBodyNoTitle = createReviewSummaryBody(objectGraph, reviewSummaryData, false); const subtitle = objectGraph.loc.string("ProductPage.ReviewSummary.Subtitle"); const reviewSummaryReportConcernData = objectGraph.bag.reviewSummaryReportConcernData; if (isNothing(reviewSummaryBodyNoTitle) || isNothing(reviewSummaryBodyWithTitle)) { return null; } const appName = mediaAttributes.attributeAsString(data, "name"); const reviewSummaryText = contentAttributes.contentAttributeAsString(objectGraph, reviewSummaryData, "text"); const reviewSummaryArtwork = artworkBuilder.createArtworkForResource(objectGraph, "systemimage://text.line.2.summary"); const reviewSummary = new models.ReviewSummary(reviewSummaryBodyWithTitle, reviewSummaryBodyNoTitle, subtitle, reviewSummaryArtwork, "leading", "text/markdown", flowPreviewActionsConfigurationForReviewSummaryFromData(objectGraph, reviewSummaryReportConcernData, data.id, appName, reviewSummaryId, reviewSummaryText, objectGraph.client.guid)); // Add metrics before serializing token for url const shelfMetricsOptions = { id: `${shelfMetrics.getSequenceId()}`, kind: null, softwareType: null, targetType: "reviewSummary", title: objectGraph.loc.string("ProductPage.ReviewSummary.Body.Title"), pageInformation: shelfMetrics.metricsPageInformation, locationTracker: shelfMetrics.locationTracker, idType: "sequential", }; metricsHelpersLocation.pushContentLocation(objectGraph, { pageInformation: shelfMetrics.metricsPageInformation, locationTracker: shelfMetrics.locationTracker, targetType: "reviewSummary", id: `${shelfMetrics.getSequenceId()}`, idType: "sequential", }, reviewSummary.title); metricsHelpersImpressions.addImpressionFields(objectGraph, reviewSummary, shelfMetricsOptions); metricsHelpersLocation.popLocation(shelfMetrics.locationTracker); metricsHelpersLocation.nextPosition(shelfMetrics.locationTracker); return reviewSummary; } /** * Checks if the review summary module should be enabled * @param objectGraph * @returns true if the feature flag and bag key are enabled and the platform is iOS */ export function isReviewSummaryEnabled(objectGraph) { return objectGraph.client.isiOS && objectGraph.bag.enableReviewSummarization; } /** * Builds the review summary text using the review summary text * @param objectGraph The current object graph * @param reviewSummaryData The review summary content attributes * @returns The built review summary body text */ function createReviewSummaryBody(objectGraph, reviewSummaryData, withTitle) { const reviewSummaryText = contentAttributes.contentAttributeAsString(objectGraph, reviewSummaryData, "text"); if (isNothing(reviewSummaryText)) { return null; } const reviewSummaryTitle = objectGraph.loc.string("ProductPage.ReviewSummary.Body.Title"); const reviewSummaryTitleStyled = `^[${reviewSummaryTitle}](jetFont: 'reviewSummaryTitle')`; const reviewSummaryTextStyled = `^[${reviewSummaryText}](jetFont: 'reviewSummaryText')`; if (!withTitle) { return reviewSummaryTextStyled; } const reviewSummaryBody = objectGraph.loc .string("ProductPage.ReviewSummary.Body") .replace("{styledTitle}", reviewSummaryTitleStyled) .replace("{reviewSummary}", reviewSummaryTextStyled); return reviewSummaryBody; } export function starRatingsFromRatings(ratings) { if (!ratings) { return null; } const starRatings = new models.ProductStarRatings(); copyRatingsInfoIntoOther(ratings, starRatings); return starRatings; } export function starRatingsHistogramFromRatings(ratings) { if (!ratings) { return null; } const starRatings = new models.ProductStarRatingsHistogram(); copyRatingsInfoIntoOther(ratings, starRatings); return starRatings; } export function noRatingsFromRatings(ratings) { if (!ratings) { return null; } const noRatings = new models.ProductNoRatings(); copyRatingsInfoIntoOther(ratings, noRatings); return noRatings; } function copyRatingsInfoIntoOther(original, other) { other.ratingAverage = original.ratingAverage; other.ratingCounts = original.ratingCounts; other.totalNumberOfRatings = original.totalNumberOfRatings; other.totalNumberOfReviews = original.totalNumberOfReviews; other.status = original.status; other.reviews = original.reviews; other.actions = original.actions; other.nextPage = original.nextPage; } /** * Create a tap to rate model from the lookup response * @param adamId The adamId of the app. * @param appName The name of the app. * @param isBundle Whether this is a bundle adamId * @param rating The current rating * @returns A single tap to rate model object */ export function tapToRateWithAdamId(objectGraph, adamId, isBundle, appName = null, rating = null) { const tapToRate = new models.TapToRate(); switch (objectGraph.client.deviceType) { case "tv": tapToRate.title = objectGraph.loc.string("TV_SELECT_TO_RATE"); break; case "mac": tapToRate.title = objectGraph.loc.string("CLICK_TO_RATE"); break; default: tapToRate.title = objectGraph.client.isiOS ? objectGraph.loc.string("TAP_TO_RATE") : objectGraph.loc.string("TAP_TO_RATE_LEGACY"); break; } tapToRate.rating = rating; tapToRate.rateAction = userRateAction(objectGraph, adamId, isBundle, appName); return tapToRate; } /** * Creates a platform-specific action to write a review. * @param adamId The app's identifier. * @param isBundle Whether this is a bundle adamId * @param appIcon The app's icon. * @param isRated Whether the app has an existing rating * @returns Action to show the write a review sheet. */ export function createWriteReviewAction(objectGraph, adamId, isBundle, appIcon, isRated = false, lockupSubtitle, lockupTitle) { return validation.context("createWriteReviewAction", () => { const title = isRated ? objectGraph.loc.string("EDIT_REVIEW") : objectGraph.loc.string("WRITE_A_REVIEW"); const writeReviewUrl = assembleWriteReviewURL(objectGraph, adamId, isBundle); let appIconWithCrop; if (isSome(appIcon)) { // The artwork template is ultimately passed to AMS, and doesn't go through our pipeline, // so we need to ensure the crop code and file type is already populated. const variant = artworkBuilder.createArtworkVariantForClient(objectGraph, true, false, 1 /* ArtworkUseCase.LockupIconSmall */); const template = appIcon.template .replace("{c}", `${appIcon.crop}-${variant.quality}`) .replace("{f}", variant.format); appIconWithCrop = new models.Artwork(template, appIcon.width, appIcon.height, appIcon.variants); appIconWithCrop.backgroundColor = appIcon.backgroundColor; appIconWithCrop.textColor = appIcon.textColor; appIconWithCrop.checksum = appIcon.checksum; appIconWithCrop.style = appIcon.style; appIconWithCrop.crop = appIcon.crop; appIconWithCrop.contentMode = appIcon.contentMode; appIconWithCrop.imageScale = appIcon.imageScale; } let writeReviewAction; switch (objectGraph.client.deviceType) { case "mac": { const action = new models.WriteReviewAction(adamId, writeReviewUrl); action.title = title; action.appIcon = appIconWithCrop; action.itemDescription = lockupSubtitle; action.appName = lockupTitle; writeReviewAction = action; break; } default: { if (objectGraph.featureFlags.isEnabled("review_composer_redesign")) { const action = new models.WriteReviewAction(adamId, writeReviewUrl); action.title = title; action.appName = lockupTitle; action.itemDescription = lockupSubtitle; action.appIcon = appIconWithCrop; action.artwork = artworkBuilder.createArtworkForResource(objectGraph, "systemimage://square.and.pencil"); writeReviewAction = action; break; } else { const action = new models.FlowAction("writeReview"); action.title = title; action.pageUrl = writeReviewUrl; action.pageData = adamId; action.presentationContext = "presentModal"; action.artwork = artworkBuilder.createArtworkForResource(objectGraph, "systemimage://square.and.pencil"); writeReviewAction = action; break; } } } return writeReviewAction; }); } /** * Creates and returns an action for writing a product review * @param data The response data to read from. * @returns An action or null */ export function writeReviewActionFromData(objectGraph, data, lockupSubtitle, lockupTitle, appIcon) { return validation.context(`writeReviewActionFromData`, () => { const adamId = data.id; if ((adamId === null || adamId === void 0 ? void 0 : adamId.length) > 0) { const isBundle = data.type === "app-bundles"; const rating = mediaAttributes.attributeAsNumber(data, "rating"); const isRated = serverData.isDefinedNonNull(rating) && rating > 0; return createWriteReviewAction(objectGraph, adamId, isBundle, appIcon, isRated, lockupSubtitle, lockupTitle); } return null; }); } /** * Creates and returns an action for navigating to a product's ratings & reviews * @param clickAction The flow action for viewing the product * @returns An action or null */ export function seeRatingsAndReviewsActionFromClickAction(objectGraph, adamId, clickAction) { return validation.context(`seeRatingsAndReviewsActionFromData`, () => { const flowAction = objects.shallowCopyOf(clickAction); if (clickAction.pageData instanceof models.ProductPage || clickAction.pageData instanceof models.ShelfBasedProductPage) { let shelfBasedPageScrollAction; if (objectGraph.client.isVision) { shelfBasedPageScrollAction = new models.ShelfBasedPageScrollAction("productRatings", "notPurchasedRatingsAndReviews", "purchasedRatingsAndReviews", adamId); } else { shelfBasedPageScrollAction = new models.ShelfBasedPageScrollAction("productRatings"); } const scrollAction = productPageUtil.isShelfBased(objectGraph) ? shelfBasedPageScrollAction : new models.ProductPageScrollAction(new models.ProductPageSection("shelf", "reviews")); const productPageData = objects.shallowCopyOf(clickAction.pageData); productPageData.fullProductFetchedAction = scrollAction; flowAction.pageData = productPageData; } return flowAction; }); } /** * Create a custom shelf for the reviews module * @param deviceId The UUID for the user's device. * @param reviewData The basic review data response. * @param rows The review row data response. * @returns A custom shelf with one ratings module */ export function reviewsShelfForReviewsData(objectGraph, deviceId, ratingsData, reviewItems, includeMoreAction = false, includeFeedbackActions = false, includeFlowPreviewActionsConfiguration = true, shelfMetrics) { return validation.context("reviewsShelfForReviewsData", () => { if (objectGraph.client.isiOS) { const shelf = new models.Shelf("productReview"); shelfMetrics === null || shelfMetrics === void 0 ? void 0 : shelfMetrics.addImpressionsToShelf(objectGraph, shelf, "ratingsDetails"); shelf.items = createProductReviewsList(objectGraph, ratingsData, reviewItems, includeMoreAction, null, null, shelfMetrics); return shelf; } else { const shelf = new models.Shelf("reviews"); shelfMetrics === null || shelfMetrics === void 0 ? void 0 : shelfMetrics.addImpressionsToShelf(objectGraph, shelf, "ratingsDetails"); shelf.items = createReviewItems(objectGraph, deviceId, ratingsData, reviewItems, includeMoreAction, includeFeedbackActions, includeFlowPreviewActionsConfiguration, shelfMetrics); return shelf; } }); } /** * Create a reviews container shelf. Optionally pass in reviews to horizontally scroll them. */ export function reviewsContainerShelfForReviewsData(objectGraph, deviceId, ratingsData, reviewItems, appName, productData, appIcon, editorsChoice, shelfMetrics, nextPageHref, includeSeeAllAction = false, shouldIncludePersonalizationUrl = true, rating = null, tvOnlyApp = false) { return validation.context("reviewsContainerShelfForReviewsData", () => { const reviewsContainer = reviewsContainerForReviewsData(objectGraph, deviceId, ratingsData, reviewItems, productData, appName, appIcon, editorsChoice, shelfMetrics, rating, tvOnlyApp); const shelf = new models.Shelf("reviewsContainer"); shelf.title = sectionTitleForPlatform(objectGraph); shelf.items = [reviewsContainer]; shelfMetrics === null || shelfMetrics === void 0 ? void 0 : shelfMetrics.addImpressionsToShelf(objectGraph, shelf, "ratingsDetails"); if (includeSeeAllAction && serverData.isDefinedNonNull(reviewsContainer.reviews) && reviewsContainer.reviews.length > 0) { shelf.seeAllAction = reviewsPageActionFromReviewsData(objectGraph, deviceId, ratingsData, reviewItems, nextPageHref, appName, appIcon, false, false); } return shelf; }); } /** * Create a personalized reviews container shelf */ export function personalizedReviewsContainerShelf(objectGraph, deviceId, token, dataContainer) { return validation.context("personalizedReviewsContainerShelf", () => { const data = mediaDataStructure.dataFromDataContainer(objectGraph, dataContainer); const rating = mediaAttributes.attributeAsNumber(data, "rating"); return reviewsContainerShelfForReviewsData(objectGraph, deviceId, token.ratingsData, token.reviewItems, token.appName, data, token.appIcon, token.editorsChoice, token.shelfMetrics, token.nextPageHref, token.includeSeeAllAction, false, rating); }); } /** * Create a reviews container */ export function reviewsContainerForReviewsData(objectGraph, deviceId, ratingsData, reviewItems, productData, appName, appIcon, editorsChoice, shelfMetrics, rating = null, tvOnlyApp = false) { return validation.context("reviewsContainerForReviewsData", () => { const reviewsContainer = new models.ReviewsContainer(); const adamId = serverData.asString(ratingsData, "adamId"); const isBundle = serverData.asBooleanOrFalse(ratingsData, "isBundle"); reviewsContainer.adamId = adamId; // Ratings reviewsContainer.ratings = ratingsFromApiResponses(objectGraph, deviceId, "details", ratingsData); if (objectGraph.client.isiOS) { const reviewSummary = reviewSummaryFromData(objectGraph, productData, shelfMetrics); if (serverData.isDefinedNonNull(reviewSummary)) { reviewsContainer.reviewSummary = reviewSummary; } } // Tap to Rate if (!tvOnlyApp || objectGraph.client.isTV) { reviewsContainer.tapToRate = tapToRateWithAdamId(objectGraph, adamId, isBundle, appName, rating); } if (objectGraph.client.isWeb) { const productPageIntent = makeProductPageIntent({ ...getLocale(objectGraph), ...getPlatform(objectGraph), id: unwrapOptional(adamId), }); const productAction = actionFor(productPageIntent, objectGraph); productAction.title = appName; reviewsContainer.productAction = productAction; } // Reviews if (serverData.isDefinedNonNull(reviewItems) && reviewItems.length > 0) { const isWatchStore = objectGraph.client.isWatch; // We omit the actions on watchOS, they aren't used. const includeActions = !isWatchStore; // We limit the number of reviews to one on watchOS. That's all // we can display inline, and we want to reduce memory usage. const fixedUpReviewItems = isWatchStore ? reviewItems.slice(0, 1) : reviewItems; reviewsContainer.reviews = createReviewItems(objectGraph, deviceId, ratingsData, fixedUpReviewItems, includeActions, false, includeActions, shelfMetrics); } // No reviews affects ratings message if (serverData.isNull(reviewsContainer.reviews) || reviewsContainer.reviews.length === 0) { const hasRatings = reviewsContainer.ratings.ratingAverage > 0 && reviewsContainer.ratings.ratingCounts; const wasReset = serverData.asBooleanOrFalse(ratingsData, "wasReset"); if (!hasRatings && !wasReset) { reviewsContainer.ratings.status = objectGraph.loc.string("RATINGS_STATUS_NOT_ENOUGH_RATINGS_OR_REVIEWS"); } } // Editors choice if (!serverData.isNull(editorsChoice)) { reviewsContainer.editorsChoice = editorsChoice; } // Write a review action if (!tvOnlyApp || objectGraph.client.isTV) { const isRated = serverData.isDefinedNonNull(rating) && rating > 0; reviewsContainer.writeReviewAction = createWriteReviewAction(objectGraph, adamId, isBundle, appIcon, isRated, subtitleFromData(objectGraph, productData), appName); } // Support action const supportUrl = serverData.asString(ratingsData, "supportUrl"); if (supportUrl) { const supportAction = new models.ExternalUrlAction(supportUrl, false); supportAction.title = objectGraph.loc.string("APP_SUPPORT"); reviewsContainer.supportAction = supportAction; } let alwaysAllowReviews = false; for (const item of reviewItems) { if (shouldAllowReviewsForSADApp(objectGraph, item)) { alwaysAllowReviews = true; break; } } reviewsContainer.alwaysAllowReviews = alwaysAllowReviews; return reviewsContainer; }); } function createSortOption(objectGraph, adamId, sortId, title, sortActionTitle, sortedReviewsPageToken = null) { const url = sortedReviewsPageUrl(objectGraph, adamId, sortId, sortedReviewsPageToken); return new models.ReviewsPageSortOption(sortId, title, sortActionTitle, url); } /** * Create and return the learn more action on the Review Summary context menu * @param objectGraph * @returns a flow action for the learn more article page for review summary generation */ export function reviewSummaryLearnMoreAction(objectGraph) { const editorialItemId = objectGraph.bag.reviewSummarizationLearnMoreEditorialItemId; if (serverData.isNullOrEmpty(editorialItemId)) { return null; } const title = objectGraph.loc.string("Action.LearnMore"); const flowAction = new models.FlowAction("article"); flowAction.title = title; flowAction.pageUrl = `https://apps.apple.com/story/id${editorialItemId}`; flowAction.artwork = artworkBuilder.createArtworkForResource(objectGraph, "systemimage://questionmark.circle"); return flowAction; } /** * Create a reviews page * @param deviceId The UUID for the user's device. * @param ratingsData The basic ratings response. * @param reviewItems Reviews row data. * @param isFirstPage Whether this is the first page. * @param nextPageHref the Media API href for next page. * @param appName The name of the app. * @param appAdamId The adamId of the app. * @param appIcon The app icon to use. * @param sortId The sort identifier for this page. * @param productData Product data for the app * @returns A page object to be presented using a FlowAction */ function reviewsPageForReviewsData(objectGraph, deviceId, ratingsData, reviewItems, isFirstPage, nextPageHref = null, appAdamId = null, appName, appIcon = null, productData, shelfMetrics, includeMoreAction = false, includeFeedbackActions = false, sortId = sortIdDefault, sortedReviewsPageToken = null) { const page = new models.ReviewsPage(); page.shelves = []; return validation.context("reviewsPageForReviewsData", () => { const adamId = serverData.isNull(appAdamId) ? serverData.asString(ratingsData, "adamId") : appAdamId; if (isFirstPage && !serverData.isNull(ratingsData) && !objectGraph.client.isiOS && !objectGraph.client.isVision) { // Reviews container shelf const containerShelf = reviewsContainerShelfForReviewsData(objectGraph, deviceId, ratingsData, [], appName, productData, appIcon, null, shelfMetrics); page.trailingNavBarAction = reviewSummaryLearnMoreAction(objectGraph); page.shelves.push(containerShelf); } // Ratings & review actions if (isFirstPage && objectGraph.client.isVision && serverData.isDefinedNonNull(productData)) { const appPlatforms = supportedAppPlatformsFromData(objectGraph, productData); const tvOnlyApp = appPlatforms.length === 1 && appPlatforms[0] === "tv"; const isBundle = productData.type === "app-bundles"; page.ratings = ratingsFromApiResponses(objectGraph, deviceId, "details", ratingsData); page.productReviewActions = createProductReviewActions(objectGraph, productData, appName, ratingsData, isBundle, tvOnlyApp, appIcon); } const pageComponents = createReviewsPageComponents(objectGraph, deviceId, adamId, ratingsData, reviewItems, nextPageHref, includeMoreAction, includeFeedbackActions, sortId, sortedReviewsPageToken, shelfMetrics); pageComponents.reviewsShelf.presentationHints = { ...pageComponents.reviewsShelf.presentationHints, isSortable: isFirstPage && pageComponents.reviewsShelf.presentationHints.isSortable, }; page.adamId = adamId; page.shelves.push(pageComponents.reviewsShelf); page.nextPage = pageComponents.paginationToken; page.initialSortOptionIdentifier = pageComponents.initialSortId; page.sortActionSheetTitle = pageComponents.sortActionSheetTitle; page.sortOptions = pageComponents.sorts; page.alwaysAllowReviews = shouldAllowReviewsForSADApp(objectGraph, productData); return page; }); } /// THe id used to concat the sort optin if there is one const baseReviewsShelfId = "ReviewsPage.ShelfId"; /// Create the known shelfId for the shelf that includes the reviews export function reviewsShelfIdForSortId(sortOptionId) { if (serverData.isNullOrEmpty(sortOptionId)) { return baseReviewsShelfId; } return `${baseReviewsShelfId}.${sortOptionId}`; } /** * @param objectGraph The dependency graph for the app. * @param deviceId The id used when submitting reviews. * @param ratingsData The ratings data for this app. * @param reviewItems The actual reivews data * @param nextPageHref The url to fetch more reviews. * @param includeMoreAction Whether to include the more action on the reviews shelf. * @param includeFeedbackActions Whether to include the feedback actions on the reviews shelf. * @param sortId The sort identifier for this page. * @returns The components needed to create a reviews page. */ export function createReviewsPageComponents(objectGraph, deviceId, adamId, ratingsData, reviewItems, nextPageHref = null, includeMoreAction = false, includeFeedbackActions = false, sortId = sortIdDefault, sortedReviewsPageToken = null, shelfMetrics) { // Ensure an even number of items are on the page for two column layout paging const shouldTruncateShelfItems = (nextPageHref === null || nextPageHref === void 0 ? void 0 : nextPageHref.length) > 0 && serverData.isDefinedNonNullNonEmpty(reviewItems) && reviewItems.length % 2 > 0; const itemsForReviewsShelf = shouldTruncateShelfItems ? reviewItems.slice(0, reviewItems.length - 1) : reviewItems; const remainingItems = shouldTruncateShelfItems ? reviewItems.slice(reviewItems.length - 1) : []; const reviewsShelf = reviewsShelfForReviewsData(objectGraph, deviceId, ratingsData, itemsForReviewsShelf, includeMoreAction, includeFeedbackActions, true, shelfMetrics); reviewsShelf.id = reviewsShelfIdForSortId(sortId); const pageComponents = { reviewsShelf, }; reviewsShelf.presentationHints = { isSortable: reviewItems.length > 0 || (nextPageHref === null || nextPageHref === void 0 ? void 0 : nextPageHref.length) > 0, isSeeAllContext: true, }; pageComponents.initialSortId = sortId; pageComponents.sortActionSheetTitle = objectGraph.loc.string("REVIEWS_SORT_BY"); pageComponents.sorts = [ createSortOption(objectGraph, adamId, "helpful", objectGraph.loc.string("REVIEWS_MOST_HELPFUL"), objectGraph.loc.string(objectGraph.client.isiOS ? "REVIEWS_MOST_HELPFUL" : "REVIEWS_SORT_BY_MOST_HELPFUL"), sortedReviewsPageToken), createSortOption(objectGraph, adamId, "favorable", objectGraph.loc.string("REVIEWS_MOST_FAVORABLE"), objectGraph.loc.string(objectGraph.client.isiOS ? "REVIEWS_MOST_FAVORABLE" : "REVIEWS_SORT_BY_MOST_FAVORABLE"), sortedReviewsPageToken), createSortOption(objectGraph, adamId, "critical", objectGraph.loc.string("REVIEWS_MOST_CRITICAL"), objectGraph.loc.string(objectGraph.client.isiOS ? "REVIEWS_MOST_CRITICAL" : "REVIEWS_SORT_BY_MOST_CRITICAL"), sortedReviewsPageToken), createSortOption(objectGraph, adamId, "recent", objectGraph.loc.string("REVIEWS_MOST_RECENT"), objectGraph.loc.string(objectGraph.client.isiOS ? "REVIEWS_MOST_RECENT" : "REVIEWS_SORT_BY_MOST_RECENT"), sortedReviewsPageToken), ]; if ((nextPageHref === null || nextPageHref === void 0 ? void 0 : nextPageHref.length) > 0) { pageComponents.paginationToken = createReviewsPageToken(objectGraph, adamId, nextPageHref, sortId, remainingItems); } return pageComponents; } /** * Create a partial reviews page from a reviews media api container */ export function partialReviewsPageForReviewsMediaContainer(objectGraph, deviceId, adamId, prependingItems, data, isFirstPage, sortId, sortedReviewsPageToken, shelfMetrics) { return validation.context("reviewsPageForReviewsMediaContainer", () => { let reviewItems; if (serverData.isDefinedNonNullNonEmpty(data.data)) { reviewItems = prependingItems.concat(data.data); } else { reviewItems = prependingItems; } const includeMoreAction = objectGraph.client.isVision; const includeFeedbackActions = objectGraph.client.isVision; return reviewsPageForReviewsData(objectGraph, deviceId, null, reviewItems, isFirstPage, data.next, adamId, null, null, null, shelfMetrics, includeMoreAction, includeFeedbackActions, sortId, sortedReviewsPageToken); }); } /** * Creates a flow action to display a reviews page. * @param deviceId The UUID for the user's device. * @param ratingsData The ratings data response * @param reviewsData The review row data response. * @param nextPageHref The Media API href for the next page. * @param appName The name of the app * @param appIcon The app's icon. * @param productData The data for the product * @returns A `FlowAction` object pointing to a reviews page. */ export function reviewsPageActionFromReviewsData(objectGraph, deviceId, ratingsData, reviewsData, nextPageHref = null, appName = null, appIcon = null, includeMoreAction, includeFeedbackActions, productData, shelfMetrics) { if (!ratingsData) { return null; } return validation.context("reviewsPageActionFromReviewsData", () => { const pageData = reviewsPageForReviewsData(objectGraph, deviceId, ratingsData, reviewsData, true, nextPageHref, null, appName, appIcon, productData, shelfMetrics, includeMoreAction, includeFeedbackActions); pageData.title = pageTitleForPlatform(objectGraph); const action = new models.FlowAction("reviews"); action.pageData = pageData; action.title = objectGraph.loc.string("ACTION_SEE_ALL"); if (objectGraph.client.isWeb) { const destination = makeSeeAllPageIntent({ ...getLocale(objectGraph), ...getPlatform(objectGraph), "id": pageData.adamId, "see-all": "reviews", }); action.destination = destination; action.pageUrl = makeSeeAllPageURL(objectGraph, destination); const { metricsPageInformation, locationTracker } = shelfMetrics !== null && shelfMetrics !== void 0 ? shelfMetrics : {}; metricsHelpersClicks.addClickEventToAction(objectGraph, action, { id: "SeeAllReviews", actionType: "navigate", locationTracker: locationTracker, pageInformation: metricsPageInformation, }, false, "button"); } return action; }); } /** * Creates a flow action to display a single reviewreviewsPageUrl * @param deviceId The UUID for the user's device. * @param ratingsData The ratings data response * @param item The review data from a single row data response. * @returns A `FlowAction` object pointing to a reviews page. */ function singleReviewPageActionFromReviewItem(objectGraph, deviceId, ratingsData, item, shelfMetrics) { if (!item) { return null; } return validation.context("singleReviewActionFromReviewData", () => { // Reviews shelf const reviewsShelf = reviewsShelfForReviewsData(objectGraph, deviceId, ratingsData, [item], null, null, null, shelfMetrics); const page = new models.ReviewsPage(); page.adamId = serverData.asString(ratingsData, "adamId"); page.targetReviewId = serverData.asString(item, "id", "coercible"); page.shelves = [reviewsShelf]; const action = new models.FlowAction("reviews"); action.pageData = page; action.title = sectionTitleForPlatform(objectGraph); return action; }); } export function pageTitleForPlatform(objectGraph) { if (objectGraph.client.isWatch) { return objectGraph.loc.string("ProductPage.Section.Reviews.Title"); } else { return null; } } export function sectionTitleForPlatform(objectGraph) { switch (objectGraph.client.deviceType) { case "tv": return objectGraph.loc.string("TV_PRODUCT_SECTION_RATINGS"); case "watch": return null; default: return objectGraph.loc.string("PRODUCT_SECTION_REVIEWS"); } } /** * Creates a flow action for navigating to the expanded view of a review. * * @param objectGraph The current object graph * @param review The source review * @returns A flow action */ function createReviewDetailActionForReview(objectGraph, review) { const flowAction = new models.FlowAction("reviewDetail"); const detailReview = objects.shallowCopyOf(review); detailReview.moreAction = null; flowAction.pageData = detailReview; return flowAction; } //# sourceMappingURL=reviews.js.map