import { isNothing, isSome } from "@jet/environment"; import * as models from "../../../api/models"; import * as serverData from "../../../foundation/json-parsing/server-data"; import * as mediaAttributes from "../../../foundation/media/attributes"; import * as mediaDataFetching from "../../../foundation/media/data-fetching"; import * as mediaDataStructure from "../../../foundation/media/data-structure"; import * as mediaRelationship from "../../../foundation/media/relationships"; import { Parameters, Path, Protocol } from "../../../foundation/network/url-constants"; import * as urls from "../../../foundation/network/urls"; import * as appEvents from "../../app-promotions/app-event"; import * as appPromotionsCommon from "../../app-promotions/app-promotions-common"; import { pageRouter } from "../../builders/routing"; import * as legacyArtwork from "../../content/artwork/legacy-artwork"; import * as contentAttributes from "../../content/attributes"; import * as content from "../../content/content"; import * as externalDeepLink from "../../linking/external-deep-link"; import * as lockups from "../../lockups/lockups"; import * as metricsHelpersClicks from "../../metrics/helpers/clicks"; import * as metricsHelpersLocation from "../../metrics/helpers/location"; import * as onDevicePersonalization from "../../personalization/on-device-personalization"; import * as productPageVariants from "../../product-page/product-page-variants"; import * as refresh from "../../refresh/page-refresh-controller"; import * as mediaRequestUtils from "../../builders/url-mapping-utils"; import { defaultTodayCardConfiguration, lockupsForRelatedContent } from "../../today/today-card-util"; import { isLockupShelf, } from "../grouping-types"; import { categoryArtworkData } from "../../categories"; import { hrefToRoutableUrl } from "../../builders/url-mapping"; import { clientIdentifierForEditorialContextInData } from "../../lockups/editorial-context"; import { areAppTagsEnabled } from "../../util/app-tags-util"; import { AppEventsAttributes } from "../../../gameservicesui/src/foundation/media-api/requests/recommendation-request-types"; /** * Create the base 2 requirements to start parsing a grouping page shelf. These include the base shelf token and the * base metrics options * * @param objectGraph The App Store dependency graph for making native calls and viewing properties * @param mediaApiData The media api data for this specific shelf * @param groupingParseContext The grouping page parsing context. */ export function createBaseShelfRequirements(objectGraph, mediaApiData, groupingParseContext) { const featuredContentId = mediaAttributes.attributeAsNumber(mediaApiData, "editorialElementKind"); // Populate the gamesFilter, if it is provided. let gamesFilter = null; const rawGamesFilter = mediaAttributes.attributeAsString(mediaApiData, "gamesFilter"); switch (rawGamesFilter) { case "arcade": case "nonArcade": case "all": gamesFilter = rawGamesFilter; break; default: if (featuredContentId === 495 /* FeaturedContentID.AppStore_PopularWithYourFriendsMarker */ || featuredContentId === 500 /* FeaturedContentID.AppStore_ContinuePlayingMarker */) { gamesFilter = "arcade"; // Defaulting to Arcade because this param won't be passed until 20I. } break; } // Build Shelf eyebrow, title, and subtitle let eyebrow = null; let title = mediaAttributes.attributeAsString(mediaApiData, shelfTitleAttributePathForFeaturedContentId(objectGraph, featuredContentId)); let titleArtwork = null; let badges = null; let subtitle = mediaAttributes.attributeAsString(mediaApiData, "tagline"); const shelfHeaderConfiguration = {}; // 'Similar To' Personalised Shelf // Check if personalised shelf has a badge-content, if it's got valid content, and the clients support eyebrows and title artwork: // - Reco shelf title becomes the eyebrow // - Featured App in badge-content becomes the shelf title, and we add the icon as title artwork // - No subtitle should be shown // - Flag to use the eyebrown name for metrics let wantsEyebrowNameForMetrics = false; const badgeContent = mediaRelationship.relationshipCollection(mediaApiData, "badge-content")[0]; if (featuredContentId === 476 /* FeaturedContentID.AppStore_PersonalizedShelfMarker */ && serverData.isDefinedNonNullNonEmpty(badgeContent)) { eyebrow = objectGraph.loc.uppercased(mediaAttributes.attributeAsString(mediaApiData, shelfTitleAttributePathForFeaturedContentId(objectGraph, featuredContentId))); subtitle = null; const dataType = badgeContent.type; if (dataType === "collections") { title = content.notesFromData(objectGraph, badgeContent, "name"); const artworkData = categoryArtworkData(objectGraph, badgeContent, false); // Reconfigure eyebrow text color only if there is eyebrow artwork. if (isSome(artworkData)) { titleArtwork = content.artworkFromApiArtwork(objectGraph, artworkData, { useCase: 1 /* content.ArtworkUseCase.LockupIconSmall */, style: "unadorned", }); const eyebrowColor = { type: "named", name: "secondaryText" }; shelfHeaderConfiguration.eyebrowColor = eyebrowColor; } badges = { forYou: true }; } else { wantsEyebrowNameForMetrics = true; title = mediaAttributes.attributeAsString(badgeContent, shelfTitleAttributePathForFeaturedContentId(objectGraph, featuredContentId)); titleArtwork = content.iconFromData(objectGraph, badgeContent, { useCase: 1 /* content.ArtworkUseCase.LockupIconSmall */, }); } } const shelfToken = { featuredContentId, id: serverData.asString(mediaApiData, "id"), presentationHints: {}, metricsPageInformation: groupingParseContext.metricsPageInformation, metricsLocationTracker: groupingParseContext.metricsLocationTracker, pageGenreId: groupingParseContext.pageGenreId, featuredContentData: mediaApiData, title: title, subtitle: subtitle, eyebrow: eyebrow, titleArtwork: titleArtwork, shelfHeaderConfiguration: shelfHeaderConfiguration, shouldFilter: false, gamesFilter: gamesFilter, remainingItems: [], isFirstRender: true, isDeferring: false, showOrdinals: false, hasExistingContent: false, showingPlaceholders: false, ordinalIndex: 1, isSearchLandingPage: groupingParseContext.isSearchLandingPage, isArcadePage: groupingParseContext === null || groupingParseContext === void 0 ? void 0 : groupingParseContext.isArcadePage, }; const shelfMetricsOptions = { id: shelfToken.id, kind: null, softwareType: serverData.asBooleanOrFalse(groupingParseContext === null || groupingParseContext === void 0 ? void 0 : groupingParseContext.isArcadePage) ? "Arcade" : null, targetType: "swoosh", title: wantsEyebrowNameForMetrics ? shelfToken.eyebrow : shelfToken.title, badges: badges, pageInformation: groupingParseContext.metricsPageInformation, locationTracker: groupingParseContext.metricsLocationTracker, idType: "its_contentId", fcKind: featuredContentId, recoMetricsData: recoMetricsDataForFCData(objectGraph, mediaApiData), }; return { shelfToken: shelfToken, metricsOptions: shelfMetricsOptions, }; } // endregion // region Shelf Tokens /** * Whether we should defer building the rest of a shelf given a shelf token * @param token */ export function shouldDefer(token) { return token && token.isDeferring && token.isFirstRender; } /** * Due to upstream oddities, we get the shelf title in the form of `title` for these markers. These are * unique markers in that they are not filled by reco. * * For personalization markers, they are programmed in DJ with the name field hidden, and it is filled in by * Reco. Without wanting to expose loc strings to that name field, we unfortunately have to use this other * attribute key. * * This will all go away once these markers are replaced with proper Reco equivalents (i.e. when popular- * with-your-friends and suggested-friends move to Reco). */ export function shelfTitleAttributePathForFeaturedContentId(objectGraph, id) { switch (id) { case 548 /* FeaturedContentID.AppStore_GameCenterActivityFeedMarker */: case 495 /* FeaturedContentID.AppStore_PopularWithYourFriendsMarker */: case 496 /* FeaturedContentID.AppStore_SuggestedFriendsMarker */: return "title"; default: return "name"; } } /** * Returns the URL schema for grouping shelves that may need to fetch additional content. * Grouping-Related builders can extend on this scheme if needed, e.g. query param on Continue Playing shelves. * @param token Token to encode in URL for subsequent fetch. */ export function groupingShelfUrl(token) { let shelfUrl = new urls.URL() .set("protocol", Protocol.internal) .append("pathname", Path.grouping) .append("pathname", Path.shelf) .append("pathname", encodeURIComponent(JSON.stringify(token))) .param(Parameters.groupingFeaturedContentId, `${token.featuredContentId}`); if (isSome(token.nativeGroupingShelfId)) { shelfUrl = shelfUrl.param(Parameters.nativeGroupingShelfId, `${token.nativeGroupingShelfId}`); } return shelfUrl.build(); } /** * Configure `url` on a standard grouping shelf if it needs to fetch more content. * @param shelf Shelf to add url to. * @param token Token tode encode in URL for subsequent fetch. */ export function createShelfTokenUrlIfNecessaryForShelf(objectGraph, shelf, token) { if (serverData.isNullOrEmpty(token)) { return null; } // Web does not curently support pagination via "fetch more", so shelf token URLs are not needed. // Note: removing these URLs for web reduces view model size by ~45%. if (objectGraph.client.isWeb) { return null; } // Ensure the token has a `shelfStyle` so we can construct an empty shelf in the case of a hydration error, // or the shelf being empty upon hydration. if (serverData.isNull(token.shelfStyle)) { token.shelfStyle = shelf.contentType; } const hasNonPlaceholderItems = shelf.contentType !== "placeholder" && serverData.isDefinedNonNullNonEmpty(shelf.items); token.hasExistingContent = token.hasExistingContent || (hasNonPlaceholderItems && token.isFirstRender); const shouldAddFirstRenderUrl = token.remainingItems.length || token.recommendationsHref || token.onDeviceRecommendationsUseCase; if (shouldAddFirstRenderUrl && token.isFirstRender) { return groupingShelfUrl(token); } else { return null; } } /** * Updates a shelf URL based on the provided token. * @param shelf Shelf to add url to. * @param token Token to encode in the url. */ export function updateShelfUrlWithNewToken(objectGraph, shelf, token) { const originalShelfUrl = urls.URL.from(shelf.url); const updatedUrl = urls.URL.from(createShelfTokenUrlIfNecessaryForShelf(objectGraph, shelf, token)); // Add missing query params to the updated URL from the original. for (const key of Object.keys(originalShelfUrl.query)) { if (serverData.isNull(updatedUrl.query[key])) { updatedUrl.query[key] = originalShelfUrl.query[key]; } } // Finally, update the shelf's URL. shelf.url = updatedUrl.build(); } /** * From a grouping shelf token determine the list of unhydrated MAPI data to fetch * this will also handle the case were whe need to fetch additional relationship data * * This method will also hoist up ids of content needed to be fetched from an unhydrated relationship if needed. For . * * This is necessary to accommodate some MAPI objects which have a nested relationship that isn't hydrated and cannot * be hydrated by re-fetching the content at parent ID. * * @param token The shelfToken for the shelf we're fetching data for */ export function unhydratedRemainingItemsFromShelfToken(objectGraph, token) { var _a; const shouldFetchRelationshipItems = ((_a = token.relationshipToFetch) === null || _a === void 0 ? void 0 : _a.length) > 0; let remainingItems = token.remainingItems; if (shouldFetchRelationshipItems) { remainingItems = token.remainingItems.map((remainingItem) => { return mediaRelationship.relationshipData(objectGraph, remainingItem, token.relationshipToFetch); }); } return remainingItems; } /** * Given a MAPI response for a list of unhydrated shelf items, and the original grouping shelf token determine * the list of hydrated MAPI data. This also handles the case where we need to hoist up relationship data * * If ids to be fetched were hoisted from nested relationships, replace the original unhydrated nested * relationship on parent with hydrated data. * * @param token * @param mediaApiData */ export function hydratedRemainingItemsForShelfTokenFromMediaApiData(objectGraph, token, mediaApiData) { var _a; const didFetchRelationshipItems = ((_a = token.relationshipToFetch) === null || _a === void 0 ? void 0 : _a.length) > 0; let hyrdatedItems = mediaDataStructure.dataCollectionFromDataContainer(mediaApiData); if (didFetchRelationshipItems) { const dataMapping = {}; for (const dataItem of mediaApiData.data) { dataMapping[dataItem.id] = dataItem; } hyrdatedItems = []; for (const remainingItem of token.remainingItems) { const unhydratedRelationshipData = mediaRelationship.relationshipData(objectGraph, remainingItem, token.relationshipToFetch); if (serverData.isDefinedNonNullNonEmpty(unhydratedRelationshipData)) { remainingItem.relationships[token.relationshipToFetch].data = [ dataMapping[unhydratedRelationshipData.id], ]; } hyrdatedItems.push(remainingItem); } } return hyrdatedItems; } /** * Deletes all remainingItems that have been requested for hydration from the shelfToken. * * @param shelfToken * @param requestedItems */ export function flushRequestedItemsFromShelfToken(shelfToken, requestedItemIds) { shelfToken.remainingItems = shelfToken.remainingItems.filter((remainingItem) => { return !requestedItemIds.has(remainingItem.id); }); } // endregion // region Shelf Requests /** * Generates a media API request for fetching a shelf's data * @param objectGraph * @param {URL} url The URL of the page being requested * @param {Parameters} parameters The parameters that were extracted from the URL * @returns {Request} A media API request */ export function generateShelfRequest(objectGraph, token, parameters) { var _a; // Whether or not token is for fetching additional items that were unhydated. // These tokens are used when a server returned explicit IDs for the contents of shelves, and some (or all) of those IDs were fully unhydrated. const isFetchingAdditionalContent = serverData.isDefinedNonNullNonEmpty(token.remainingItems); // Whether or not token is for fetching personalized recommendations. // Recommendation shelves can sometimes return with no IDs provided within its shelf. We are expected to use the `recommendationsHref` to fetch in this case. // Note that server can sometimes choose to prepopulate IDs for personalized shelves, even if `recommendationsHref` is present. const isPersonalizedRefetch = !isFetchingAdditionalContent && ((_a = token.recommendationsHref) === null || _a === void 0 ? void 0 : _a.length) > 0; if (isFetchingAdditionalContent) { // Remaining items to fetch, since they were unhydrated in original page response. const remainingItemsToFetch = unhydratedRemainingItemsFromShelfToken(objectGraph, token); // MAPI Request const mediaApiRequest = new mediaDataFetching.Request(objectGraph, remainingItemsToFetch, true); productPageVariants.addVariantParametersToRequestForItems(objectGraph, mediaApiRequest, remainingItemsToFetch); prepareGroupingShelfRequest(objectGraph, mediaApiRequest); return mediaApiRequest; } else if (isPersonalizedRefetch) { const mediaApiRequest = new mediaDataFetching.Request(objectGraph, token.recommendationsHref).includingAgeRestrictions(); prepareGroupingShelfRequest(objectGraph, mediaApiRequest); if (appPromotionsCommon.appEventsAreEnabled(objectGraph)) { mediaApiRequest.enablingFeature("appEvents"); mediaApiRequest.includingMetaKeys("editorial-elements:contents", ["personalizationData", "cppData"]); mediaApiRequest.includingScopedAttributes("app-events", AppEventsAttributes); mediaApiRequest.includingScopedRelationships("app-events", ["app"]); } if (appPromotionsCommon.appContingentItemsAreEnabled(objectGraph)) { mediaApiRequest.enablingFeature("contingentItems"); mediaRequestUtils.configureContingentItemsForGroupingRequest(mediaApiRequest); } if (appPromotionsCommon.appOfferItemsAreEnabled(objectGraph)) { mediaApiRequest.enablingFeature("offerItems"); mediaRequestUtils.configureOfferItemsForMediaRequest(mediaApiRequest); } if (areAppTagsEnabled(objectGraph, "grouping")) { mediaRequestUtils.configureTagsForMediaRequest(mediaApiRequest); } return mediaApiRequest; } return null; } /** * Modify request for a items fetched for grouping shelf items. (i.e. individual IDs). * This should be ideally be in grouping builder's `prepareRequest`, but that would change how url-driven requests are configured. * @param objectGraph * @param request Request to modify. */ export function prepareGroupingShelfRequest(objectGraph, request) { request .includingAdditionalPlatforms(mediaDataFetching.defaultAdditionalPlatformsForClient(objectGraph)) .includingRelationshipsForUpsell(true) .includingMacOSCompatibleIOSAppsWhenSupported(true) .usingCustomAttributes(productPageVariants.shouldFetchCustomAttributes(objectGraph)); let attributes = ["editorialArtwork", "editorialVideo", "minimumOSVersion"]; if (request.includesResourceType("app-events") && appPromotionsCommon.appEventsAreEnabled(objectGraph)) { request.enablingFeature("appEvents"); request.includingMetaKeys("editorial-elements:contents", ["personalizationData", "cppData"]); request.includingScopedAttributes("app-events", AppEventsAttributes); request.includingScopedRelationships("app-events", ["app"]); } if (request.includesResourceType("contingent-items") && appPromotionsCommon.appContingentItemsAreEnabled(objectGraph)) { request.enablingFeature("contingentItems"); mediaRequestUtils.configureContingentItemsForGroupingRequest(request); attributes = []; } if (request.includesResourceType("offer-items") && appPromotionsCommon.appOfferItemsAreEnabled(objectGraph)) { request.enablingFeature("offerItems"); mediaRequestUtils.configureOfferItemsForMediaRequest(request); attributes = []; } if (request.includesResourceType("apps") || request.includesResourceType("app-events")) { attributes = attributes.concat("screenshotsByType", "videoPreviewsByType", "expectedReleaseDateDisplayFormat"); } if (areAppTagsEnabled(objectGraph, "grouping")) { mediaRequestUtils.configureTagsForMediaRequest(request); } request.includingAttributes(attributes); } // endregion // region Metrics export function recoMetricsDataForFCData(objectGraph, mediaApiData) { const featuredContentId = mediaAttributes.attributeAsNumber(mediaApiData, "editorialElementKind"); switch (featuredContentId) { // All of these kinds just require walking their children case 425 /* FeaturedContentID.AppStore_GenreStack */: case 415 /* FeaturedContentID.AppStore_HeroList */: case 416 /* FeaturedContentID.AppStore_Hero */: case 417 /* FeaturedContentID.AppStore_CustomHero */: case 501 /* FeaturedContentID.AppStore_PersonalizedHeroMarker */: case 258 /* FeaturedContentID.Sundance_Flowcase */: case 421 /* FeaturedContentID.AppStore_BrickRow */: case 422 /* FeaturedContentID.AppStore_Brick */: case 423 /* FeaturedContentID.AppStore_CustomBrick */: case 261 /* FeaturedContentID.Sundance_BrickRow */: case 584 /* FeaturedContentID.AppStore_TagsBrick */: case 587 /* FeaturedContentID.AppStore_PersonalizedTagsBrick */: { const childrenRelationship = mediaRelationship.relationship(mediaApiData, "children"); return mediaDataStructure.metricsFromMediaApiObject(childrenRelationship); } case 437 /* FeaturedContentID.AppStore_LinkList */: case 265 /* FeaturedContentID.Sundance_LinkList */: { const contentRelationship = mediaRelationship.relationship(mediaApiData, "children"); const textLinks = mediaAttributes.attributeAsArrayOrEmpty(mediaApiData, "links"); if (serverData.isDefinedNonNullNonEmpty(contentRelationship)) { return mediaDataStructure.metricsFromMediaApiObject(contentRelationship); } else if (serverData.isDefinedNonNullNonEmpty(textLinks)) { return mediaDataStructure.metricsFromMediaApiObject(mediaApiData); } return null; } case 414 /* FeaturedContentID.AppStore_TabRoot */: case 424 /* FeaturedContentID.AppStore_ChartSet */: case 566 /* FeaturedContentID.AppStore_ArcadeDownloadPackMarker */: { return null; // unapplicable fckinds } default: { if (isLockupShelf(featuredContentId)) { let childrenRelationship = mediaRelationship.relationship(mediaApiData, "contents"); if (serverData.isNull(childrenRelationship)) { return null; } const contentItems = childrenRelationship.data; if (!contentItems || contentItems.length === 0) { childrenRelationship = mediaRelationship.relationship(mediaApiData, "children"); } return mediaDataStructure.metricsFromMediaApiObject(childrenRelationship); } else { objectGraph.console.warn("Unknown featured content ID:", featuredContentId); return null; } } } } // endregion // region Artwork /** * Creating an artwork model with the crop required for a grouping page piece of art */ export function groupingArtworkFromApiArtwork(objectGraph, artworkData, options) { const artwork = content.artworkFromApiArtwork(objectGraph, artworkData, options); if (artwork) { artwork.crop = "sr"; } return artwork; } export function artworkFromFC(objectGraph, node, width, height, options) { const artworkData = mediaAttributes.attributeAsDictionary(node, "artwork"); if (artworkData instanceof Array) { const artwork = legacyArtwork.closestArtworkMatchingSize(objectGraph, artworkData, width, height); artwork.crop = "bb"; return artwork; } else if (artworkData != null) { return groupingArtworkFromApiArtwork(objectGraph, artworkData, options); } return null; } export function artworkForTags(objectGraph, node, width, height, options, metricsOptions) { const lockupData = serverData.asArrayOrEmpty(node.meta, "associations.apps.data"); const artworks = []; if (isSome(lockupData)) { for (const lockup of lockupData) { const lockupOptions = { artworkUseCase: 1 /* content.ArtworkUseCase.LockupIconSmall */, metricsOptions: metricsOptions, useJoeColorIconPlaceholder: true, joeColorPlaceholderSelectionLogic: content.bestJoeColorPlaceholderSelectionLogic, }; const lockupFromData = lockups.lockupFromData(objectGraph, lockup, lockupOptions); const lockupIcon = lockupFromData === null || lockupFromData === void 0 ? void 0 : lockupFromData.icon; if (isSome(lockupIcon)) { artworks.push(lockupIcon); } } } return artworks; } // endregion // region FC Metadata export function metadataForFCData(objectGraph, fcData, token, shouldPersonalizeContent, personalizationDataContainer, metricsOptions, context, unavailableCallback) { var _a, _b, _c; const callUnavailable = function (contentData) { if (unavailableCallback) { unavailableCallback(); } else { token === null || token === void 0 ? void 0 : token.remainingItems.push(contentData); } }; const isLinkNode = ((_a = serverData.asString(fcData, "url")) === null || _a === void 0 ? void 0 : _a.length) > 0; const containsLinkNode = ((_b = mediaAttributes.attributeAsString(fcData, "link.url")) === null || _b === void 0 ? void 0 : _b.length) > 0; const isContentNodeUsingPrimaryContent = mediaRelationship.hasRelationship(fcData, "primary-content", false); const isContentNode = mediaRelationship.hasRelationship(fcData, "contents", false) || isContentNodeUsingPrimaryContent; // Define whether data is a Category Grouping kind. let isCategoryGroupingKind = mediaAttributes.attributeAsString(fcData, "kind") === "CategoryGrouping"; if (containsLinkNode || isLinkNode) { return metadataForLink(objectGraph, fcData, token, metricsOptions, unavailableCallback); } else if (isContentNode) { let contentData; // Define category grouping content to set when content is a Category Grouping kind. let categoryGroupingContent; let personalizedDataResult; if (shouldPersonalizeContent && !isContentNodeUsingPrimaryContent) { const contentDataItems = mediaRelationship.relationshipCollection(fcData, "contents"); personalizedDataResult = onDevicePersonalization.personalizeDataItems(objectGraph, "groupingCommon", contentDataItems, true, personalizationDataContainer, false, 1); const personalizedContentDataItems = personalizedDataResult.personalizedData; if (personalizedContentDataItems.length === 0) { return null; } contentData = personalizedContentDataItems[0]; } else { contentData = isContentNodeUsingPrimaryContent ? mediaRelationship.relationshipData(objectGraph, fcData, "primary-content") : mediaRelationship.relationshipData(objectGraph, fcData, "contents"); } // Check whether content is a Category Grouping kind. if (mediaAttributes.attributeAsString(contentData, "kind") === "CategoryGrouping") { categoryGroupingContent = contentData; // Update content to its primary content. contentData = mediaRelationship.relationshipData(objectGraph, contentData, "primary-content"); isCategoryGroupingKind = true; } // If the content data is null don't even bother with the call unavailable since we have no way of knowing how to fetch it if (serverData.isNull(contentData)) { return null; } else if (serverData.isNull(contentData.attributes) || shouldDefer(token)) { if (serverData.isDefinedNonNullNonEmpty(token)) { token.isDeferring = true; } callUnavailable(contentData); return null; } // Generate the subtitle let subtitle = content.notesFromData(objectGraph, contentData, "tagline") || lockups.subtitleFromData(objectGraph, contentData); // Generate the click action // Using the fcData here because the ids need to match the id used for the impressions, otherwise the reporting // heat maps dont work. // rdar://61527868 (Metrics: Arcade Data Mismatch (Location and Impressions have different name fields)) const metricsClickOptions = metricsHelpersClicks.clickOptionsForLockup(objectGraph, fcData, metricsOptions); metricsClickOptions.targetType = metricsOptions.targetType; let action = lockups.actionFromData(objectGraph, contentData, metricsClickOptions, token === null || token === void 0 ? void 0 : token.clientIdentifierOverride); // Understand if we're dealing with an article here, so when we go to generate app events, we know if we // should use the action we've created above or the app event action. const isArticle = mediaAttributes.attributeAsBooleanOrFalse(contentData, "isCanvasAvailable"); // Find the artwork and title depending on the content type let artwork = null; let caption = null; // Find lockup (if any) let lockup = null; // Find app event (if any) let appEvent; const shortEditorialDescription = mediaAttributes.attributeAsString(contentData, "itunesNotes.short"); const hasContentId = ((_c = contentData.id) === null || _c === void 0 ? void 0 : _c.length) > 0; const contentMetricsOptions = { ...metricsOptions, id: hasContentId ? contentData.id : fcData.id, idType: hasContentId ? "its_id" : "editorial_id", }; switch (contentData.type) { case "groupings": { artwork = mediaAttributes.attributeAsDictionary(contentData, "artwork"); // If data is a Category Grouping kind, reconfigure content. if (isCategoryGroupingKind) { contentData = categoryGroupingContent !== null && categoryGroupingContent !== void 0 ? categoryGroupingContent : fcData; } break; } case "editorial-items": { // Check for an app-event relationship const relatedCardContents = mediaRelationship.relationshipData(objectGraph, contentData, "card-contents"); if (serverData.isDefinedNonNullNonEmpty(relatedCardContents)) { const clickOptions = { ...contentMetricsOptions, inAppEventId: relatedCardContents.id, }; const parentAppData = mediaRelationship.relationshipData(objectGraph, relatedCardContents, "app"); if (serverData.isDefinedNonNull(parentAppData)) { clickOptions.relatedSubjectIds = [parentAppData.id]; } const appEventOrDate = appEvents.appEventOrPromotionStartDateFromData(objectGraph, relatedCardContents, null, false, true, "dark", "white", false, clickOptions, false, true, null, token.isArcadePage, false); const cardDisplayStyle = mediaAttributes.attributeAsString(contentData, "cardDisplayStyle"); if (cardDisplayStyle === "AppEventCard") { if (appEventOrDate instanceof Date) { // If we get a date back, we have a valid app event, but it starts in the future. // We don't want the object containing this event to render yet, so return early. refresh.addNextPreferredContentRefreshDate(appEventOrDate, context.refreshController); return null; } else if (isNothing(appEventOrDate)) { return null; } else { appEvent = appEventOrDate; if (!isArticle) { action = appEvent.clickAction; } if (serverData.isNullOrEmpty(subtitle)) { subtitle = content.notesFromData(objectGraph, relatedCardContents, "short"); } } } } caption = mediaAttributes.attributeAsString(contentData, "label"); if (caption) { // This is a hack for AOTD/GOTD caption = caption.replace(/\n/g, " "); } const relatedContent = mediaRelationship.relationshipData(objectGraph, contentData, "contents"); const tagline = serverData.asString(contentData, "editorialNotes.tagline"); if (serverData.isNullOrEmpty(subtitle)) { if (tagline) { subtitle = tagline; } else if (relatedContent) { subtitle = content.notesFromData(objectGraph, relatedContent, "short"); } } if (serverData.isNullOrEmpty(subtitle) && serverData.isDefinedNonNull(appEvent)) { subtitle = appEvent.subtitle; } let crossLinkSubtitle = mediaAttributes.attributeAsString(contentData, "editorialNotes.short"); if (isNothing(crossLinkSubtitle) || crossLinkSubtitle.length === 0) { crossLinkSubtitle = subtitle; } const cardConfig = defaultTodayCardConfiguration(objectGraph); if (serverData.isNull(appEvent) && externalDeepLink.deepLinkUrlFromData(objectGraph, contentData) && !objectGraph.client.isiOS) { cardConfig.crossLinkSubtitle = crossLinkSubtitle; } cardConfig.clientIdentifierOverride = clientIdentifierForEditorialContextInData(objectGraph, contentData); if (serverData.isDefinedNonNull(appEvent)) { lockup = appEvent.lockup; } else { // On iOS and Web, we always attempt to create a lockup. On other platforms, only do this is there is a cross link. // iOS offer style will be determined later based on artwork. const offerEnvironment = objectGraph.client.isiOS ? null : "dark"; const offerStyle = objectGraph.client.isiOS ? null : "white"; if (objectGraph.client.isiOS || objectGraph.client.isWeb || externalDeepLink.deepLinkUrlFromData(objectGraph, contentData)) { metricsHelpersLocation.pushContentLocation(objectGraph, contentMetricsOptions, token === null || token === void 0 ? void 0 : token.title); const relatedLockups = lockupsForRelatedContent(objectGraph, mediaRelationship.relationshipCollection(contentData, "card-contents"), cardConfig, metricsOptions.pageInformation, metricsOptions.locationTracker, offerEnvironment, offerStyle, externalDeepLink.deepLinkUrlFromData(objectGraph, contentData)); if (relatedLockups.length === 1) { lockup = relatedLockups[0]; } metricsHelpersLocation.popLocation(contentMetricsOptions.locationTracker); } } } // falls through default: { // Create a lockup if possible on iOS const validLockupContentTypes = [ "apps", "arcade-apps", "app-bundles", "in-apps", ]; if (serverData.isNull(lockup) && validLockupContentTypes.indexOf(contentData.type) > -1 && objectGraph.host.isiOS) { metricsHelpersLocation.pushContentLocation(objectGraph, contentMetricsOptions, token === null || token === void 0 ? void 0 : token.title); const lockupOptions = { metricsOptions: { pageInformation: metricsOptions.pageInformation, locationTracker: metricsOptions.locationTracker, recoMetricsData: mediaDataStructure.metricsFromMediaApiObject(contentData), }, clientIdentifierOverride: token === null || token === void 0 ? void 0 : token.clientIdentifierOverride, artworkUseCase: content.artworkUseCaseFromShelfStyle(objectGraph, token === null || token === void 0 ? void 0 : token.shelfStyle), canDisplayArcadeOfferButton: true, shouldHideArcadeHeader: objectGraph.featureFlags.isEnabled("hide_arcade_header_on_arcade_tab") && token.isArcadePage, }; lockup = lockups.lockupFromData(objectGraph, contentData, lockupOptions); metricsHelpersLocation.popLocation(contentMetricsOptions.locationTracker); } artwork = contentAttributes.contentAttributeAsDictionary(objectGraph, contentData, "editorialArtwork") || mediaAttributes.attributeAsDictionary(contentData, "editorialArtwork"); if (serverData.isNullOrEmpty(subtitle) && serverData.isDefinedNonNull(lockup)) { subtitle = lockup.subtitle; } break; } } if (serverData.isDefinedNonNull(action)) { action.presentationStyle = ["textFollowsTintColor"]; // If data is NOT a Category Grouping kind, reconfigure action title. if (!isCategoryGroupingKind) { // Prefer design tag or editorial name for title when available const designTag = unescapeHtmlString(mediaAttributes.attributeAsString(fcData, "designTag")); const editorialTitle = content.notesFromData(objectGraph, contentData, "name"); action.title = designTag || editorialTitle || action.title || subtitle || caption; } } return { action: action, caption: caption, title: action === null || action === void 0 ? void 0 : action.title, subtitle: subtitle, artwork: artwork, shortEditorialDescription: shortEditorialDescription, content: contentData, lockup: lockup, appEvent: appEvent, onDevicePersonalizationDataProcessingType: personalizedDataResult === null || personalizedDataResult === void 0 ? void 0 : personalizedDataResult.processingType, }; } return null; } /// Gets the action and title for a brick that is backed by tags. export function metadataForTag(objectGraph, fcData, token, metricsOptions) { const shortEditorialDescription = mediaAttributes.attributeAsString(fcData, "name"); const href = serverData.asString(fcData, "href"); const url = hrefToRoutableUrl(objectGraph, href); const flowPage = objectGraph.required(pageRouter).fetchFlowPage(url); const flowAction = new models.FlowAction(flowPage); flowAction.pageUrl = url; return { action: flowAction, caption: "null", title: shortEditorialDescription, subtitle: "null", artwork: null, shortEditorialDescription: shortEditorialDescription, }; } function metadataForLink(objectGraph, fcData, token, metricsOptions, unavailableCallback) { const isFCLinkData = serverData.isDefinedNonNull(serverData.asString(fcData, "url")); const linkData = isFCLinkData ? fcData : mediaAttributes.attributeAsDictionary(fcData, "link"); const callUnavailable = function (contentData) { if (unavailableCallback) { unavailableCallback(); } else { token === null || token === void 0 ? void 0 : token.remainingItems.push(contentData); } }; if (serverData.isNull(linkData) || shouldDefer(token)) { callUnavailable(fcData); return null; } const target = serverData.asString(linkData, "target"); const url = serverData.asString(linkData, "url"); // Prefer design tag for title when available const label = serverData.asString(linkData, "label"); const designTag = unescapeHtmlString(mediaAttributes.attributeAsString(fcData, "designTag")); const title = designTag || label; let action = null; if (target === "external") { action = new models.ExternalUrlAction(url); action.title = title; } else { const flowPage = objectGraph.required(pageRouter).fetchFlowPage(url); const flowAction = new models.FlowAction(flowPage); flowAction.pageUrl = url; flowAction.title = title; action = flowAction; } action.presentationStyle = ["textFollowsTintColor"]; // Configure metrics const clickOptions = { ...metricsOptions, id: "", }; metricsHelpersClicks.addClickEventToAction(objectGraph, action, clickOptions); return { action: action, caption: null, title: title, subtitle: null, artwork: null, shortEditorialDescription: null, }; } // endregion // region Editorial Data Merging export function mergeContentDataIntoEditorialData(contentDataArray, editorialItemsDataArray) { const contentDataMap = {}; for (const contentData of contentDataArray) { contentDataMap[contentData.id] = contentData; } // The relationships we're interested in merging fetched content for const relationShipsToMerge = ["contents", "grouping"]; const mergedEditorialData = []; // Steps for merging // 1. Loop through the editorial items array // 2. For each editorial item loop through the relationships we're interested in // 3. If this editorial item has any of these relationships loop through that relationsip looking in our content map // for the hydrated version of each piece of data in the relationship // 4. If all the content has been fetched for each relationship we can add it to our resulting array of hydrated editorial items. for (const editorialItemData of editorialItemsDataArray) { let hasHydratedAllRelationships = true; for (const relationshipType of relationShipsToMerge) { const unhydratedRelationshipCollection = mediaRelationship.relationshipCollection(editorialItemData, relationshipType); if (serverData.isDefinedNonNull(unhydratedRelationshipCollection)) { const hydratedRelationship = []; for (const unhydratedData of unhydratedRelationshipCollection) { const hydratedData = contentDataMap[unhydratedData.id]; if (serverData.isDefinedNonNullNonEmpty(hydratedData)) { hydratedRelationship.push(hydratedData); } } if (hydratedRelationship.length === unhydratedRelationshipCollection.length) { editorialItemData.relationships[relationshipType] = { data: hydratedRelationship }; } else { hasHydratedAllRelationships = false; } } } if (hasHydratedAllRelationships) { mergedEditorialData.push(editorialItemData); } } return mergedEditorialData; } // endregion // region Shelf Info /** * For incomplete shelf fetches via a secondary lookup, whether a given shelf w/ token should merge or replace. * @param objectGraph * @param token The token corresponding to the shelf being built */ export function shelfFetchShouldMergeWhenFetched(objectGraph, token) { /** * [POLISH] Arcade Coming Soon: If too few items, the coming soon swoosh should show larger items. * Always reload posterLockup shelves on macOS to adapt if the presentationHint "isLowDensity" changed. */ if (token.shelfStyle === "posterLockup" && objectGraph.client.isMac) { return false; } else if (token.showingPlaceholders) { return false; } // Always reload Arcade download pack shelf // as there is always only a single cell (card) that contains lockups or their placeholders. if (token.shelfStyle === "arcadeDownloadPackCard") { return false; } if (token.shelfStyle === "ribbonBar" && isSome(token.initialHydratedItems) && token.initialHydratedItems.length > 0) { return false; } // Generally true to support partially hydrated shelves. return true; } // endregion // region Search Landing Shelves /** * Modify `shelf` in place for global customization for all SLP shelves. * @param objectGraph * @param shelf * @param token */ export function modifyShelfForSearchLandingGrouping(objectGraph, shelf, token) { shelf.seeAllAction = null; shelf.isHorizontal = false; if (shelf.shouldFilterApps) { // App Store: SLP: Installed app 'Open' button show up inconsistently (Filtering is sometimes not applied) shelf.filteredItemsMinimumCount = 0; shelf.filteringExcludedItems = token.includedAdAdamIds; } } // endregion // region Card Shelves /** * The shelf content type to use for the inline card display style. * @param objectGraph * @param {HorizontalCardDisplayStyle} style The display style for the inline card. * @returns {ShelfContentType} The shelf content type to use. */ export function contentTypeForHorizontalCardDisplayStyle(objectGraph, style) { switch (style) { case "small": return "smallStoryCard"; case "medium": return "mediumStoryCard"; case "large": return "largeStoryCard"; case "card": if (objectGraph.client.isiOS) { return "editorialStoryCard"; } else { return null; } default: return null; } } // endregion /** * Determine the best content id to use given a media api data object * @param objectGraph * @param contentItem The media api data model to find the id from */ export function contentIdFromContentItem(objectGraph, contentItem) { let contentId = mediaAttributes.attributeAsString(contentItem, "adamId"); if (!contentId) { contentId = mediaAttributes.attributeAsString(contentItem, "contentId"); } if (!contentId) { contentId = mediaAttributes.attributeAsString(contentItem, "id"); } return contentId; } export function unescapeHtmlString(str) { if (serverData.isNull(str)) { return null; } const escapedString = str .replace(/&/g, "&") .replace(/>/g, ">") .replace(/</g, "<") .replace(/"/g, '"') .replace(/'/g, "'") .replace(/`/g, "`") .replace(/\r\n/g, " ") .replace(/ /g, " ") .replace(//g, "") .replace(/<\/span>/g, "") .replace(/
/g, " ") .replace(/\u23ce/g, "") .replace(//g, "") .replace(/<\/i>/g, "") .replace(//g, "") .replace(/<\/b>/g, ""); if (escapedString.match(/^\s*$/)) { return null; } return escapedString; } /** * Update the shelf header to use the provided seeAll action. * @param objectGraph The App Store object graph used to check feature flags * @param shelfHeader The shelf header to update * @param seeAllAction The "See All" action to apply */ export function replaceShelfHeaderSeeAllAction(objectGraph, shelfHeader, seeAllAction) { if (objectGraph.featureFlags.isEnabled("shelf_header")) { // Modern headers make title tappable with a chevron (>). shelfHeader.titleAction = seeAllAction; } else { // Legacy headers show "See All" textual button on trailing edge. shelfHeader.accessoryAction = seeAllAction; } } /** * Update the shelf header to use the provided seeAll action. * @param objectGraph The App Store object graph used to check feature flags * @param shelf The shelf whose header needs updating * @param seeAllAction The see all action to apply */ export function replaceShelfSeeAllAction(objectGraph, shelf, seeAllAction) { if (objectGraph.featureFlags.isEnabled("shelf_header")) { if (isSome(shelf.header)) { replaceShelfHeaderSeeAllAction(objectGraph, shelf.header, seeAllAction); } else { shelf.header = { titleAction: seeAllAction, }; } } else { shelf.seeAllAction = seeAllAction; } } //# sourceMappingURL=grouping-shelf-controller-common.js.map