/** * Created by keithpk on 3/21/17. */ import { isNothing, isSome } from "@jet/environment"; 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 mediaAugment from "../../foundation/media/augment"; import * as mediaDataStructure from "../../foundation/media/data-structure"; import * as mediaNetwork from "../../foundation/media/network"; import * as mediaRelationships from "../../foundation/media/relationships"; import * as urls from "../../foundation/network/urls"; import * as color from "../../foundation/util/color-util"; import { PageID } from "../../gameservicesui/src/common/id-builder"; import * as gamesComponentBuilder from "../../gameservicesui/src/editorial-page/editorial-component-builder"; import * as appPromotionsShelf from "../app-promotions/app-promotions-shelf"; import * as arcadeCommon from "../arcade/arcade-common"; import * as arcadeUpsell from "../arcade/arcade-upsell"; import * as breakoutsCommon from "../arcade/breakouts-common"; import * as videoDefaults from "../constants/video-constants"; import * as artworkBuilder from "../content/artwork/artwork"; import * as content from "../content/content"; import { EditorialMediaPlacement } from "../editorial-pages/editorial-media-util"; import { buildSmallStoryCardShelf } from "../editorial-pages/editorial-page-shelf-builder/editorial-page-collection-shelf-builder/editorial-page-story-card-collection-shelf-builder"; import { buildStoryCard } from "../editorial-pages/editorial-page-shelf-builder/editorial-page-collection-shelf-builder/editorial-page-story-card-utils"; import { createBaseShelfToken } from "../editorial-pages/editorial-page-shelf-token"; import { CollectionShelfDisplayStyle } from "../editorial-pages/editorial-page-types"; import * as externalDeepLink from "../linking/external-deep-link"; import * as links from "../linking/os-update-links"; import * as lockups from "../lockups/lockups"; import * as metricsHelpersClicks from "../metrics/helpers/clicks"; import * as metricsHelpersImpressions from "../metrics/helpers/impressions"; import * as metricsHelpersLocation from "../metrics/helpers/location"; import * as metricsHelpersMedia from "../metrics/helpers/media"; import * as metricsHelpersPage from "../metrics/helpers/page"; import * as metricsHelpersUtil from "../metrics/helpers/util"; import * as sharing from "../sharing"; import { crossLinkSubtitleFromData, defaultTodayCardConfiguration, fallbackWatchTodayCardFromData, todayCardFromData, } from "./today-card-util"; import * as todayHorizontalCardUtil from "./today-horizontal-card-util"; import { todayCardPreviewUrlForTodayCard } from "./today-parse-util"; import { HeroMediaDisplayContext, TodayCardDisplayStyle, TodayParseContext, } from "./today-types"; export const iAPBackgroundColor = color.named("componentBackgroundStandout"); const appShowcaseBackgroundColor = color.named("componentBackgroundStandout"); const arcadeShowcaseShelfBackgroundColor = color.named("componentBackgroundStandout"); /** * Resolves the article module's app media platform to an `AppPlatform` to use for screenshots. * @param {AppMediaPlatform} appMediaPlatform The server-dictated media platform to use for the module. * @returns {AppPlatform} The app platform that is appropriate for this media platform, taking into account our device. */ function appPlatformFromAppMediaPlatform(objectGraph, appMediaPlatform) { switch (appMediaPlatform) { case "Watch": return "watch"; case "iOS": if (objectGraph.client.isPad) { return "pad"; } else { return "phone"; } case "tvOS": return "tv"; case "Messages": return "messages"; case "visionOS": return "vision"; default: return null; } } export class ArticleParseContext { constructor() { // The index of the current module this.index = 0; // The reco metrics from the shelf on the today page this.todayShelfRecoMetricsData = {}; /// Whether there are any focusable elements (for touch mode) this.hasFocusableElements = false; /// Whether there are any non-focusable elements (for touch mode) this.hasNonFocusableElements = false; /// Whether there is a resilient deep link. this.isResilientDeepLink = false; /// Whether or not to allow app event previews, used by editorial to preview app event stories before they are published this.allowUnpublishedAppEventPreviews = false; } } function todayCardConfigFromArticleContext(objectGraph, articleContext) { if (!serverData.isDefinedNonNull(articleContext)) { return null; } if (isSome(articleContext.todayCardConfig)) { return articleContext.todayCardConfig; } const config = defaultTodayCardConfiguration(objectGraph); config.enableListCardToMultiAppFallback = false; config.clientIdentifierOverride = articleContext.clientIdentifierOverride; config.useOTDTextStyle = false; config.allowUnpublishedAppEventPreviews = articleContext.allowUnpublishedAppEventPreviews; config.currentRowIndex = undefined; switch (objectGraph.client.deviceType) { case "mac": config.prevailingCropCodes = { defaultCrop: "en" }; config.coercedCollectionTodayCardDisplayStyle = TodayCardDisplayStyle.Grid; config.heroDisplayContext = HeroMediaDisplayContext.Article; break; case "tv": config.prevailingCropCodes = { "defaultCrop": "ek", "editorialArtwork.storyCenteredStatic16x9": "SCS.ApDHXL01", }; config.coercedCollectionTodayCardDisplayStyle = TodayCardDisplayStyle.Grid; config.heroDisplayContext = HeroMediaDisplayContext.Article; break; case "web": config.coercedCollectionTodayCardDisplayStyle = TodayCardDisplayStyle.River; config.prevailingCropCodes = { "defaultCrop": "sr", "editorialArtwork.dayCard": "grav.west", }; break; default: break; } return config; } export function articlePageFromResponse(objectGraph, articleResponse, context) { return validation.context("articlePageWithResponse", () => { var _a; const articleData = mediaDataStructure.dataFromDataContainer(objectGraph, articleResponse); context.metricsPageInformation = metricsHelpersPage.metricsPageInformationFromMediaApiResponse(objectGraph, "editorialItem", articleData.id, articleResponse); context.metricsLocationTracker = metricsHelpersLocation.newLocationTracker(); context.pageId = articleData.id; // Bridge over article contexts to today's metrics context and card config const todayParseContext = new TodayParseContext(context.metricsPageInformation, context.metricsLocationTracker, context.refreshController); const todayCardConfig = todayCardConfigFromArticleContext(objectGraph, context); // Render the top card let todayCard = todayCardFromData(objectGraph, articleData, todayCardConfig, todayParseContext); let editorialStoryCard = null; const todayCardMedia = todayCard === null || todayCard === void 0 ? void 0 : todayCard.media; if (objectGraph.client.isVision || preprocessor.GAMES_TARGET) { editorialStoryCard = buildStoryCard(objectGraph, articleData, EditorialMediaPlacement.StoryDetail, { pageInformation: context.metricsPageInformation, locationTracker: context.metricsLocationTracker, }, CollectionShelfDisplayStyle.StoryMedium, false); todayCard = null; } if (isNothing(todayCard)) { todayCard = fallbackWatchTodayCardFromData(objectGraph, articleData, todayCardConfig, todayParseContext); } // Get the title for metrics purposes. const title = (_a = todayCard === null || todayCard === void 0 ? void 0 : todayCard.title) !== null && _a !== void 0 ? _a : editorialStoryCard === null || editorialStoryCard === void 0 ? void 0 : editorialStoryCard.title; const editorialItemKind = mediaAttributes.attributeAsString(articleData, "kind"); // Configure subtitle for cross link context.crossLinkSubtitle = crossLinkSubtitleFromData(objectGraph, articleData); // Bridge today config back into articles, now that cards are created. // Now we've created the card, reference the clientIdentifierOverride it used for the rest of the article. context.clientIdentifierOverride = todayCardConfig.clientIdentifierOverride; // Start a metrics location metricsHelpersLocation.pushContentLocation(objectGraph, { pageInformation: context.metricsPageInformation, locationTracker: context.metricsLocationTracker, targetType: "article", id: context.pageId, idType: "its_id", }, title); // Render the article itself const shelves = renderArticle(objectGraph, articleData, todayCardMedia, context); const lastShelf = shelves[shelves.length - 1]; // Sharing const shareAction = objectGraph.client.isTV || objectGraph.client.isWeb || context.isResilientDeepLink || preprocessor.GAMES_TARGET || editorialItemKind === "OfferItem" ? null : shareSheetActionFromData(objectGraph, articleData, todayCardConfig); if (serverData.isDefinedNonNull(shareAction)) { // Add click event metricsHelpersClicks.addClickEventToAction(objectGraph, shareAction, { targetType: "button", id: context.pageId, actionType: "share", pageInformation: context.metricsPageInformation, locationTracker: context.metricsLocationTracker, }); const isLastModuleFullWidth = isArticleShelfFullWidth(objectGraph, lastShelf, context.module); const shareButtonShelf = createShareShelf(objectGraph, shareAction, context, isLastModuleFullWidth); if (shareButtonShelf) { shelves.push(shareButtonShelf); } } const page = new models.ArticlePage(todayCard, shelves, shareAction); page.editorialStoryCard = editorialStoryCard; page.title = todayCard === null || todayCard === void 0 ? void 0 : todayCard.title; page.subtitle = todayCard === null || todayCard === void 0 ? void 0 : todayCard.inlineDescription; addFooterLockupForPageIfNeeded(objectGraph, page, articleData, context); if (objectGraph.client.isTV) { if (context.hasFocusableElements && !context.hasNonFocusableElements) { page.touchMode = "focus"; } else if (!context.hasFocusableElements && context.hasNonFocusableElements) { page.touchMode = "pan"; } else { page.touchMode = "auto"; } } // Map whether the article should terminate on close. page.shouldTerminateOnClose = context.isResilientDeepLink; metricsHelpersPage.addMetricsEventsToPageWithInformation(objectGraph, page, context.metricsPageInformation, (fields) => { let additionalValue = title; if ((todayCard === null || todayCard === void 0 ? void 0 : todayCard.media) instanceof models.TodayCardMediaBrandedSingleApp && (todayCard === null || todayCard === void 0 ? void 0 : todayCard.overlay) instanceof models.TodayCardLockupOverlay) { const lockupOverlay = todayCard === null || todayCard === void 0 ? void 0 : todayCard.overlay; additionalValue = lockupOverlay.lockup.title; } if (!additionalValue) { return; } let pageDetails = serverData.asString(serverData.asJSONValue(fields["pageDetails"]), "coercible"); pageDetails = pageDetails || serverData.asString(serverData.asJSONValue(fields["pageId"])); if (pageDetails) { fields["pageDetails"] = `${pageDetails}_${additionalValue}`; } else { fields["pageDetails"] = `unknown_${additionalValue}`; } }); page.canonicalURL = mediaAttributes.attributeAsString(articleData, "url"); if (isSome(articleData)) { const articleUrl = mediaAttributes.attributeAsString(articleData, "url"); if (isSome(articleUrl)) { page.viewArticleAction = new models.ExternalUrlAction(articleUrl, true); } } return page; }); } function renderArticle(objectGraph, articleData, cardMedia, context) { return validation.context("renderArticle", () => { var _a; const shelves = []; const canvas = (_a = mediaRelationships.relationshipCollection(articleData, "canvas")) !== null && _a !== void 0 ? _a : []; for (const storyModule of canvas) { context.module = mediaAttributes.attributeAsString(storyModule, "displayType"); context.subStyle = null; const shelfIndex = shelves.length; const shelvesToRender = renderModule(objectGraph, storyModule, articleData, context, shelfIndex); if (shelvesToRender.length > 0) { for (const shelf of shelvesToRender) { shelf.title = context.titleForNextShelf; if (objectGraph.client.isTV) { // Skip unsupported tvOS shelves if (shelf.contentType === "editorialLink") { continue; } } else if (objectGraph.client.isWatch) { // Skip unsupported watchOS shelves if (shelf.contentType === "editorialLink") { continue; } } shelves.push(shelf); context.titleForNextShelf = null; } } context.index++; metricsHelpersLocation.nextPosition(context.metricsLocationTracker); } // If we we're showing the fallback list card type on the today page, we're going to show // the lockups as a list shelf underneath so we can still display the list contents. // If we're on watchOS, we also want to hit this codepath so that lockup lists do not // show as empty pages. if ((context.showingFallbackMediaInline || objectGraph.client.isWatch || objectGraph.client.isVision || objectGraph.client.isWeb || preprocessor.GAMES_TARGET) && shelves.length === 0) { const fallbackShelf = createFallbackListShelf(objectGraph, cardMedia); if (serverData.isDefinedNonNull(fallbackShelf)) { shelves.push(fallbackShelf); } } return shelves; }); } // region Data Augmenting /** * Article specific entrypoint for page response augmenting. See `augment.ts`. * @param response Response to augment. */ export async function fetchAdditionalDataForInitialResponse(objectGraph, response) { return await mediaAugment.fetchAugmentedData(objectGraph, response, findAdditionalDataKeysForArticleResponse, fetchDataForArticleDataKey); } /** * Determine the set of data, expressed as an set of `ArticleAdditionalDataKey`s, that need to be fetched for given article response to be displayed. * This is equivalent to `AbstractMediaApiPageBuilder.additionalDataKeysNeededForData`, but for article builder which doesn't adopt the `builder` API. * * @param articleResponse Initial response to determine additional data requirements for. * @returns {Set} Additional data needed expressed as set of `ArticleAdditionalDataKey` */ function findAdditionalDataKeysForArticleResponse(objectGraph, articleResponse) { /** * Keys for requested requirements determined by: * - Modules in canvas only now :) */ const allAdditionalDataKeySet = new Set(); // Requirements based on canvas items: const articleData = mediaDataStructure.dataFromDataContainer(objectGraph, articleResponse); const canvasModules = mediaRelationships.relationshipCollection(articleData, "canvas"); for (const storyModule of canvasModules) { // Determine additional requests and add to `allRequirementsSet` const dataKeysForModule = additionalDataKeysForArticleModule(objectGraph, storyModule, articleData); if (serverData.isDefinedNonNullNonEmpty(dataKeysForModule)) { for (const requirement of dataKeysForModule) { allAdditionalDataKeySet.add(requirement); } } } return allAdditionalDataKeySet; } /** * Builds a promise that will fetch data fulfilling given requirement. Note that these promises will return `null` when they fail, * and their failure should not cause the entire page to fail. * This is equivalent to `AbstractMediaApiPageBuilder.fetchAdditionalDataForKey`, but for article builder which doesn't adopt the `builder` API. * * @param dataKey Corresponding data key to fetch data for. */ // eslint-disable-next-line @typescript-eslint/promise-function-async function fetchDataForArticleDataKey(objectGraph, dataKey) { let request; if (dataKey === "upsellForNonacquisitionCanvas") { // Use `editorialItem` matching context of that would've otherwise been joined if this story was an acquisition story. request = arcadeCommon.arcadeUpsellRequest(objectGraph, models.marketingItemContextFromString("editorialItemCanvas")); } if (dataKey === "arcadeIcons") { // Require 10 for now. request = arcadeCommon.arcadeAppsRequestForIcons(objectGraph, 10); } if (serverData.isNull(request)) { return null; } // Failable data fetch, either resolving to valid response or `null`. return mediaNetwork.fetchData(objectGraph, request).catch(() => null); } /** * Determine the requirements for single article module as determined by it's type. * @param storyModule The module to fetch additional requirements for. * @param articleData The article that contains `storyModule` in its canvas. * @returns {ArticleAdditionalDataKey[] | undefined} Set of data keys if any are needed for rendering given module. */ export function additionalDataKeysForArticleModule(objectGraph, storyModule, articleData) { // Only `AppMarker` has additional requirements. const moduleType = mediaAttributes.attributeAsString(storyModule, "displayType"); if (moduleType !== "AppMarker") { return null; } const markerType = mediaAttributes.attributeAsString(storyModule, "appMarkerType"); // In story Arcade acquisition module dropping from stories // Editorial wants to use the acquisition module in non-acquisition stories, but the `upsell` relationship is only joined for EIs marked with the acquisition flag. // When an article is missing the upsell relationship, we'll fetch it separately if we have modules that need it... const articleDataIsMissingUpsell = serverData.isNull(arcadeCommon.upsellFromRelationshipOf(objectGraph, articleData)); /** * Acquisition AppMarker, i.e. `ArcadeShowcase` needs: * 1. Upsell data for text data, e.g. editorial notes and breakoutCallToAction label, provided this data isn't already provided as part of original page. * 2. Assortment of Arcade App Icons (iOS Only). */ const additionalDataKeysForModule = []; if (markerType === "Acquisition") { // iOS needs icon dependency if (objectGraph.host.isiOS || objectGraph.client.isVision) { additionalDataKeysForModule.push("arcadeIcons"); } // All platform needs upsell to render acquisition modules, add it if missing. if (articleDataIsMissingUpsell) { additionalDataKeysForModule.push("upsellForNonacquisitionCanvas"); } } return additionalDataKeysForModule; } // endregion /** * Create a shelf model representing a single module within article pages. * @param storyModule Module server data to build shelf and contents from. * @param articleData The data for article that contains `storyModule` above. * @param context Global parse context updated while entire sets of modules are being parsed. * @returns an array of `Shelf` or `null` if building fails for given module. */ function renderModule(objectGraph, storyModule, articleData, context, shelfIndex) { return validation.catchingContext(`module: ${context.module}`, () => { var _a; const shelves = []; switch (context.module) { case "Header": { context.titleForNextShelf = mediaAttributes.attributeAsString(storyModule, "editorialCopy"); break; } case "TextBlock": { const textBlockShelf = createParagraph(objectGraph, storyModule, context); if (isSome(textBlockShelf)) { shelves.push(textBlockShelf); context.hasNonFocusableElements = true; } break; } case "CollectionLockup": { const appListShelf = createAppList(objectGraph, storyModule, context); if (isSome(appListShelf)) { shelves.push(appListShelf); context.hasFocusableElements = true; } break; } case "InlineImage": { const inlineImageShelf = createImage(objectGraph, storyModule, context); if (isSome(inlineImageShelf)) { shelves.push(inlineImageShelf); context.hasNonFocusableElements = true; } break; } case "AppLockup": { const appLockupShelf = createAppLockup(objectGraph, storyModule, context); if (isSome(appLockupShelf)) { shelves.push(appLockupShelf); context.hasFocusableElements = true; } break; } case "TipBlock": { const tipShelf = createTip(objectGraph, storyModule, context); if (isSome(tipShelf)) { shelves.push(tipShelf); context.hasNonFocusableElements = true; } break; } case "PullQuote": { const pullQuoteShelf = createPullQuote(objectGraph, storyModule, context); if (isSome(pullQuoteShelf)) { shelves.push(pullQuoteShelf); context.hasNonFocusableElements = true; } break; } case "HorizontalRule": { const horizontalRuleShelf = createHorizontalRule(objectGraph, storyModule, context); if (isSome(horizontalRuleShelf)) { shelves.push(horizontalRuleShelf); context.hasNonFocusableElements = true; } break; } case "InlineVideo": { const inlineVideoShelf = createVideo(objectGraph, storyModule, context); if (isSome(inlineVideoShelf)) { shelves.push(inlineVideoShelf); context.hasFocusableElements = true; } break; } case "AppMedia": { const appMediaShelf = createAppMedia(objectGraph, storyModule, context); if (isSome(appMediaShelf)) { shelves.push(appMediaShelf); context.hasFocusableElements = true; } break; } case "LinkBlock": { const linkBlockShelf = createLink(objectGraph, storyModule, context); if (isSome(linkBlockShelf)) { shelves.push(linkBlockShelf); context.hasFocusableElements = true; } break; } case "TextList": { const textListShelf = createTextList(objectGraph, storyModule, context); if (isSome(textListShelf)) { shelves.push(textListShelf); context.hasNonFocusableElements = true; } break; } case "IAPLockup": { const iapLockupShelf = createIAPLockup(objectGraph, storyModule, context); if (isSome(iapLockupShelf)) { shelves.push(iapLockupShelf); context.hasFocusableElements = true; } break; } case "AppMarker": { const appMarkerShelf = createAppMarker(objectGraph, storyModule, articleData, context); if (isSome(appMarkerShelf)) { shelves.push(appMarkerShelf); context.hasFocusableElements = true; } break; } case "StoryList": { const storyListShelf = createStoryCards(objectGraph, storyModule, context, shelfIndex); if (isSome(storyListShelf)) { shelves.push(storyListShelf); context.hasFocusableElements = true; } break; } case "AppEventLockup": { const appEventShelf = createAppEventLockup(objectGraph, storyModule, context); if (isSome(appEventShelf)) { shelves.push(appEventShelf); context.hasFocusableElements = true; } break; } case "OfferItemLockup": { const offerItemShelves = createOfferItemLockup(objectGraph, storyModule, context); if (isSome(offerItemShelves)) { shelves.push(...offerItemShelves); context.hasFocusableElements = true; } break; } default: { objectGraph.console.log(`Unknown module: ${context.module}`); } } for (const shelf of shelves) { const existingShelfPresentationHints = (_a = shelf.presentationHints) !== null && _a !== void 0 ? _a : {}; shelf.presentationHints = { ...existingShelfPresentationHints, isArticleContext: true, }; } return shelves; }); } const FULL_WIDTH_MODULES = ["AppLockup", "InlineImage", "InlineVideo", "AppMarker"]; /** * Determines whether the provided parameters signifies a full-width article * module. * @param shelf The shelf in question. * @param type The type of article module. * @returns Whether or not the given shelf for the article type is full width. */ function isArticleShelfFullWidth(objectGraph, shelf, type) { if (shelf && type) { const itemCount = shelf.items.length; if (itemCount > 0 && FULL_WIDTH_MODULES.indexOf(type) !== -1) { const lastItem = shelf.items[itemCount - 1]; switch (shelf.contentType) { case "framedArtwork": { const framedArt = lastItem; return framedArt && framedArt.isFullWidth; } case "framedVideo": { const framedVideo = lastItem; return framedVideo && framedVideo.isFullWidth; } default: { return true; } } } } return false; } // region Footer Lockup /** * Adds either a `footerLockup` or `arcadeFooterLockup` property on `ArticlePage` model, based on type of article. * @param page Page to add footer to if needed. * @param articleData Original data of article being rendered. * @param context Parse context for article builder. */ function addFooterLockupForPageIfNeeded(objectGraph, page, articleData, context) { // App Lockup for Articles about single specific app. const footerProductData = productDataFromArticle(objectGraph, articleData); if (footerProductData) { const externalDeepLinkUrl = externalDeepLink.deepLinkUrlFromData(objectGraph, articleData); page.footerLockup = productFooterLockupFromData(objectGraph, footerProductData, context, externalDeepLinkUrl); return; } // Arcade Lockup for Acquisition Story for supported platforms const isArcadeAcquisitionEI = mediaAttributes.attributeAsBooleanOrFalse(articleData, "isAcquisition"); const platformSupportsArcadeFooterLockup = objectGraph.host.isiOS || objectGraph.host.isMac; const additionalDataIsAvailable = serverData.isDefinedNonNull(context.additionalData); if (additionalDataIsAvailable && isArcadeAcquisitionEI && platformSupportsArcadeFooterLockup) { const upsellData = arcadeCommon.upsellFromRelationshipOf(objectGraph, articleData); page.arcadeFooterLockup = arcadeFooterLockupFromData(objectGraph, upsellData, context); } } /** * Find platform data from editorial item to enhance sharing and display in footer lockup * At the moment, only single app editorials get footer lockups and have enhanced sharing. * * @param editorialItem Item to find footer content for * @returns content to display in footer lockup, or null if no content should be displayed */ export function productDataFromArticle(objectGraph, editorialItem) { const relatedContent = mediaRelationships.relationshipCollection(editorialItem, "card-contents"); if (relatedContent.length !== 1) { return null; } const contentData = relatedContent[0]; if (!contentData) { return null; } switch (contentData.type) { case "apps": case "app-bundles": return contentData; default: return null; } } /** * Creates a footer lockup with a data for a specific app. * Cover method over `lockupFromData` to override `offerStyle`. * * @param data MAPI data to build footer with. * @param context Parse context * @param externalDeepLinkUrl promotional deep link url to use on the lockup's offer. * @returns A new `Lockup` object for footer lockups. */ function productFooterLockupFromData(objectGraph, data, context, externalDeepLinkUrl) { const lockupOptions = { offerStyle: footerLockupOfferStyle(objectGraph), metricsOptions: { pageInformation: context.metricsPageInformation, locationTracker: context.metricsLocationTracker, }, clientIdentifierOverride: context.clientIdentifierOverride, externalDeepLinkUrl: externalDeepLinkUrl, crossLinkSubtitle: context.crossLinkSubtitle, artworkUseCase: 0 /* content.ArtworkUseCase.Default */, canDisplayArcadeOfferButton: content.shelfContentTypeCanDisplayArcadeOfferButtons(objectGraph, "smallLockup"), }; return lockups.lockupFromData(objectGraph, data, lockupOptions); } /** * Creates a footer lockup representing the Arcade subscription service. * @param upsellData Contains both editorial and iAP data for Arcade * @param context Parse context. */ function arcadeFooterLockupFromData(objectGraph, upsellData, context) { const metricsOptions = { pageInformation: context.metricsPageInformation, locationTracker: context.metricsLocationTracker, }; return lockups.arcadeLockupFromData(objectGraph, upsellData, metricsOptions, models.marketingItemContextFromString("editorialItem"), "infer", null); } /** * Determines the offer style to use for the footer lockup. */ function footerLockupOfferStyle(objectGraph) { switch (objectGraph.client.deviceType) { case "mac": return "white"; default: return "infer"; } } // endregion function createFallbackListShelf(objectGraph, cardMedia) { if (cardMedia instanceof models.TodayCardMediaList || cardMedia instanceof models.TodayCardMediaRiver) { const fallbackShelf = new models.Shelf("smallLockup"); fallbackShelf.items = cardMedia.lockups; if (objectGraph.client.isWeb) { fallbackShelf.presentationHints = { ...fallbackShelf.presentationHints, isArticleContext: true, }; } return fallbackShelf; } return null; } function shareSheetActionFromData(objectGraph, editorialItem, todayCardConfig) { const productData = productDataFromArticle(objectGraph, editorialItem); /* * Determine title */ let title = null; const name = content.notesFromData(objectGraph, editorialItem, "name"); const short = content.notesFromData(objectGraph, editorialItem, "short"); // Prefer "name: short" if (name && short) { title = objectGraph.loc .string("ShareSheet.TitleSubtitle.Format", "{title}: {subtitle}") .replace("{title}", name) .replace("{subtitle}", short); } // Followed by name if (!title && name) { title = name; } // Followed by short if (!title && short) { title = short; } // Followed by product name if (!title && productData) { const productTitle = mediaAttributes.attributeAsString(productData, "name"); const cardDisplayStyle = mediaAttributes.attributeAsString(editorialItem, "cardDisplayStyle"); switch (cardDisplayStyle) { case TodayCardDisplayStyle.GameOfTheDay: { title = objectGraph.loc.string("SHARE_SHEET_GAME_OF_DAY_TITLE_FORMAT").replace("{title}", productTitle); break; } case TodayCardDisplayStyle.AppOfTheDay: { title = objectGraph.loc.string("SHARE_SHEET_APP_OF_DAY_TITLE_FORMAT").replace("{title}", productTitle); break; } default: { objectGraph.console.log(`No title for article with unknown style: ${cardDisplayStyle}`); break; } } } const url = mediaAttributes.attributeAsString(editorialItem, "url"); let articleArtwork; const cardDisplayStyle = mediaAttributes.attributeAsString(editorialItem, "cardDisplayStyle"); switch (cardDisplayStyle) { case TodayCardDisplayStyle.Grid: case TodayCardDisplayStyle.List: case TodayCardDisplayStyle.River: articleArtwork = artworkBuilder.createArtworkForResource(objectGraph, "resource://ShareCollectionThumbnail", 40, 40); break; default: articleArtwork = null; break; } // Create share sheet model (bail out if unable to do so) const shareData = sharing.shareSheetDataForArticle(objectGraph, title, url, null, articleArtwork, editorialItem); if (!serverData.isDefinedNonNull(shareData)) { return null; } const activities = sharing.shareSheetActivitiesForArticle(objectGraph, url, todayCardPreviewUrlForTodayCard(objectGraph, editorialItem.id, todayCardConfig), editorialItem.id); return new models.ShareSheetAction(shareData, activities); } function createShareShelf(objectGraph, shareAction, context, isLastModuleFullWidth) { if (!serverData.isDefinedNonNull(shareAction) || objectGraph.client.isVision || preprocessor.GAMES_TARGET || objectGraph.client.isCompanionVisionApp) { return null; } // Create share button const shareButton = new models.RoundedButton("share", objectGraph.loc.string("SHARE_STORY"), !isLastModuleFullWidth, shareAction); // Add share shelf const shareButtonShelf = new models.Shelf("roundedButton"); shareButtonShelf.items = [shareButton]; return shareButtonShelf; } function createParagraph(objectGraph, module, context) { const text = mediaAttributes.attributeAsString(module, "editorialCopy"); if (!text) { return null; } const paragraph = new models.Paragraph(text, "text/x-apple-as3-nqml", "article"); // Setup impressions addImpressionsFieldsToModel(objectGraph, paragraph, context); const shelf = new models.Shelf("paragraph"); shelf.items = [paragraph]; return shelf; } function createImage(objectGraph, module, context) { const displayStyle = mediaAttributes.attributeAsString(module, "inlineImageDisplayType"); const artworkData = mediaAttributes.attributeAsDictionary(module, "artwork"); // If the displayStyle is FullWidth want to 'allowTransparency' so that images blend into the page in both // light and dark mode. Previously editorial would bake white backgrounds into images they wanted to 'blend' // with the page const artwork = content.artworkFromApiArtwork(objectGraph, artworkData, { useCase: 13 /* content.ArtworkUseCase.ArticleImage */, allowingTransparency: displayStyle === "FullWidth" && !objectGraph.client.isVision, withJoeColorPlaceholder: objectGraph.client.isVision, }); if (!artwork) { return null; } const frame = new models.FramedArtwork(artwork, false, "text/x-apple-as3-nqml"); // Get the optional caption frame.caption = mediaAttributes.attributeAsString(module, "editorialCopy"); context.subStyle = displayStyle; if (displayStyle) { switch (displayStyle) { case "BoundingBox": { frame.isFullWidth = false; frame.hasRoundedCorners = true; break; } case "FullWidth": default: { frame.isFullWidth = true; frame.hasRoundedCorners = false; break; } } } // Setup impressions addImpressionsFieldsToModel(objectGraph, frame, context); const shelf = new models.Shelf("framedArtwork"); shelf.items = [frame]; return shelf; } function createTip(objectGraph, module, context) { const artworkData = mediaAttributes.attributeAsDictionary(module, "artwork"); const artwork = content.artworkFromApiArtwork(objectGraph, artworkData, { useCase: 13 /* content.ArtworkUseCase.ArticleImage */, }); if (!artwork) { return null; } const caption = mediaAttributes.attributeAsString(module, "editorialCopy"); const ordinal = mediaAttributes.attributeAsString(module, "tipNumber"); // Create the tip image const frame = new models.FramedArtwork(artwork, false, "text/x-apple-as3-nqml"); frame.isFullWidth = false; frame.hasRoundedCorners = true; frame.caption = caption; frame.ordinal = ordinal; // Setup impressions addImpressionsFieldsToModel(objectGraph, frame, context); // Create the shelf const shelf = new models.Shelf("framedArtwork"); shelf.items = [frame]; return shelf; } function createPullQuote(objectGraph, module, context) { const text = mediaAttributes.attributeAsString(module, "quote"); const attribution = mediaAttributes.attributeAsString(module, "quoteAttribution"); // Get the optional artwork const artworkData = mediaAttributes.attributeAsDictionary(module, "artwork"); const artwork = content.artworkFromApiArtwork(objectGraph, artworkData, { useCase: 13 /* content.ArtworkUseCase.ArticleImage */, }); const fullWidth = mediaAttributes.attributeAsString(module, "pullQuoteDisplayType") === "FullWidth"; // Create the quote const quote = new models.Quote(text, attribution, artwork, fullWidth); // Setup impressions addImpressionsFieldsToModel(objectGraph, quote, context); // Create the shelf const shelf = new models.Shelf("quote"); shelf.items = [quote]; return shelf; } function createHorizontalRule(objectGraph, module, context) { const lineStyle = mediaAttributes.attributeAsString(module, "lineStyle"); const fullWidth = mediaAttributes.attributeAsString(module, "displayStyle") === "FullWidth"; let ruleColor = color.named("defaultLine"); if (objectGraph.client.isVision && (lineStyle === "Dotted" || lineStyle === "Dashed")) { ruleColor = color.white; } // Parse the customColor from Media API. This can only be a dynamic color. const apiColor = mediaAttributes.attributeAsDictionary(module, "customColor"); const lightColor = color.fromHex(serverData.asString(apiColor, "lightMode")); const darkColor = color.fromHex(serverData.asString(apiColor, "darkMode")); if (!serverData.isNullOrEmpty(lightColor) && !serverData.isNullOrEmpty(darkColor)) { ruleColor = color.dynamicWith(lightColor, darkColor); } const horizontalRule = new models.HorizontalRule(lineStyle, ruleColor, fullWidth); // Create the Shelf const shelf = new models.Shelf("horizontalRule"); shelf.items = [horizontalRule]; return shelf; } function createVideo(objectGraph, module, context) { // Get the preview artwork const artworkData = mediaAttributes.attributeAsDictionary(module, "video.previewFrame"); const artwork = content.artworkFromApiArtwork(objectGraph, artworkData, { useCase: 13 /* content.ArtworkUseCase.ArticleImage */, }); if (!artwork) { return null; } // Get the video URL const videoUrl = mediaAttributes.attributeAsString(module, "video.video"); if (!videoUrl) { return null; } const videoDisplayType = mediaAttributes.attributeAsString(module, "inlineVideoDisplayType"); const isFullWidth = videoDisplayType === "FullWidth"; // Create the video const video = new models.Video(videoUrl, artwork, videoDefaults.defaultVideoConfiguration(objectGraph)); metricsHelpersMedia.addMetricsEventsToVideo(objectGraph, video, { pageInformation: context.metricsPageInformation, locationTracker: context.metricsLocationTracker, id: context.pageId, }); const videoModule = new models.FramedVideo(video, isFullWidth, "text/x-apple-as3-nqml"); // Get the optional caption videoModule.caption = mediaAttributes.attributeAsString(module, "editorialCopy"); // Setup impressions addImpressionsFieldsToModel(objectGraph, videoModule, context); // Create the shelf const shelf = new models.Shelf("framedVideo"); shelf.items = [videoModule]; return shelf; } function createAppLockup(objectGraph, module, context) { const contentData = contentFromModule(objectGraph, module, context); if (!contentData) { return null; } // Shelf to generate. Either lockup, app showcase, or app event shelf let shelf = null; // If we have an app-events relationship, we want to use this as the priority. This sometimes exists on the // AppLockup type, rather than as the AppEventLockup type, so that older clients can still render this // item by falling back to the AppLockup type. const appEventsDataItems = mediaRelationships.relationshipCollection(module, "app-events"); if (serverData.isDefinedNonNullNonEmpty(appEventsDataItems)) { shelf = appPromotionsShelf.appEventsShelfForArticle(objectGraph, appEventsDataItems, context.metricsPageInformation, context.metricsLocationTracker, context); if (serverData.isDefinedNonNull(shelf)) { return shelf; } } // Set the display style const displayStyle = mediaAttributes.attributeAsString(module, "appLockupSize"); context.subStyle = displayStyle; let shelfStyle; let isLockup = false; if (displayStyle) { switch (displayStyle) { case "Small": { shelfStyle = "smallLockup"; isLockup = true; break; } case "Medium": { shelfStyle = "mediumLockup"; isLockup = true; break; } case "Large": default: { if (objectGraph.client.isWatch || objectGraph.client.isTV || objectGraph.client.isVision || preprocessor.GAMES_TARGET) { // Per design, on watchOS we always show a lockup for app showcases. // Watch App Store treats all lockup sizes the same -- let's pick small. shelfStyle = "smallLockup"; isLockup = true; } else { shelfStyle = "appShowcase"; } break; } } } // Determine the deep link URL, if there is one. const externalDeepLinkUrl = externalDeepLink.deepLinkUrlFromData(objectGraph, module); // Create the appropriate shelf item if (isLockup) { const lockupShelf = new models.Shelf(shelfStyle); const metricsOptions = { metricsOptions: { pageInformation: context.metricsPageInformation, locationTracker: context.metricsLocationTracker, }, clientIdentifierOverride: context.clientIdentifierOverride, externalDeepLinkUrl: externalDeepLinkUrl, artworkUseCase: content.artworkUseCaseFromShelfStyle(objectGraph, shelfStyle), }; let lockup; if (preprocessor.GAMES_TARGET) { const shelfID = new PageID(context.pageId).shelfID(module.id); lockup = gamesComponentBuilder.makeArticleGameLockup(objectGraph, contentData, shelfID); } else { lockup = lockups.lockupFromData(objectGraph, contentData, metricsOptions); } if (isNothing(lockup)) { return null; } lockupShelf.items = [lockup]; shelf = lockupShelf; } else { // On all platforms, the AppLockup platform generates a AppShowcase when display style is large. shelf = createAppShowcase(objectGraph, module, context); } return shelf; } function createAppShowcase(objectGraph, module, context) { // Create the shelf const shelf = new models.Shelf("appShowcase"); // Parameterize by platform: // tvOS populates the `screenshots` field to display alongside video. const showcaseHasScreenshots = objectGraph.client.isTV; // Only non-tvOS has shelf background color const shelfHasBackgroundColor = objectGraph.client.deviceType !== "tv"; const contentData = contentFromModule(objectGraph, module, context); const externalDeepLinkUrl = externalDeepLink.deepLinkUrlFromData(objectGraph, module); const lockup = lockups.lockupFromData(objectGraph, contentData, { offerStyle: "colored", metricsOptions: { pageInformation: context.metricsPageInformation, locationTracker: context.metricsLocationTracker, }, clientIdentifierOverride: context.clientIdentifierOverride, externalDeepLinkUrl: externalDeepLinkUrl, crossLinkSubtitle: context.crossLinkSubtitle, artworkUseCase: 1 /* content.ArtworkUseCase.LockupIconSmall */, }); const showcase = new models.AppShowcase("large", lockup); showcase.description = lockups.subtitleFromData(objectGraph, contentData); // Add Video // Configure the video for the showcase, if the module demands it. let showcaseVideo = null; const videoType = mediaAttributes.attributeAsString(module, "appLockupVideo"); switch (videoType) { case "AppTrailer": { const allAppVideos = content.videoPreviewsFromData(objectGraph, contentData); if (allAppVideos && allAppVideos.length > 0) { showcaseVideo = allAppVideos[0]; } break; } default: break; } if (showcaseVideo) { metricsHelpersMedia.addMetricsEventsToVideo(objectGraph, showcaseVideo, { pageInformation: context.metricsPageInformation, locationTracker: context.metricsLocationTracker, id: context.pageId, }); showcase.video = showcaseVideo; } // Add Screenshots for AppShowcase if necessary if (showcaseHasScreenshots) { showcase.screenshots = content.screenshotsFromData(objectGraph, contentData, 14 /* content.ArtworkUseCase.ArticleScreenshots */, [content.currentAppPlatform(objectGraph)]); } // Configure background if necessary. if (shelfHasBackgroundColor) { shelf.background = { type: "color", color: appShowcaseBackgroundColor, }; } shelf.items = [showcase]; return shelf; } function createIAPLockup(objectGraph, module, context) { const contentData = contentFromModule(objectGraph, module, context); if (!contentData) { return null; } // Create the lockup const lockup = lockups.inAppPurchaseLockupFromData(objectGraph, contentData, { metricsOptions: { pageInformation: context.metricsPageInformation, locationTracker: context.metricsLocationTracker, }, clientIdentifierOverride: context.clientIdentifierOverride, artworkUseCase: 1 /* content.ArtworkUseCase.LockupIconSmall */, }); if (!lockup) { return null; } const showcase = new models.InAppPurchaseShowcase(lockup); // Create the shelf const shelf = new models.Shelf("inAppPurchaseShowcase"); shelf.background = { type: "color", color: iAPBackgroundColor, }; shelf.items = [showcase]; return shelf; } function createAppList(objectGraph, module, context) { const showOrdinals = mediaAttributes.attributeAsBooleanOrFalse(module, "showOrdinals"); const ordinalDirection = mediaAttributes.attributeAsString(module, "collectionLockupDisplayType") === "OrdinalDesc" ? "descending" : "ascending"; // Set the display style const displayStyle = mediaAttributes.attributeAsString(module, "collectionLockupSize"); context.subStyle = displayStyle; let style; if (displayStyle) { switch (displayStyle) { case "Large": { style = "largeLockup"; break; } case "Medium": { style = "mediumLockup"; break; } case "Small": default: { style = "smallLockup"; break; } } } // Construct the lockup options const lockupOptions = { metricsOptions: { pageInformation: context.metricsPageInformation, locationTracker: context.metricsLocationTracker, }, clientIdentifierOverride: context.clientIdentifierOverride, artworkUseCase: content.artworkUseCaseFromShelfStyle(objectGraph, style), canDisplayArcadeOfferButton: content.shelfContentTypeCanDisplayArcadeOfferButtons(objectGraph, style), }; // Check if we have content const contents = mediaRelationships.relationshipCollection(module, "contents"); if (isNothing(contents)) { return null; } let childLockups = []; if (preprocessor.GAMES_TARGET) { const shelfID = new PageID(context.pageId).shelfID(module.id); childLockups = gamesComponentBuilder.makeArticleGameLockups(objectGraph, contents, shelfID); } else { childLockups = lockups.lockupsFromData(objectGraph, contents, { includeOrdinals: showOrdinals, ordinalDirection: ordinalDirection, lockupOptions: lockupOptions, }); } if (!childLockups || childLockups.length === 0) { return null; } // Create the shelf const shelf = new models.Shelf(style); shelf.items = childLockups; return shelf; } function createAppMedia(objectGraph, module, context) { const contentData = contentFromModule(objectGraph, module, context); if (!contentData) { return null; } // Set the display style const mediaOption = mediaAttributes.attributeAsString(module, "appMediaOption"); const appMediaPlatform = mediaAttributes.attributeAsString(module, "appMediaPlatform"); context.subStyle = mediaOption; switch (mediaOption) { case "Screenshots": { let shelf = null; // I'm so sorry, but making this split makes the macOS client code infinitely better because we are able // to reuse the same product media view and component contract that is used on product page screenshots/trailers. // Really, iOS should be reworked such that its module & product page implementation has a single source, // but this has serious design obstacles that need to be worked through. if (objectGraph.client.isMac) { shelf = new models.Shelf("productMedia"); const productMedia = content.productMediaFromData(objectGraph, contentData, 14 /* content.ArtworkUseCase.ArticleScreenshots */); if (serverData.isDefinedNonNull(productMedia) && productMedia.length) { shelf.items = productMedia; } } else { shelf = new models.Shelf("screenshots"); if (serverData.isNull(appMediaPlatform)) { /** * The server did not tell us which app platform to use, so we need to infer based on various keys in * the response. These parameters are only fully baked into product-dv responses, so we we need to do * the more expensive product-dv lookup in order to correctly infer the default screenshots to use for * the shelf. */ const screenshots = content.screenshotsFromData(objectGraph, contentData, 14 /* content.ArtworkUseCase.ArticleScreenshots */); if (screenshots && screenshots.length > 0) { shelf.items = [screenshots[0]]; } } else { /** * Server tells us which platform to use -- dictated by `appMediaPlatform`. Selectively do a lookup for * just those screenshots. */ const desiredAppPlatform = appPlatformFromAppMediaPlatform(objectGraph, appMediaPlatform); if (desiredAppPlatform) { const screenshots = content.screenshotsFromData(objectGraph, contentData, 14 /* content.ArtworkUseCase.ArticleScreenshots */, [desiredAppPlatform]); if (screenshots && screenshots.length) { shelf.items = [screenshots[0]]; } } } } if (serverData.isDefinedNonNull(shelf) && shelf.items.length === 0) { return null; } return shelf; } case "AppTrailers": const trailersShelf = new models.Shelf("framedVideo"); const videoPreviews = content.videoPreviewsFromData(objectGraph, contentData); if (videoPreviews && videoPreviews.length > 0) { const video = videoPreviews[0]; metricsHelpersMedia.addMetricsEventsToVideo(objectGraph, video, { pageInformation: context.metricsPageInformation, locationTracker: context.metricsLocationTracker, id: context.pageId, }); const firstTrailer = new models.FramedVideo(video, false, "text/plain", null, null, true); trailersShelf.items = [firstTrailer]; return trailersShelf; } else { return null; } default: { return null; } } } function createLink(objectGraph, module, context) { if (objectGraph.client.isTV || objectGraph.client.isWatch) { return null; } const urlString = mediaAttributes.attributeAsString(module, "url"); if (!urlString) { return null; } const url = new urls.URL(urlString); const linkTitle = mediaAttributes.attributeAsString(module, "urlTitle"); let text = mediaAttributes.attributeAsString(module, "editorialCopy"); if (!text) { text = url.host; } const mediaHosts = [ "itunes.apple.com", "apps.apple.com", "music.apple.com", "books.apple.com", "podcasts.apple.com", "watch-app.cdn-apple.com", "tv.apple.com", ]; let linkPresentationEnabled = false; for (const mediaHost of mediaHosts) { if (url.host.endsWith(mediaHost)) { linkPresentationEnabled = true; } } const action = new models.ExternalUrlAction(urlString); metricsHelpersClicks.addClickEventToAction(objectGraph, action, { targetType: "link", pageInformation: context.metricsPageInformation, id: `${context.index}`, locationTracker: context.metricsLocationTracker, }); const link = new models.EditorialLink(linkTitle, text, action, linkPresentationEnabled); // Setup impressions addImpressionsFieldsToModel(objectGraph, link, context); const shelf = new models.Shelf("editorialLink"); shelf.items = [link]; return shelf; } function createTextList(objectGraph, module, context) { const listEntries = mediaAttributes.attributeAsArrayOrEmpty(module, "editorialCopy"); if (!listEntries.length) { return null; } const type = mediaAttributes.attributeAsString(module, "textListDisplayType"); context.subStyle = type; let isBulleted = false; switch (type) { case "Bulleted": { isBulleted = true; break; } default: { isBulleted = false; break; } } let text; if (isBulleted) { text = "`; } else { text = `${text}`; } const paragraph = new models.Paragraph(text, "text/x-apple-as3-nqml", "article"); // Setup impressions addImpressionsFieldsToModel(objectGraph, paragraph, context); const shelf = new models.Shelf("paragraph"); shelf.items = [paragraph]; return shelf; } function createStoryCards(objectGraph, module, context, shelfIndex) { if (objectGraph.client.isVision) { const shelfToken = createBaseShelfToken(objectGraph, undefined, module, false, shelfIndex, context.metricsPageInformation, context.metricsLocationTracker); const shelf = buildSmallStoryCardShelf(objectGraph, shelfToken); shelf.isHorizontal = true; return shelf; } const cards = mediaRelationships.relationshipCollection(module, "contents"); if (!cards) { return null; } const title = mediaAttributes.attributeAsString(module, "name"); const subtitle = mediaAttributes.attributeAsString(module, "tagline"); let shelf = null; if (objectGraph.client.isiOS && objectGraph.featureFlags.isEnabled("mini_today_cards_article")) { const todayParseContext = new TodayParseContext(context.metricsPageInformation, context.metricsLocationTracker); shelf = todayHorizontalCardUtil.shelfForMiniTodayCards(objectGraph, cards, title, subtitle, todayParseContext); } else { const isSmallStoryCardsSupported = objectGraph.host.isiOS || objectGraph.host.isMac || objectGraph.host.isWeb; const resolvedContentType = isSmallStoryCardsSupported ? "smallStoryCard" : "todayBrick"; shelf = todayHorizontalCardUtil.shelfForHorizontalCardItems(objectGraph, cards, resolvedContentType, title, subtitle, context, null); if (isSmallStoryCardsSupported) { // Only specific small story cards are supported and will crash otherwise, filter those here preemptively. // rdar://91965501 (MAS Crashing - Earth Day Landing Page - 4/19) if (Array.isArray(shelf.items)) { shelf.items = shelf.items.filter((item) => { if (!(item instanceof models.TodayCard)) { return true; } return todayHorizontalCardUtil.isHorizontalCardSupportedForKind(objectGraph, item.media.kind, resolvedContentType); }); } } } return shelf; } function createAppEventLockup(objectGraph, module, context) { const contentData = contentFromModule(objectGraph, module, context); if (!contentData) { return null; } return appPromotionsShelf.appEventsShelfForArticle(objectGraph, [contentData], context.metricsPageInformation, context.metricsLocationTracker, context); } function createOfferItemLockup(objectGraph, module, context) { if (!objectGraph.client.isiOS) { return []; } const offerItem = mediaRelationships.relationshipData(objectGraph, module, "contents"); if (serverData.isNullOrEmpty(offerItem)) { return null; } // Offer detail Paragraph const offerParagraph = mediaAttributes.attributeAsString(module, "editorialCopy"); const paragraph = new models.Paragraph(offerParagraph, "text/x-apple-as3-nqml", "article"); const paragraphShelf = new models.Shelf("paragraph"); paragraphShelf.items = [paragraph]; // Winback Offer Card const offerItemShelf = appPromotionsShelf.appEventsShelfForArticle(objectGraph, [offerItem], context.metricsPageInformation, context.metricsLocationTracker, context); return [paragraphShelf, offerItemShelf]; } /** * Ingests EI canvas modules of form: * { * id: , * type: "editorial-item-shelves", * attributes: { * displayType: "AppMarker", * appMarkerType: * } * } * to generate an shelf for AppMarker model. */ function createAppMarker(objectGraph, appMarkerModule, articleData, context) { const markerType = mediaAttributes.attributeAsString(appMarkerModule, "appMarkerType"); context.subStyle = markerType; let shelf = null; switch (markerType) { case "OSUpgrade": shelf = createOSUpgradeClientControlButton(objectGraph, appMarkerModule, context); break; case "Acquisition": shelf = createArcadeShowcase(objectGraph, appMarkerModule, articleData, context); break; default: break; } return shelf; } /** * Ingests EI canvas modules of form: * { * id: , * type: "editorial-item-shelves", * attributes: { * displayType: "AppMarker", * appMarkerType: "OSUpgrade" * } * } * to generate an shelf with an button that links to preferences updates. */ function createOSUpgradeClientControlButton(objectGraph, osUpgradeModule, context) { const deviceType = objectGraph.client.deviceType; if (deviceType !== "mac") { return null; // Early exit - Only MAS utilizes OS Upgrade Client Control Button currently. } const installUpdateUrl = links.osUpdateUrl(deviceType); if (installUpdateUrl === null) { return null; } // Action to Preferences const openUpdatesAction = new models.ExternalUrlAction(installUpdateUrl); // Action to open preferences is configured as `link` metricsHelpersClicks.addClickEventToAction(objectGraph, openUpdatesAction, { targetType: "link", pageInformation: context.metricsPageInformation, id: `${context.index}`, locationTracker: context.metricsLocationTracker, }); // Shelf model const upgradeControlText = objectGraph.loc.string("CLIENT_CONTROL_OS_UPGRADE_TITLE", "CHECK FOR UPDATE"); const upgradeControl = new models.ClientControlButton(upgradeControlText, openUpdatesAction); // Add impressions addImpressionsFieldsToModel(objectGraph, upgradeControl, context); const shelf = new models.Shelf("clientControlButton"); shelf.items = [upgradeControl]; return shelf; } /** * Ingests EI canvas modules of form: * { * id: , * type: "editorial-item-shelves", * } * with additional data: * - Upsell data on `context.additionalData` * - Icon Artwork data (iOS only) on `context.additionalData` * * to generate an shelf that promotes Arcade service. * * @param arcadeShowcaseModule Arcade showcase module * @param articleData The data backing the article containing the module. Used for top-level relationship. * @param context Parse context for this page parsing. This context contains the additional requirements data. */ function createArcadeShowcase(objectGraph, arcadeShowcaseModule, articleData, context) { const supportedOnPlatform = objectGraph.host.isiOS || objectGraph.host.isMac || objectGraph.client.isVision; if (!supportedOnPlatform) { return null; } // Default to upsell on relation, falling back to upsell that may have been fetched separately for orphaned acquisition modules. let upsellData = arcadeCommon.upsellFromRelationshipOf(objectGraph, articleData); if (!upsellData && context.additionalData) { const upsellResponse = context.additionalData.get("upsellForNonacquisitionCanvas"); upsellData = arcadeCommon.upsellFromContentsOfUpsellResponse(objectGraph, upsellResponse); } if (!serverData.isDefinedNonNull(upsellData)) { return null; } const baseMetricsOptions = { pageInformation: context.metricsPageInformation, locationTracker: context.metricsLocationTracker, }; // Flow to See All games if subscribed const subscribedAction = arcadeCommon.openArcadeMainAction(objectGraph, context.metricsPageInformation, context.metricsLocationTracker, objectGraph.client.isVision); if (preprocessor.GAMES_TARGET) { subscribedAction.title = objectGraph.loc.string("OfferButton.Arcade.Title.Explore"); } else { subscribedAction.title = objectGraph.loc.string("ARCADE_ACTION_TITLE_EXPLORE", "EXPLORE"); } // Flow to Arcade Subscribe page if unsubscribed. let unsubscribedAction; const unsubscribedActionTitle = breakoutsCommon.callToActionLabelFromData(objectGraph, upsellData.marketingItemData); if (serverData.isDefinedNonNullNonEmpty(unsubscribedActionTitle)) { // We support an inline offer here instead, when the pricing token is there. unsubscribedAction = arcadeUpsell.arcadeOfferButtonActionFromData(objectGraph, upsellData.marketingItemData, models.marketingItemContextFromString("editorialItemCanvas"), baseMetricsOptions); if (serverData.isDefinedNonNull(unsubscribedAction)) { unsubscribedAction.title = unsubscribedActionTitle; } } else { // If Upsell EI is misconfigured and missing `breakoutCallToActionLabel`, default to opening Arcade app for unsubscribed state. unsubscribedAction = arcadeCommon.openArcadeMainAction(objectGraph, context.metricsPageInformation, context.metricsLocationTracker, objectGraph.client.isVision); if (preprocessor.GAMES_TARGET) { unsubscribedAction.title = objectGraph.loc.string("OfferButton.Arcade.Title.Explore"); } else { unsubscribedAction.title = objectGraph.loc.string("ARCADE_ACTION_TITLE_EXPLORE", "EXPLORE"); } } const arcadeShowcase = new models.ArcadeShowcase(unsubscribedAction, subscribedAction); const unsubscribedDescription = arcadeUpsell.descriptionFromData(objectGraph, upsellData.marketingItemData); arcadeShowcase.unsubscribedDescription = unsubscribedDescription; const offerDisplayProperties = new models.OfferDisplayProperties("arcade", objectGraph.bag.arcadeAppAdamId, null, "colored", null, "dark", null, null, null, null, null, null, null, null, null, null, null, null, objectGraph.bag.arcadeProductFamilyId); if (preprocessor.GAMES_TARGET) { offerDisplayProperties.titles["subscribed"] = objectGraph.loc.string("OfferButton.Arcade.Title.Explore"); } else { offerDisplayProperties.titles["subscribed"] = objectGraph.loc.string("ARCADE_ACTION_TITLE_EXPLORE", "EXPLORE"); } arcadeShowcase.offerDisplayProperties = offerDisplayProperties; const showcaseMetricsOptions = { ...baseMetricsOptions, targetType: "arcadeShowcase", title: unsubscribedActionTitle, id: arcadeShowcaseModule.id, kind: "arcadeShowcase", softwareType: null, displaysArcadeUpsell: true, }; metricsHelpersImpressions.addImpressionFields(objectGraph, arcadeShowcase, showcaseMetricsOptions); // Build Artwork for iOS only if (objectGraph.host.isiOS || objectGraph.client.isVision) { // Context should have additional data to source icons. if (serverData.isNull(context.additionalData)) { return null; } const iconResponse = context.additionalData.get("arcadeIcons"); if (serverData.isDefinedNonNullNonEmpty(iconResponse)) { const iconMetricsOptions = { pageInformation: context.metricsPageInformation, locationTracker: context.metricsLocationTracker, }; const iconsDataCollection = mediaDataStructure.dataCollectionFromResultsListContainer(iconResponse); arcadeShowcase.iconArtworks = content.impressionableAppIconsFromDataCollection(objectGraph, iconsDataCollection, iconMetricsOptions, { useCase: 2 /* content.ArtworkUseCase.LockupIconMedium */, }); } } const shelf = new models.Shelf("arcadeShowcase"); shelf.items = [arcadeShowcase]; const shelfHasBackgroundColor = objectGraph.host.isiOS || objectGraph.client.isVision; if (shelfHasBackgroundColor) { shelf.background = { type: "color", color: arcadeShowcaseShelfBackgroundColor, }; } return shelf; } // endregion function contentFromModule(objectGraph, module, context) { const contents = mediaRelationships.relationshipData(objectGraph, module, "contents"); if (!contents) { return null; } return contents; } function addImpressionsFieldsToModel(objectGraph, model, context, impressionData) { if (!model) { return; } let impressionType = context.module; if (context.subStyle) { impressionType = impressionType + "_" + context.subStyle; } if (serverData.isNull(impressionData)) { impressionData = { id: `${context.index}`, impressionIndex: context.index, idType: "sequential", impressionType: impressionType, kind: "iosModule", }; } model.impressionMetrics = new models.ImpressionMetrics(metricsHelpersUtil.sanitizedMetricsDictionary(impressionData)); } //# sourceMappingURL=article.js.map