this repo has no description
at main 1572 lines 72 kB view raw
1/** 2 * Created by keithpk on 3/21/17. 3 */ 4import { isNothing, isSome } from "@jet/environment"; 5import * as validation from "@jet/environment/json/validation"; 6import * as models from "../../api/models"; 7import * as serverData from "../../foundation/json-parsing/server-data"; 8import * as mediaAttributes from "../../foundation/media/attributes"; 9import * as mediaAugment from "../../foundation/media/augment"; 10import * as mediaDataStructure from "../../foundation/media/data-structure"; 11import * as mediaNetwork from "../../foundation/media/network"; 12import * as mediaRelationships from "../../foundation/media/relationships"; 13import * as urls from "../../foundation/network/urls"; 14import * as color from "../../foundation/util/color-util"; 15import { PageID } from "../../gameservicesui/src/common/id-builder"; 16import * as gamesComponentBuilder from "../../gameservicesui/src/editorial-page/editorial-component-builder"; 17import * as appPromotionsShelf from "../app-promotions/app-promotions-shelf"; 18import * as arcadeCommon from "../arcade/arcade-common"; 19import * as arcadeUpsell from "../arcade/arcade-upsell"; 20import * as breakoutsCommon from "../arcade/breakouts-common"; 21import * as videoDefaults from "../constants/video-constants"; 22import * as artworkBuilder from "../content/artwork/artwork"; 23import * as content from "../content/content"; 24import { EditorialMediaPlacement } from "../editorial-pages/editorial-media-util"; 25import { buildSmallStoryCardShelf } from "../editorial-pages/editorial-page-shelf-builder/editorial-page-collection-shelf-builder/editorial-page-story-card-collection-shelf-builder"; 26import { buildStoryCard } from "../editorial-pages/editorial-page-shelf-builder/editorial-page-collection-shelf-builder/editorial-page-story-card-utils"; 27import { createBaseShelfToken } from "../editorial-pages/editorial-page-shelf-token"; 28import { CollectionShelfDisplayStyle } from "../editorial-pages/editorial-page-types"; 29import * as externalDeepLink from "../linking/external-deep-link"; 30import * as links from "../linking/os-update-links"; 31import * as lockups from "../lockups/lockups"; 32import * as metricsHelpersClicks from "../metrics/helpers/clicks"; 33import * as metricsHelpersImpressions from "../metrics/helpers/impressions"; 34import * as metricsHelpersLocation from "../metrics/helpers/location"; 35import * as metricsHelpersMedia from "../metrics/helpers/media"; 36import * as metricsHelpersPage from "../metrics/helpers/page"; 37import * as metricsHelpersUtil from "../metrics/helpers/util"; 38import * as sharing from "../sharing"; 39import { crossLinkSubtitleFromData, defaultTodayCardConfiguration, fallbackWatchTodayCardFromData, todayCardFromData, } from "./today-card-util"; 40import * as todayHorizontalCardUtil from "./today-horizontal-card-util"; 41import { todayCardPreviewUrlForTodayCard } from "./today-parse-util"; 42import { HeroMediaDisplayContext, TodayCardDisplayStyle, TodayParseContext, } from "./today-types"; 43export const iAPBackgroundColor = color.named("componentBackgroundStandout"); 44const appShowcaseBackgroundColor = color.named("componentBackgroundStandout"); 45const arcadeShowcaseShelfBackgroundColor = color.named("componentBackgroundStandout"); 46/** 47 * Resolves the article module's app media platform to an `AppPlatform` to use for screenshots. 48 * @param {AppMediaPlatform} appMediaPlatform The server-dictated media platform to use for the module. 49 * @returns {AppPlatform} The app platform that is appropriate for this media platform, taking into account our device. 50 */ 51function appPlatformFromAppMediaPlatform(objectGraph, appMediaPlatform) { 52 switch (appMediaPlatform) { 53 case "Watch": 54 return "watch"; 55 case "iOS": 56 if (objectGraph.client.isPad) { 57 return "pad"; 58 } 59 else { 60 return "phone"; 61 } 62 case "tvOS": 63 return "tv"; 64 case "Messages": 65 return "messages"; 66 case "visionOS": 67 return "vision"; 68 default: 69 return null; 70 } 71} 72export class ArticleParseContext { 73 constructor() { 74 // The index of the current module 75 this.index = 0; 76 // The reco metrics from the shelf on the today page 77 this.todayShelfRecoMetricsData = {}; 78 /// Whether there are any focusable elements (for touch mode) 79 this.hasFocusableElements = false; 80 /// Whether there are any non-focusable elements (for touch mode) 81 this.hasNonFocusableElements = false; 82 /// Whether there is a resilient deep link. 83 this.isResilientDeepLink = false; 84 /// Whether or not to allow app event previews, used by editorial to preview app event stories before they are published 85 this.allowUnpublishedAppEventPreviews = false; 86 } 87} 88function todayCardConfigFromArticleContext(objectGraph, articleContext) { 89 if (!serverData.isDefinedNonNull(articleContext)) { 90 return null; 91 } 92 if (isSome(articleContext.todayCardConfig)) { 93 return articleContext.todayCardConfig; 94 } 95 const config = defaultTodayCardConfiguration(objectGraph); 96 config.enableListCardToMultiAppFallback = false; 97 config.clientIdentifierOverride = articleContext.clientIdentifierOverride; 98 config.useOTDTextStyle = false; 99 config.allowUnpublishedAppEventPreviews = articleContext.allowUnpublishedAppEventPreviews; 100 config.currentRowIndex = undefined; 101 switch (objectGraph.client.deviceType) { 102 case "mac": 103 config.prevailingCropCodes = { defaultCrop: "en" }; 104 config.coercedCollectionTodayCardDisplayStyle = TodayCardDisplayStyle.Grid; 105 config.heroDisplayContext = HeroMediaDisplayContext.Article; 106 break; 107 case "tv": 108 config.prevailingCropCodes = { 109 "defaultCrop": "ek", 110 "editorialArtwork.storyCenteredStatic16x9": "SCS.ApDHXL01", 111 }; 112 config.coercedCollectionTodayCardDisplayStyle = TodayCardDisplayStyle.Grid; 113 config.heroDisplayContext = HeroMediaDisplayContext.Article; 114 break; 115 case "web": 116 config.coercedCollectionTodayCardDisplayStyle = TodayCardDisplayStyle.River; 117 config.prevailingCropCodes = { 118 "defaultCrop": "sr", 119 "editorialArtwork.dayCard": "grav.west", 120 }; 121 break; 122 default: 123 break; 124 } 125 return config; 126} 127export function articlePageFromResponse(objectGraph, articleResponse, context) { 128 return validation.context("articlePageWithResponse", () => { 129 var _a; 130 const articleData = mediaDataStructure.dataFromDataContainer(objectGraph, articleResponse); 131 context.metricsPageInformation = metricsHelpersPage.metricsPageInformationFromMediaApiResponse(objectGraph, "editorialItem", articleData.id, articleResponse); 132 context.metricsLocationTracker = metricsHelpersLocation.newLocationTracker(); 133 context.pageId = articleData.id; 134 // Bridge over article contexts to today's metrics context and card config 135 const todayParseContext = new TodayParseContext(context.metricsPageInformation, context.metricsLocationTracker, context.refreshController); 136 const todayCardConfig = todayCardConfigFromArticleContext(objectGraph, context); 137 // Render the top card 138 let todayCard = todayCardFromData(objectGraph, articleData, todayCardConfig, todayParseContext); 139 let editorialStoryCard = null; 140 const todayCardMedia = todayCard === null || todayCard === void 0 ? void 0 : todayCard.media; 141 if (objectGraph.client.isVision || preprocessor.GAMES_TARGET) { 142 editorialStoryCard = buildStoryCard(objectGraph, articleData, EditorialMediaPlacement.StoryDetail, { 143 pageInformation: context.metricsPageInformation, 144 locationTracker: context.metricsLocationTracker, 145 }, CollectionShelfDisplayStyle.StoryMedium, false); 146 todayCard = null; 147 } 148 if (isNothing(todayCard)) { 149 todayCard = fallbackWatchTodayCardFromData(objectGraph, articleData, todayCardConfig, todayParseContext); 150 } 151 // Get the title for metrics purposes. 152 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; 153 const editorialItemKind = mediaAttributes.attributeAsString(articleData, "kind"); 154 // Configure subtitle for cross link 155 context.crossLinkSubtitle = crossLinkSubtitleFromData(objectGraph, articleData); 156 // Bridge today config back into articles, now that cards are created. 157 // Now we've created the card, reference the clientIdentifierOverride it used for the rest of the article. 158 context.clientIdentifierOverride = todayCardConfig.clientIdentifierOverride; 159 // Start a metrics location 160 metricsHelpersLocation.pushContentLocation(objectGraph, { 161 pageInformation: context.metricsPageInformation, 162 locationTracker: context.metricsLocationTracker, 163 targetType: "article", 164 id: context.pageId, 165 idType: "its_id", 166 }, title); 167 // Render the article itself 168 const shelves = renderArticle(objectGraph, articleData, todayCardMedia, context); 169 const lastShelf = shelves[shelves.length - 1]; 170 // Sharing 171 const shareAction = objectGraph.client.isTV || 172 objectGraph.client.isWeb || 173 context.isResilientDeepLink || 174 preprocessor.GAMES_TARGET || 175 editorialItemKind === "OfferItem" 176 ? null 177 : shareSheetActionFromData(objectGraph, articleData, todayCardConfig); 178 if (serverData.isDefinedNonNull(shareAction)) { 179 // Add click event 180 metricsHelpersClicks.addClickEventToAction(objectGraph, shareAction, { 181 targetType: "button", 182 id: context.pageId, 183 actionType: "share", 184 pageInformation: context.metricsPageInformation, 185 locationTracker: context.metricsLocationTracker, 186 }); 187 const isLastModuleFullWidth = isArticleShelfFullWidth(objectGraph, lastShelf, context.module); 188 const shareButtonShelf = createShareShelf(objectGraph, shareAction, context, isLastModuleFullWidth); 189 if (shareButtonShelf) { 190 shelves.push(shareButtonShelf); 191 } 192 } 193 const page = new models.ArticlePage(todayCard, shelves, shareAction); 194 page.editorialStoryCard = editorialStoryCard; 195 page.title = todayCard === null || todayCard === void 0 ? void 0 : todayCard.title; 196 page.subtitle = todayCard === null || todayCard === void 0 ? void 0 : todayCard.inlineDescription; 197 addFooterLockupForPageIfNeeded(objectGraph, page, articleData, context); 198 if (objectGraph.client.isTV) { 199 if (context.hasFocusableElements && !context.hasNonFocusableElements) { 200 page.touchMode = "focus"; 201 } 202 else if (!context.hasFocusableElements && context.hasNonFocusableElements) { 203 page.touchMode = "pan"; 204 } 205 else { 206 page.touchMode = "auto"; 207 } 208 } 209 // Map whether the article should terminate on close. 210 page.shouldTerminateOnClose = context.isResilientDeepLink; 211 metricsHelpersPage.addMetricsEventsToPageWithInformation(objectGraph, page, context.metricsPageInformation, (fields) => { 212 let additionalValue = title; 213 if ((todayCard === null || todayCard === void 0 ? void 0 : todayCard.media) instanceof models.TodayCardMediaBrandedSingleApp && 214 (todayCard === null || todayCard === void 0 ? void 0 : todayCard.overlay) instanceof models.TodayCardLockupOverlay) { 215 const lockupOverlay = todayCard === null || todayCard === void 0 ? void 0 : todayCard.overlay; 216 additionalValue = lockupOverlay.lockup.title; 217 } 218 if (!additionalValue) { 219 return; 220 } 221 let pageDetails = serverData.asString(serverData.asJSONValue(fields["pageDetails"]), "coercible"); 222 pageDetails = pageDetails || serverData.asString(serverData.asJSONValue(fields["pageId"])); 223 if (pageDetails) { 224 fields["pageDetails"] = `${pageDetails}_${additionalValue}`; 225 } 226 else { 227 fields["pageDetails"] = `unknown_${additionalValue}`; 228 } 229 }); 230 page.canonicalURL = mediaAttributes.attributeAsString(articleData, "url"); 231 if (isSome(articleData)) { 232 const articleUrl = mediaAttributes.attributeAsString(articleData, "url"); 233 if (isSome(articleUrl)) { 234 page.viewArticleAction = new models.ExternalUrlAction(articleUrl, true); 235 } 236 } 237 return page; 238 }); 239} 240function renderArticle(objectGraph, articleData, cardMedia, context) { 241 return validation.context("renderArticle", () => { 242 var _a; 243 const shelves = []; 244 const canvas = (_a = mediaRelationships.relationshipCollection(articleData, "canvas")) !== null && _a !== void 0 ? _a : []; 245 for (const storyModule of canvas) { 246 context.module = mediaAttributes.attributeAsString(storyModule, "displayType"); 247 context.subStyle = null; 248 const shelfIndex = shelves.length; 249 const shelvesToRender = renderModule(objectGraph, storyModule, articleData, context, shelfIndex); 250 if (shelvesToRender.length > 0) { 251 for (const shelf of shelvesToRender) { 252 shelf.title = context.titleForNextShelf; 253 if (objectGraph.client.isTV) { 254 // Skip unsupported tvOS shelves 255 if (shelf.contentType === "editorialLink") { 256 continue; 257 } 258 } 259 else if (objectGraph.client.isWatch) { 260 // Skip unsupported watchOS shelves 261 if (shelf.contentType === "editorialLink") { 262 continue; 263 } 264 } 265 shelves.push(shelf); 266 context.titleForNextShelf = null; 267 } 268 } 269 context.index++; 270 metricsHelpersLocation.nextPosition(context.metricsLocationTracker); 271 } 272 // If we we're showing the fallback list card type on the today page, we're going to show 273 // the lockups as a list shelf underneath so we can still display the list contents. 274 // If we're on watchOS, we also want to hit this codepath so that lockup lists do not 275 // show as empty pages. 276 if ((context.showingFallbackMediaInline || 277 objectGraph.client.isWatch || 278 objectGraph.client.isVision || 279 objectGraph.client.isWeb || 280 preprocessor.GAMES_TARGET) && 281 shelves.length === 0) { 282 const fallbackShelf = createFallbackListShelf(objectGraph, cardMedia); 283 if (serverData.isDefinedNonNull(fallbackShelf)) { 284 shelves.push(fallbackShelf); 285 } 286 } 287 return shelves; 288 }); 289} 290// region Data Augmenting 291/** 292 * Article specific entrypoint for page response augmenting. See `augment.ts`. 293 * @param response Response to augment. 294 */ 295export async function fetchAdditionalDataForInitialResponse(objectGraph, response) { 296 return await mediaAugment.fetchAugmentedData(objectGraph, response, findAdditionalDataKeysForArticleResponse, fetchDataForArticleDataKey); 297} 298/** 299 * Determine the set of data, expressed as an set of `ArticleAdditionalDataKey`s, that need to be fetched for given article response to be displayed. 300 * This is equivalent to `AbstractMediaApiPageBuilder.additionalDataKeysNeededForData`, but for article builder which doesn't adopt the `builder` API. 301 * 302 * @param articleResponse Initial response to determine additional data requirements for. 303 * @returns {Set<ArticleAdditionalDataKey>} Additional data needed expressed as set of `ArticleAdditionalDataKey` 304 */ 305function findAdditionalDataKeysForArticleResponse(objectGraph, articleResponse) { 306 /** 307 * Keys for requested requirements determined by: 308 * - Modules in canvas only now :) 309 */ 310 const allAdditionalDataKeySet = new Set(); 311 // Requirements based on canvas items: 312 const articleData = mediaDataStructure.dataFromDataContainer(objectGraph, articleResponse); 313 const canvasModules = mediaRelationships.relationshipCollection(articleData, "canvas"); 314 for (const storyModule of canvasModules) { 315 // Determine additional requests and add to `allRequirementsSet` 316 const dataKeysForModule = additionalDataKeysForArticleModule(objectGraph, storyModule, articleData); 317 if (serverData.isDefinedNonNullNonEmpty(dataKeysForModule)) { 318 for (const requirement of dataKeysForModule) { 319 allAdditionalDataKeySet.add(requirement); 320 } 321 } 322 } 323 return allAdditionalDataKeySet; 324} 325/** 326 * Builds a promise that will fetch data fulfilling given requirement. Note that these promises will return `null` when they fail, 327 * and their failure should not cause the entire page to fail. 328 * This is equivalent to `AbstractMediaApiPageBuilder.fetchAdditionalDataForKey`, but for article builder which doesn't adopt the `builder` API. 329 * 330 * @param dataKey Corresponding data key to fetch data for. 331 */ 332// eslint-disable-next-line @typescript-eslint/promise-function-async 333function fetchDataForArticleDataKey(objectGraph, dataKey) { 334 let request; 335 if (dataKey === "upsellForNonacquisitionCanvas") { 336 // Use `editorialItem` matching context of that would've otherwise been joined if this story was an acquisition story. 337 request = arcadeCommon.arcadeUpsellRequest(objectGraph, models.marketingItemContextFromString("editorialItemCanvas")); 338 } 339 if (dataKey === "arcadeIcons") { 340 // Require 10 for now. 341 request = arcadeCommon.arcadeAppsRequestForIcons(objectGraph, 10); 342 } 343 if (serverData.isNull(request)) { 344 return null; 345 } 346 // Failable data fetch, either resolving to valid response or `null`. 347 return mediaNetwork.fetchData(objectGraph, request).catch(() => null); 348} 349/** 350 * Determine the requirements for single article module as determined by it's type. 351 * @param storyModule The module to fetch additional requirements for. 352 * @param articleData The article that contains `storyModule` in its canvas. 353 * @returns {ArticleAdditionalDataKey[] | undefined} Set of data keys if any are needed for rendering given module. 354 */ 355export function additionalDataKeysForArticleModule(objectGraph, storyModule, articleData) { 356 // Only `AppMarker` has additional requirements. 357 const moduleType = mediaAttributes.attributeAsString(storyModule, "displayType"); 358 if (moduleType !== "AppMarker") { 359 return null; 360 } 361 const markerType = mediaAttributes.attributeAsString(storyModule, "appMarkerType"); 362 // <rdar://problem/55919205> In story Arcade acquisition module dropping from stories 363 // 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. 364 // When an article is missing the upsell relationship, we'll fetch it separately if we have modules that need it... 365 const articleDataIsMissingUpsell = serverData.isNull(arcadeCommon.upsellFromRelationshipOf(objectGraph, articleData)); 366 /** 367 * Acquisition AppMarker, i.e. `ArcadeShowcase` needs: 368 * 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. 369 * 2. Assortment of Arcade App Icons (iOS Only). 370 */ 371 const additionalDataKeysForModule = []; 372 if (markerType === "Acquisition") { 373 // iOS needs icon dependency 374 if (objectGraph.host.isiOS || objectGraph.client.isVision) { 375 additionalDataKeysForModule.push("arcadeIcons"); 376 } 377 // All platform needs upsell to render acquisition modules, add it if missing. 378 if (articleDataIsMissingUpsell) { 379 additionalDataKeysForModule.push("upsellForNonacquisitionCanvas"); 380 } 381 } 382 return additionalDataKeysForModule; 383} 384// endregion 385/** 386 * Create a shelf model representing a single module within article pages. 387 * @param storyModule Module server data to build shelf and contents from. 388 * @param articleData The data for article that contains `storyModule` above. 389 * @param context Global parse context updated while entire sets of modules are being parsed. 390 * @returns an array of `Shelf` or `null` if building fails for given module. 391 */ 392function renderModule(objectGraph, storyModule, articleData, context, shelfIndex) { 393 return validation.catchingContext(`module: ${context.module}`, () => { 394 var _a; 395 const shelves = []; 396 switch (context.module) { 397 case "Header": { 398 context.titleForNextShelf = mediaAttributes.attributeAsString(storyModule, "editorialCopy"); 399 break; 400 } 401 case "TextBlock": { 402 const textBlockShelf = createParagraph(objectGraph, storyModule, context); 403 if (isSome(textBlockShelf)) { 404 shelves.push(textBlockShelf); 405 context.hasNonFocusableElements = true; 406 } 407 break; 408 } 409 case "CollectionLockup": { 410 const appListShelf = createAppList(objectGraph, storyModule, context); 411 if (isSome(appListShelf)) { 412 shelves.push(appListShelf); 413 context.hasFocusableElements = true; 414 } 415 break; 416 } 417 case "InlineImage": { 418 const inlineImageShelf = createImage(objectGraph, storyModule, context); 419 if (isSome(inlineImageShelf)) { 420 shelves.push(inlineImageShelf); 421 context.hasNonFocusableElements = true; 422 } 423 break; 424 } 425 case "AppLockup": { 426 const appLockupShelf = createAppLockup(objectGraph, storyModule, context); 427 if (isSome(appLockupShelf)) { 428 shelves.push(appLockupShelf); 429 context.hasFocusableElements = true; 430 } 431 break; 432 } 433 case "TipBlock": { 434 const tipShelf = createTip(objectGraph, storyModule, context); 435 if (isSome(tipShelf)) { 436 shelves.push(tipShelf); 437 context.hasNonFocusableElements = true; 438 } 439 break; 440 } 441 case "PullQuote": { 442 const pullQuoteShelf = createPullQuote(objectGraph, storyModule, context); 443 if (isSome(pullQuoteShelf)) { 444 shelves.push(pullQuoteShelf); 445 context.hasNonFocusableElements = true; 446 } 447 break; 448 } 449 case "HorizontalRule": { 450 const horizontalRuleShelf = createHorizontalRule(objectGraph, storyModule, context); 451 if (isSome(horizontalRuleShelf)) { 452 shelves.push(horizontalRuleShelf); 453 context.hasNonFocusableElements = true; 454 } 455 break; 456 } 457 case "InlineVideo": { 458 const inlineVideoShelf = createVideo(objectGraph, storyModule, context); 459 if (isSome(inlineVideoShelf)) { 460 shelves.push(inlineVideoShelf); 461 context.hasFocusableElements = true; 462 } 463 break; 464 } 465 case "AppMedia": { 466 const appMediaShelf = createAppMedia(objectGraph, storyModule, context); 467 if (isSome(appMediaShelf)) { 468 shelves.push(appMediaShelf); 469 context.hasFocusableElements = true; 470 } 471 break; 472 } 473 case "LinkBlock": { 474 const linkBlockShelf = createLink(objectGraph, storyModule, context); 475 if (isSome(linkBlockShelf)) { 476 shelves.push(linkBlockShelf); 477 context.hasFocusableElements = true; 478 } 479 break; 480 } 481 case "TextList": { 482 const textListShelf = createTextList(objectGraph, storyModule, context); 483 if (isSome(textListShelf)) { 484 shelves.push(textListShelf); 485 context.hasNonFocusableElements = true; 486 } 487 break; 488 } 489 case "IAPLockup": { 490 const iapLockupShelf = createIAPLockup(objectGraph, storyModule, context); 491 if (isSome(iapLockupShelf)) { 492 shelves.push(iapLockupShelf); 493 context.hasFocusableElements = true; 494 } 495 break; 496 } 497 case "AppMarker": { 498 const appMarkerShelf = createAppMarker(objectGraph, storyModule, articleData, context); 499 if (isSome(appMarkerShelf)) { 500 shelves.push(appMarkerShelf); 501 context.hasFocusableElements = true; 502 } 503 break; 504 } 505 case "StoryList": { 506 const storyListShelf = createStoryCards(objectGraph, storyModule, context, shelfIndex); 507 if (isSome(storyListShelf)) { 508 shelves.push(storyListShelf); 509 context.hasFocusableElements = true; 510 } 511 break; 512 } 513 case "AppEventLockup": { 514 const appEventShelf = createAppEventLockup(objectGraph, storyModule, context); 515 if (isSome(appEventShelf)) { 516 shelves.push(appEventShelf); 517 context.hasFocusableElements = true; 518 } 519 break; 520 } 521 case "OfferItemLockup": { 522 const offerItemShelves = createOfferItemLockup(objectGraph, storyModule, context); 523 if (isSome(offerItemShelves)) { 524 shelves.push(...offerItemShelves); 525 context.hasFocusableElements = true; 526 } 527 break; 528 } 529 default: { 530 objectGraph.console.log(`Unknown module: ${context.module}`); 531 } 532 } 533 for (const shelf of shelves) { 534 const existingShelfPresentationHints = (_a = shelf.presentationHints) !== null && _a !== void 0 ? _a : {}; 535 shelf.presentationHints = { 536 ...existingShelfPresentationHints, 537 isArticleContext: true, 538 }; 539 } 540 return shelves; 541 }); 542} 543const FULL_WIDTH_MODULES = ["AppLockup", "InlineImage", "InlineVideo", "AppMarker"]; 544/** 545 * Determines whether the provided parameters signifies a full-width article 546 * module. 547 * @param shelf The shelf in question. 548 * @param type The type of article module. 549 * @returns Whether or not the given shelf for the article type is full width. 550 */ 551function isArticleShelfFullWidth(objectGraph, shelf, type) { 552 if (shelf && type) { 553 const itemCount = shelf.items.length; 554 if (itemCount > 0 && FULL_WIDTH_MODULES.indexOf(type) !== -1) { 555 const lastItem = shelf.items[itemCount - 1]; 556 switch (shelf.contentType) { 557 case "framedArtwork": { 558 const framedArt = lastItem; 559 return framedArt && framedArt.isFullWidth; 560 } 561 case "framedVideo": { 562 const framedVideo = lastItem; 563 return framedVideo && framedVideo.isFullWidth; 564 } 565 default: { 566 return true; 567 } 568 } 569 } 570 } 571 return false; 572} 573// region Footer Lockup 574/** 575 * Adds either a `footerLockup` or `arcadeFooterLockup` property on `ArticlePage` model, based on type of article. 576 * @param page Page to add footer to if needed. 577 * @param articleData Original data of article being rendered. 578 * @param context Parse context for article builder. 579 */ 580function addFooterLockupForPageIfNeeded(objectGraph, page, articleData, context) { 581 // App Lockup for Articles about single specific app. 582 const footerProductData = productDataFromArticle(objectGraph, articleData); 583 if (footerProductData) { 584 const externalDeepLinkUrl = externalDeepLink.deepLinkUrlFromData(objectGraph, articleData); 585 page.footerLockup = productFooterLockupFromData(objectGraph, footerProductData, context, externalDeepLinkUrl); 586 return; 587 } 588 // Arcade Lockup for Acquisition Story for supported platforms 589 const isArcadeAcquisitionEI = mediaAttributes.attributeAsBooleanOrFalse(articleData, "isAcquisition"); 590 const platformSupportsArcadeFooterLockup = objectGraph.host.isiOS || objectGraph.host.isMac; 591 const additionalDataIsAvailable = serverData.isDefinedNonNull(context.additionalData); 592 if (additionalDataIsAvailable && isArcadeAcquisitionEI && platformSupportsArcadeFooterLockup) { 593 const upsellData = arcadeCommon.upsellFromRelationshipOf(objectGraph, articleData); 594 page.arcadeFooterLockup = arcadeFooterLockupFromData(objectGraph, upsellData, context); 595 } 596} 597/** 598 * Find platform data from editorial item to enhance sharing and display in footer lockup 599 * At the moment, only single app editorials get footer lockups and have enhanced sharing. 600 * 601 * @param editorialItem Item to find footer content for 602 * @returns content to display in footer lockup, or null if no content should be displayed 603 */ 604export function productDataFromArticle(objectGraph, editorialItem) { 605 const relatedContent = mediaRelationships.relationshipCollection(editorialItem, "card-contents"); 606 if (relatedContent.length !== 1) { 607 return null; 608 } 609 const contentData = relatedContent[0]; 610 if (!contentData) { 611 return null; 612 } 613 switch (contentData.type) { 614 case "apps": 615 case "app-bundles": 616 return contentData; 617 default: 618 return null; 619 } 620} 621/** 622 * Creates a footer lockup with a data for a specific app. 623 * Cover method over `lockupFromData` to override `offerStyle`. 624 * 625 * @param data MAPI data to build footer with. 626 * @param context Parse context 627 * @param externalDeepLinkUrl promotional deep link url to use on the lockup's offer. 628 * @returns A new `Lockup` object for footer lockups. 629 */ 630function productFooterLockupFromData(objectGraph, data, context, externalDeepLinkUrl) { 631 const lockupOptions = { 632 offerStyle: footerLockupOfferStyle(objectGraph), 633 metricsOptions: { 634 pageInformation: context.metricsPageInformation, 635 locationTracker: context.metricsLocationTracker, 636 }, 637 clientIdentifierOverride: context.clientIdentifierOverride, 638 externalDeepLinkUrl: externalDeepLinkUrl, 639 crossLinkSubtitle: context.crossLinkSubtitle, 640 artworkUseCase: 0 /* content.ArtworkUseCase.Default */, 641 canDisplayArcadeOfferButton: content.shelfContentTypeCanDisplayArcadeOfferButtons(objectGraph, "smallLockup"), 642 }; 643 return lockups.lockupFromData(objectGraph, data, lockupOptions); 644} 645/** 646 * Creates a footer lockup representing the Arcade subscription service. 647 * @param upsellData Contains both editorial and iAP data for Arcade 648 * @param context Parse context. 649 */ 650function arcadeFooterLockupFromData(objectGraph, upsellData, context) { 651 const metricsOptions = { 652 pageInformation: context.metricsPageInformation, 653 locationTracker: context.metricsLocationTracker, 654 }; 655 return lockups.arcadeLockupFromData(objectGraph, upsellData, metricsOptions, models.marketingItemContextFromString("editorialItem"), "infer", null); 656} 657/** 658 * Determines the offer style to use for the footer lockup. 659 */ 660function footerLockupOfferStyle(objectGraph) { 661 switch (objectGraph.client.deviceType) { 662 case "mac": 663 return "white"; 664 default: 665 return "infer"; 666 } 667} 668// endregion 669function createFallbackListShelf(objectGraph, cardMedia) { 670 if (cardMedia instanceof models.TodayCardMediaList || cardMedia instanceof models.TodayCardMediaRiver) { 671 const fallbackShelf = new models.Shelf("smallLockup"); 672 fallbackShelf.items = cardMedia.lockups; 673 if (objectGraph.client.isWeb) { 674 fallbackShelf.presentationHints = { 675 ...fallbackShelf.presentationHints, 676 isArticleContext: true, 677 }; 678 } 679 return fallbackShelf; 680 } 681 return null; 682} 683function shareSheetActionFromData(objectGraph, editorialItem, todayCardConfig) { 684 const productData = productDataFromArticle(objectGraph, editorialItem); 685 /* 686 * Determine title 687 */ 688 let title = null; 689 const name = content.notesFromData(objectGraph, editorialItem, "name"); 690 const short = content.notesFromData(objectGraph, editorialItem, "short"); 691 // Prefer "name: short" 692 if (name && short) { 693 title = objectGraph.loc 694 .string("ShareSheet.TitleSubtitle.Format", "{title}: {subtitle}") 695 .replace("{title}", name) 696 .replace("{subtitle}", short); 697 } 698 // Followed by name 699 if (!title && name) { 700 title = name; 701 } 702 // Followed by short 703 if (!title && short) { 704 title = short; 705 } 706 // Followed by product name 707 if (!title && productData) { 708 const productTitle = mediaAttributes.attributeAsString(productData, "name"); 709 const cardDisplayStyle = mediaAttributes.attributeAsString(editorialItem, "cardDisplayStyle"); 710 switch (cardDisplayStyle) { 711 case TodayCardDisplayStyle.GameOfTheDay: { 712 title = objectGraph.loc.string("SHARE_SHEET_GAME_OF_DAY_TITLE_FORMAT").replace("{title}", productTitle); 713 break; 714 } 715 case TodayCardDisplayStyle.AppOfTheDay: { 716 title = objectGraph.loc.string("SHARE_SHEET_APP_OF_DAY_TITLE_FORMAT").replace("{title}", productTitle); 717 break; 718 } 719 default: { 720 objectGraph.console.log(`No title for article with unknown style: ${cardDisplayStyle}`); 721 break; 722 } 723 } 724 } 725 const url = mediaAttributes.attributeAsString(editorialItem, "url"); 726 let articleArtwork; 727 const cardDisplayStyle = mediaAttributes.attributeAsString(editorialItem, "cardDisplayStyle"); 728 switch (cardDisplayStyle) { 729 case TodayCardDisplayStyle.Grid: 730 case TodayCardDisplayStyle.List: 731 case TodayCardDisplayStyle.River: 732 articleArtwork = artworkBuilder.createArtworkForResource(objectGraph, "resource://ShareCollectionThumbnail", 40, 40); 733 break; 734 default: 735 articleArtwork = null; 736 break; 737 } 738 // Create share sheet model (bail out if unable to do so) 739 const shareData = sharing.shareSheetDataForArticle(objectGraph, title, url, null, articleArtwork, editorialItem); 740 if (!serverData.isDefinedNonNull(shareData)) { 741 return null; 742 } 743 const activities = sharing.shareSheetActivitiesForArticle(objectGraph, url, todayCardPreviewUrlForTodayCard(objectGraph, editorialItem.id, todayCardConfig), editorialItem.id); 744 return new models.ShareSheetAction(shareData, activities); 745} 746function createShareShelf(objectGraph, shareAction, context, isLastModuleFullWidth) { 747 if (!serverData.isDefinedNonNull(shareAction) || 748 objectGraph.client.isVision || 749 preprocessor.GAMES_TARGET || 750 objectGraph.client.isCompanionVisionApp) { 751 return null; 752 } 753 // Create share button 754 const shareButton = new models.RoundedButton("share", objectGraph.loc.string("SHARE_STORY"), !isLastModuleFullWidth, shareAction); 755 // Add share shelf 756 const shareButtonShelf = new models.Shelf("roundedButton"); 757 shareButtonShelf.items = [shareButton]; 758 return shareButtonShelf; 759} 760function createParagraph(objectGraph, module, context) { 761 const text = mediaAttributes.attributeAsString(module, "editorialCopy"); 762 if (!text) { 763 return null; 764 } 765 const paragraph = new models.Paragraph(text, "text/x-apple-as3-nqml", "article"); 766 // Setup impressions 767 addImpressionsFieldsToModel(objectGraph, paragraph, context); 768 const shelf = new models.Shelf("paragraph"); 769 shelf.items = [paragraph]; 770 return shelf; 771} 772function createImage(objectGraph, module, context) { 773 const displayStyle = mediaAttributes.attributeAsString(module, "inlineImageDisplayType"); 774 const artworkData = mediaAttributes.attributeAsDictionary(module, "artwork"); 775 // If the displayStyle is FullWidth want to 'allowTransparency' so that images blend into the page in both 776 // light and dark mode. Previously editorial would bake white backgrounds into images they wanted to 'blend' 777 // with the page 778 const artwork = content.artworkFromApiArtwork(objectGraph, artworkData, { 779 useCase: 13 /* content.ArtworkUseCase.ArticleImage */, 780 allowingTransparency: displayStyle === "FullWidth" && !objectGraph.client.isVision, 781 withJoeColorPlaceholder: objectGraph.client.isVision, 782 }); 783 if (!artwork) { 784 return null; 785 } 786 const frame = new models.FramedArtwork(artwork, false, "text/x-apple-as3-nqml"); 787 // Get the optional caption 788 frame.caption = mediaAttributes.attributeAsString(module, "editorialCopy"); 789 context.subStyle = displayStyle; 790 if (displayStyle) { 791 switch (displayStyle) { 792 case "BoundingBox": { 793 frame.isFullWidth = false; 794 frame.hasRoundedCorners = true; 795 break; 796 } 797 case "FullWidth": 798 default: { 799 frame.isFullWidth = true; 800 frame.hasRoundedCorners = false; 801 break; 802 } 803 } 804 } 805 // Setup impressions 806 addImpressionsFieldsToModel(objectGraph, frame, context); 807 const shelf = new models.Shelf("framedArtwork"); 808 shelf.items = [frame]; 809 return shelf; 810} 811function createTip(objectGraph, module, context) { 812 const artworkData = mediaAttributes.attributeAsDictionary(module, "artwork"); 813 const artwork = content.artworkFromApiArtwork(objectGraph, artworkData, { 814 useCase: 13 /* content.ArtworkUseCase.ArticleImage */, 815 }); 816 if (!artwork) { 817 return null; 818 } 819 const caption = mediaAttributes.attributeAsString(module, "editorialCopy"); 820 const ordinal = mediaAttributes.attributeAsString(module, "tipNumber"); 821 // Create the tip image 822 const frame = new models.FramedArtwork(artwork, false, "text/x-apple-as3-nqml"); 823 frame.isFullWidth = false; 824 frame.hasRoundedCorners = true; 825 frame.caption = caption; 826 frame.ordinal = ordinal; 827 // Setup impressions 828 addImpressionsFieldsToModel(objectGraph, frame, context); 829 // Create the shelf 830 const shelf = new models.Shelf("framedArtwork"); 831 shelf.items = [frame]; 832 return shelf; 833} 834function createPullQuote(objectGraph, module, context) { 835 const text = mediaAttributes.attributeAsString(module, "quote"); 836 const attribution = mediaAttributes.attributeAsString(module, "quoteAttribution"); 837 // Get the optional artwork 838 const artworkData = mediaAttributes.attributeAsDictionary(module, "artwork"); 839 const artwork = content.artworkFromApiArtwork(objectGraph, artworkData, { 840 useCase: 13 /* content.ArtworkUseCase.ArticleImage */, 841 }); 842 const fullWidth = mediaAttributes.attributeAsString(module, "pullQuoteDisplayType") === "FullWidth"; 843 // Create the quote 844 const quote = new models.Quote(text, attribution, artwork, fullWidth); 845 // Setup impressions 846 addImpressionsFieldsToModel(objectGraph, quote, context); 847 // Create the shelf 848 const shelf = new models.Shelf("quote"); 849 shelf.items = [quote]; 850 return shelf; 851} 852function createHorizontalRule(objectGraph, module, context) { 853 const lineStyle = mediaAttributes.attributeAsString(module, "lineStyle"); 854 const fullWidth = mediaAttributes.attributeAsString(module, "displayStyle") === "FullWidth"; 855 let ruleColor = color.named("defaultLine"); 856 if (objectGraph.client.isVision && (lineStyle === "Dotted" || lineStyle === "Dashed")) { 857 ruleColor = color.white; 858 } 859 // Parse the customColor from Media API. This can only be a dynamic color. 860 const apiColor = mediaAttributes.attributeAsDictionary(module, "customColor"); 861 const lightColor = color.fromHex(serverData.asString(apiColor, "lightMode")); 862 const darkColor = color.fromHex(serverData.asString(apiColor, "darkMode")); 863 if (!serverData.isNullOrEmpty(lightColor) && !serverData.isNullOrEmpty(darkColor)) { 864 ruleColor = color.dynamicWith(lightColor, darkColor); 865 } 866 const horizontalRule = new models.HorizontalRule(lineStyle, ruleColor, fullWidth); 867 // Create the Shelf 868 const shelf = new models.Shelf("horizontalRule"); 869 shelf.items = [horizontalRule]; 870 return shelf; 871} 872function createVideo(objectGraph, module, context) { 873 // Get the preview artwork 874 const artworkData = mediaAttributes.attributeAsDictionary(module, "video.previewFrame"); 875 const artwork = content.artworkFromApiArtwork(objectGraph, artworkData, { 876 useCase: 13 /* content.ArtworkUseCase.ArticleImage */, 877 }); 878 if (!artwork) { 879 return null; 880 } 881 // Get the video URL 882 const videoUrl = mediaAttributes.attributeAsString(module, "video.video"); 883 if (!videoUrl) { 884 return null; 885 } 886 const videoDisplayType = mediaAttributes.attributeAsString(module, "inlineVideoDisplayType"); 887 const isFullWidth = videoDisplayType === "FullWidth"; 888 // Create the video 889 const video = new models.Video(videoUrl, artwork, videoDefaults.defaultVideoConfiguration(objectGraph)); 890 metricsHelpersMedia.addMetricsEventsToVideo(objectGraph, video, { 891 pageInformation: context.metricsPageInformation, 892 locationTracker: context.metricsLocationTracker, 893 id: context.pageId, 894 }); 895 const videoModule = new models.FramedVideo(video, isFullWidth, "text/x-apple-as3-nqml"); 896 // Get the optional caption 897 videoModule.caption = mediaAttributes.attributeAsString(module, "editorialCopy"); 898 // Setup impressions 899 addImpressionsFieldsToModel(objectGraph, videoModule, context); 900 // Create the shelf 901 const shelf = new models.Shelf("framedVideo"); 902 shelf.items = [videoModule]; 903 return shelf; 904} 905function createAppLockup(objectGraph, module, context) { 906 const contentData = contentFromModule(objectGraph, module, context); 907 if (!contentData) { 908 return null; 909 } 910 // Shelf to generate. Either lockup, app showcase, or app event shelf 911 let shelf = null; 912 // If we have an app-events relationship, we want to use this as the priority. This sometimes exists on the 913 // AppLockup type, rather than as the AppEventLockup type, so that older clients can still render this 914 // item by falling back to the AppLockup type. 915 const appEventsDataItems = mediaRelationships.relationshipCollection(module, "app-events"); 916 if (serverData.isDefinedNonNullNonEmpty(appEventsDataItems)) { 917 shelf = appPromotionsShelf.appEventsShelfForArticle(objectGraph, appEventsDataItems, context.metricsPageInformation, context.metricsLocationTracker, context); 918 if (serverData.isDefinedNonNull(shelf)) { 919 return shelf; 920 } 921 } 922 // Set the display style 923 const displayStyle = mediaAttributes.attributeAsString(module, "appLockupSize"); 924 context.subStyle = displayStyle; 925 let shelfStyle; 926 let isLockup = false; 927 if (displayStyle) { 928 switch (displayStyle) { 929 case "Small": { 930 shelfStyle = "smallLockup"; 931 isLockup = true; 932 break; 933 } 934 case "Medium": { 935 shelfStyle = "mediumLockup"; 936 isLockup = true; 937 break; 938 } 939 case "Large": 940 default: { 941 if (objectGraph.client.isWatch || 942 objectGraph.client.isTV || 943 objectGraph.client.isVision || 944 preprocessor.GAMES_TARGET) { 945 // Per design, on watchOS we always show a lockup for app showcases. 946 // Watch App Store treats all lockup sizes the same -- let's pick small. 947 shelfStyle = "smallLockup"; 948 isLockup = true; 949 } 950 else { 951 shelfStyle = "appShowcase"; 952 } 953 break; 954 } 955 } 956 } 957 // Determine the deep link URL, if there is one. 958 const externalDeepLinkUrl = externalDeepLink.deepLinkUrlFromData(objectGraph, module); 959 // Create the appropriate shelf item 960 if (isLockup) { 961 const lockupShelf = new models.Shelf(shelfStyle); 962 const metricsOptions = { 963 metricsOptions: { 964 pageInformation: context.metricsPageInformation, 965 locationTracker: context.metricsLocationTracker, 966 }, 967 clientIdentifierOverride: context.clientIdentifierOverride, 968 externalDeepLinkUrl: externalDeepLinkUrl, 969 artworkUseCase: content.artworkUseCaseFromShelfStyle(objectGraph, shelfStyle), 970 }; 971 let lockup; 972 if (preprocessor.GAMES_TARGET) { 973 const shelfID = new PageID(context.pageId).shelfID(module.id); 974 lockup = gamesComponentBuilder.makeArticleGameLockup(objectGraph, contentData, shelfID); 975 } 976 else { 977 lockup = lockups.lockupFromData(objectGraph, contentData, metricsOptions); 978 } 979 if (isNothing(lockup)) { 980 return null; 981 } 982 lockupShelf.items = [lockup]; 983 shelf = lockupShelf; 984 } 985 else { 986 // On all platforms, the AppLockup platform generates a AppShowcase when display style is large. 987 shelf = createAppShowcase(objectGraph, module, context); 988 } 989 return shelf; 990} 991function createAppShowcase(objectGraph, module, context) { 992 // Create the shelf 993 const shelf = new models.Shelf("appShowcase"); 994 // Parameterize by platform: 995 // tvOS populates the `screenshots` field to display alongside video. 996 const showcaseHasScreenshots = objectGraph.client.isTV; 997 // Only non-tvOS has shelf background color 998 const shelfHasBackgroundColor = objectGraph.client.deviceType !== "tv"; 999 const contentData = contentFromModule(objectGraph, module, context); 1000 const externalDeepLinkUrl = externalDeepLink.deepLinkUrlFromData(objectGraph, module); 1001 const lockup = lockups.lockupFromData(objectGraph, contentData, { 1002 offerStyle: "colored", 1003 metricsOptions: { 1004 pageInformation: context.metricsPageInformation, 1005 locationTracker: context.metricsLocationTracker, 1006 }, 1007 clientIdentifierOverride: context.clientIdentifierOverride, 1008 externalDeepLinkUrl: externalDeepLinkUrl, 1009 crossLinkSubtitle: context.crossLinkSubtitle, 1010 artworkUseCase: 1 /* content.ArtworkUseCase.LockupIconSmall */, 1011 }); 1012 const showcase = new models.AppShowcase("large", lockup); 1013 showcase.description = lockups.subtitleFromData(objectGraph, contentData); 1014 // Add Video 1015 // Configure the video for the showcase, if the module demands it. 1016 let showcaseVideo = null; 1017 const videoType = mediaAttributes.attributeAsString(module, "appLockupVideo"); 1018 switch (videoType) { 1019 case "AppTrailer": { 1020 const allAppVideos = content.videoPreviewsFromData(objectGraph, contentData); 1021 if (allAppVideos && allAppVideos.length > 0) { 1022 showcaseVideo = allAppVideos[0]; 1023 } 1024 break; 1025 } 1026 default: 1027 break; 1028 } 1029 if (showcaseVideo) { 1030 metricsHelpersMedia.addMetricsEventsToVideo(objectGraph, showcaseVideo, { 1031 pageInformation: context.metricsPageInformation, 1032 locationTracker: context.metricsLocationTracker, 1033 id: context.pageId, 1034 }); 1035 showcase.video = showcaseVideo; 1036 } 1037 // Add Screenshots for AppShowcase if necessary 1038 if (showcaseHasScreenshots) { 1039 showcase.screenshots = content.screenshotsFromData(objectGraph, contentData, 14 /* content.ArtworkUseCase.ArticleScreenshots */, [content.currentAppPlatform(objectGraph)]); 1040 } 1041 // Configure background if necessary. 1042 if (shelfHasBackgroundColor) { 1043 shelf.background = { 1044 type: "color", 1045 color: appShowcaseBackgroundColor, 1046 }; 1047 } 1048 shelf.items = [showcase]; 1049 return shelf; 1050} 1051function createIAPLockup(objectGraph, module, context) { 1052 const contentData = contentFromModule(objectGraph, module, context); 1053 if (!contentData) { 1054 return null; 1055 } 1056 // Create the lockup 1057 const lockup = lockups.inAppPurchaseLockupFromData(objectGraph, contentData, { 1058 metricsOptions: { 1059 pageInformation: context.metricsPageInformation, 1060 locationTracker: context.metricsLocationTracker, 1061 }, 1062 clientIdentifierOverride: context.clientIdentifierOverride, 1063 artworkUseCase: 1 /* content.ArtworkUseCase.LockupIconSmall */, 1064 }); 1065 if (!lockup) { 1066 return null; 1067 } 1068 const showcase = new models.InAppPurchaseShowcase(lockup); 1069 // Create the shelf 1070 const shelf = new models.Shelf("inAppPurchaseShowcase"); 1071 shelf.background = { 1072 type: "color", 1073 color: iAPBackgroundColor, 1074 }; 1075 shelf.items = [showcase]; 1076 return shelf; 1077} 1078function createAppList(objectGraph, module, context) { 1079 const showOrdinals = mediaAttributes.attributeAsBooleanOrFalse(module, "showOrdinals"); 1080 const ordinalDirection = mediaAttributes.attributeAsString(module, "collectionLockupDisplayType") === "OrdinalDesc" 1081 ? "descending" 1082 : "ascending"; 1083 // Set the display style 1084 const displayStyle = mediaAttributes.attributeAsString(module, "collectionLockupSize"); 1085 context.subStyle = displayStyle; 1086 let style; 1087 if (displayStyle) { 1088 switch (displayStyle) { 1089 case "Large": { 1090 style = "largeLockup"; 1091 break; 1092 } 1093 case "Medium": { 1094 style = "mediumLockup"; 1095 break; 1096 } 1097 case "Small": 1098 default: { 1099 style = "smallLockup"; 1100 break; 1101 } 1102 } 1103 } 1104 // Construct the lockup options 1105 const lockupOptions = { 1106 metricsOptions: { 1107 pageInformation: context.metricsPageInformation, 1108 locationTracker: context.metricsLocationTracker, 1109 }, 1110 clientIdentifierOverride: context.clientIdentifierOverride, 1111 artworkUseCase: content.artworkUseCaseFromShelfStyle(objectGraph, style), 1112 canDisplayArcadeOfferButton: content.shelfContentTypeCanDisplayArcadeOfferButtons(objectGraph, style), 1113 }; 1114 // Check if we have content 1115 const contents = mediaRelationships.relationshipCollection(module, "contents"); 1116 if (isNothing(contents)) { 1117 return null; 1118 } 1119 let childLockups = []; 1120 if (preprocessor.GAMES_TARGET) { 1121 const shelfID = new PageID(context.pageId).shelfID(module.id); 1122 childLockups = gamesComponentBuilder.makeArticleGameLockups(objectGraph, contents, shelfID); 1123 } 1124 else { 1125 childLockups = lockups.lockupsFromData(objectGraph, contents, { 1126 includeOrdinals: showOrdinals, 1127 ordinalDirection: ordinalDirection, 1128 lockupOptions: lockupOptions, 1129 }); 1130 } 1131 if (!childLockups || childLockups.length === 0) { 1132 return null; 1133 } 1134 // Create the shelf 1135 const shelf = new models.Shelf(style); 1136 shelf.items = childLockups; 1137 return shelf; 1138} 1139function createAppMedia(objectGraph, module, context) { 1140 const contentData = contentFromModule(objectGraph, module, context); 1141 if (!contentData) { 1142 return null; 1143 } 1144 // Set the display style 1145 const mediaOption = mediaAttributes.attributeAsString(module, "appMediaOption"); 1146 const appMediaPlatform = mediaAttributes.attributeAsString(module, "appMediaPlatform"); 1147 context.subStyle = mediaOption; 1148 switch (mediaOption) { 1149 case "Screenshots": { 1150 let shelf = null; 1151 // I'm so sorry, but making this split makes the macOS client code infinitely better because we are able 1152 // to reuse the same product media view and component contract that is used on product page screenshots/trailers. 1153 // Really, iOS should be reworked such that its module & product page implementation has a single source, 1154 // but this has serious design obstacles that need to be worked through. 1155 if (objectGraph.client.isMac) { 1156 shelf = new models.Shelf("productMedia"); 1157 const productMedia = content.productMediaFromData(objectGraph, contentData, 14 /* content.ArtworkUseCase.ArticleScreenshots */); 1158 if (serverData.isDefinedNonNull(productMedia) && productMedia.length) { 1159 shelf.items = productMedia; 1160 } 1161 } 1162 else { 1163 shelf = new models.Shelf("screenshots"); 1164 if (serverData.isNull(appMediaPlatform)) { 1165 /** 1166 * The server did not tell us which app platform to use, so we need to infer based on various keys in 1167 * the response. These parameters are only fully baked into product-dv responses, so we we need to do 1168 * the more expensive product-dv lookup in order to correctly infer the default screenshots to use for 1169 * the shelf. 1170 */ 1171 const screenshots = content.screenshotsFromData(objectGraph, contentData, 14 /* content.ArtworkUseCase.ArticleScreenshots */); 1172 if (screenshots && screenshots.length > 0) { 1173 shelf.items = [screenshots[0]]; 1174 } 1175 } 1176 else { 1177 /** 1178 * Server tells us which platform to use -- dictated by `appMediaPlatform`. Selectively do a lookup for 1179 * just those screenshots. 1180 */ 1181 const desiredAppPlatform = appPlatformFromAppMediaPlatform(objectGraph, appMediaPlatform); 1182 if (desiredAppPlatform) { 1183 const screenshots = content.screenshotsFromData(objectGraph, contentData, 14 /* content.ArtworkUseCase.ArticleScreenshots */, [desiredAppPlatform]); 1184 if (screenshots && screenshots.length) { 1185 shelf.items = [screenshots[0]]; 1186 } 1187 } 1188 } 1189 } 1190 if (serverData.isDefinedNonNull(shelf) && shelf.items.length === 0) { 1191 return null; 1192 } 1193 return shelf; 1194 } 1195 case "AppTrailers": 1196 const trailersShelf = new models.Shelf("framedVideo"); 1197 const videoPreviews = content.videoPreviewsFromData(objectGraph, contentData); 1198 if (videoPreviews && videoPreviews.length > 0) { 1199 const video = videoPreviews[0]; 1200 metricsHelpersMedia.addMetricsEventsToVideo(objectGraph, video, { 1201 pageInformation: context.metricsPageInformation, 1202 locationTracker: context.metricsLocationTracker, 1203 id: context.pageId, 1204 }); 1205 const firstTrailer = new models.FramedVideo(video, false, "text/plain", null, null, true); 1206 trailersShelf.items = [firstTrailer]; 1207 return trailersShelf; 1208 } 1209 else { 1210 return null; 1211 } 1212 default: { 1213 return null; 1214 } 1215 } 1216} 1217function createLink(objectGraph, module, context) { 1218 if (objectGraph.client.isTV || objectGraph.client.isWatch) { 1219 return null; 1220 } 1221 const urlString = mediaAttributes.attributeAsString(module, "url"); 1222 if (!urlString) { 1223 return null; 1224 } 1225 const url = new urls.URL(urlString); 1226 const linkTitle = mediaAttributes.attributeAsString(module, "urlTitle"); 1227 let text = mediaAttributes.attributeAsString(module, "editorialCopy"); 1228 if (!text) { 1229 text = url.host; 1230 } 1231 const mediaHosts = [ 1232 "itunes.apple.com", 1233 "apps.apple.com", 1234 "music.apple.com", 1235 "books.apple.com", 1236 "podcasts.apple.com", 1237 "watch-app.cdn-apple.com", 1238 "tv.apple.com", 1239 ]; 1240 let linkPresentationEnabled = false; 1241 for (const mediaHost of mediaHosts) { 1242 if (url.host.endsWith(mediaHost)) { 1243 linkPresentationEnabled = true; 1244 } 1245 } 1246 const action = new models.ExternalUrlAction(urlString); 1247 metricsHelpersClicks.addClickEventToAction(objectGraph, action, { 1248 targetType: "link", 1249 pageInformation: context.metricsPageInformation, 1250 id: `${context.index}`, 1251 locationTracker: context.metricsLocationTracker, 1252 }); 1253 const link = new models.EditorialLink(linkTitle, text, action, linkPresentationEnabled); 1254 // Setup impressions 1255 addImpressionsFieldsToModel(objectGraph, link, context); 1256 const shelf = new models.Shelf("editorialLink"); 1257 shelf.items = [link]; 1258 return shelf; 1259} 1260function createTextList(objectGraph, module, context) { 1261 const listEntries = mediaAttributes.attributeAsArrayOrEmpty(module, "editorialCopy"); 1262 if (!listEntries.length) { 1263 return null; 1264 } 1265 const type = mediaAttributes.attributeAsString(module, "textListDisplayType"); 1266 context.subStyle = type; 1267 let isBulleted = false; 1268 switch (type) { 1269 case "Bulleted": { 1270 isBulleted = true; 1271 break; 1272 } 1273 default: { 1274 isBulleted = false; 1275 break; 1276 } 1277 } 1278 let text; 1279 if (isBulleted) { 1280 text = "<ul>"; 1281 } 1282 else { 1283 text = "<ol>"; 1284 } 1285 for (const textEntry of listEntries) { 1286 const listItemJSONString = JSON.stringify(textEntry); 1287 // rdar://104446319 - We must use `parse` on our JSON string to convert back to 1288 // a raw string object as this ensures leading/trailing quotation marks are *not* escaped 1289 const listItem = JSON.parse(listItemJSONString); 1290 text = `${text}<li>${listItem}</li>`; 1291 } 1292 if (isBulleted) { 1293 text = `${text}</ul>`; 1294 } 1295 else { 1296 text = `${text}</ol>`; 1297 } 1298 const paragraph = new models.Paragraph(text, "text/x-apple-as3-nqml", "article"); 1299 // Setup impressions 1300 addImpressionsFieldsToModel(objectGraph, paragraph, context); 1301 const shelf = new models.Shelf("paragraph"); 1302 shelf.items = [paragraph]; 1303 return shelf; 1304} 1305function createStoryCards(objectGraph, module, context, shelfIndex) { 1306 if (objectGraph.client.isVision) { 1307 const shelfToken = createBaseShelfToken(objectGraph, undefined, module, false, shelfIndex, context.metricsPageInformation, context.metricsLocationTracker); 1308 const shelf = buildSmallStoryCardShelf(objectGraph, shelfToken); 1309 shelf.isHorizontal = true; 1310 return shelf; 1311 } 1312 const cards = mediaRelationships.relationshipCollection(module, "contents"); 1313 if (!cards) { 1314 return null; 1315 } 1316 const title = mediaAttributes.attributeAsString(module, "name"); 1317 const subtitle = mediaAttributes.attributeAsString(module, "tagline"); 1318 let shelf = null; 1319 if (objectGraph.client.isiOS && objectGraph.featureFlags.isEnabled("mini_today_cards_article")) { 1320 const todayParseContext = new TodayParseContext(context.metricsPageInformation, context.metricsLocationTracker); 1321 shelf = todayHorizontalCardUtil.shelfForMiniTodayCards(objectGraph, cards, title, subtitle, todayParseContext); 1322 } 1323 else { 1324 const isSmallStoryCardsSupported = objectGraph.host.isiOS || objectGraph.host.isMac || objectGraph.host.isWeb; 1325 const resolvedContentType = isSmallStoryCardsSupported ? "smallStoryCard" : "todayBrick"; 1326 shelf = todayHorizontalCardUtil.shelfForHorizontalCardItems(objectGraph, cards, resolvedContentType, title, subtitle, context, null); 1327 if (isSmallStoryCardsSupported) { 1328 // Only specific small story cards are supported and will crash otherwise, filter those here preemptively. 1329 // rdar://91965501 (MAS Crashing - Earth Day Landing Page - 4/19) 1330 if (Array.isArray(shelf.items)) { 1331 shelf.items = shelf.items.filter((item) => { 1332 if (!(item instanceof models.TodayCard)) { 1333 return true; 1334 } 1335 return todayHorizontalCardUtil.isHorizontalCardSupportedForKind(objectGraph, item.media.kind, resolvedContentType); 1336 }); 1337 } 1338 } 1339 } 1340 return shelf; 1341} 1342function createAppEventLockup(objectGraph, module, context) { 1343 const contentData = contentFromModule(objectGraph, module, context); 1344 if (!contentData) { 1345 return null; 1346 } 1347 return appPromotionsShelf.appEventsShelfForArticle(objectGraph, [contentData], context.metricsPageInformation, context.metricsLocationTracker, context); 1348} 1349function createOfferItemLockup(objectGraph, module, context) { 1350 if (!objectGraph.client.isiOS) { 1351 return []; 1352 } 1353 const offerItem = mediaRelationships.relationshipData(objectGraph, module, "contents"); 1354 if (serverData.isNullOrEmpty(offerItem)) { 1355 return null; 1356 } 1357 // Offer detail Paragraph 1358 const offerParagraph = mediaAttributes.attributeAsString(module, "editorialCopy"); 1359 const paragraph = new models.Paragraph(offerParagraph, "text/x-apple-as3-nqml", "article"); 1360 const paragraphShelf = new models.Shelf("paragraph"); 1361 paragraphShelf.items = [paragraph]; 1362 // Winback Offer Card 1363 const offerItemShelf = appPromotionsShelf.appEventsShelfForArticle(objectGraph, [offerItem], context.metricsPageInformation, context.metricsLocationTracker, context); 1364 return [paragraphShelf, offerItemShelf]; 1365} 1366/** 1367 * Ingests EI canvas modules of form: 1368 * { 1369 * id: <editorial-id>, 1370 * type: "editorial-item-shelves", 1371 * attributes: { 1372 * displayType: "AppMarker", 1373 * appMarkerType: <AppMarkerType> 1374 * } 1375 * } 1376 * to generate an shelf for AppMarker model. 1377 */ 1378function createAppMarker(objectGraph, appMarkerModule, articleData, context) { 1379 const markerType = mediaAttributes.attributeAsString(appMarkerModule, "appMarkerType"); 1380 context.subStyle = markerType; 1381 let shelf = null; 1382 switch (markerType) { 1383 case "OSUpgrade": 1384 shelf = createOSUpgradeClientControlButton(objectGraph, appMarkerModule, context); 1385 break; 1386 case "Acquisition": 1387 shelf = createArcadeShowcase(objectGraph, appMarkerModule, articleData, context); 1388 break; 1389 default: 1390 break; 1391 } 1392 return shelf; 1393} 1394/** 1395 * Ingests EI canvas modules of form: 1396 * { 1397 * id: <editorial-id>, 1398 * type: "editorial-item-shelves", 1399 * attributes: { 1400 * displayType: "AppMarker", 1401 * appMarkerType: "OSUpgrade" 1402 * } 1403 * } 1404 * to generate an shelf with an button that links to preferences updates. 1405 */ 1406function createOSUpgradeClientControlButton(objectGraph, osUpgradeModule, context) { 1407 const deviceType = objectGraph.client.deviceType; 1408 if (deviceType !== "mac") { 1409 return null; // Early exit - Only MAS utilizes OS Upgrade Client Control Button currently. 1410 } 1411 const installUpdateUrl = links.osUpdateUrl(deviceType); 1412 if (installUpdateUrl === null) { 1413 return null; 1414 } 1415 // Action to Preferences 1416 const openUpdatesAction = new models.ExternalUrlAction(installUpdateUrl); 1417 // Action to open preferences is configured as `link` 1418 metricsHelpersClicks.addClickEventToAction(objectGraph, openUpdatesAction, { 1419 targetType: "link", 1420 pageInformation: context.metricsPageInformation, 1421 id: `${context.index}`, 1422 locationTracker: context.metricsLocationTracker, 1423 }); 1424 // Shelf model 1425 const upgradeControlText = objectGraph.loc.string("CLIENT_CONTROL_OS_UPGRADE_TITLE", "CHECK FOR UPDATE"); 1426 const upgradeControl = new models.ClientControlButton(upgradeControlText, openUpdatesAction); 1427 // Add impressions 1428 addImpressionsFieldsToModel(objectGraph, upgradeControl, context); 1429 const shelf = new models.Shelf("clientControlButton"); 1430 shelf.items = [upgradeControl]; 1431 return shelf; 1432} 1433/** 1434 * Ingests EI canvas modules of form: 1435 * { 1436 * id: <editorial-id>, 1437 * type: "editorial-item-shelves", 1438 * } 1439 * with additional data: 1440 * - Upsell data on `context.additionalData` 1441 * - Icon Artwork data (iOS only) on `context.additionalData` 1442 * 1443 * to generate an shelf that promotes Arcade service. 1444 * 1445 * @param arcadeShowcaseModule Arcade showcase module 1446 * @param articleData The data backing the article containing the module. Used for top-level relationship. 1447 * @param context Parse context for this page parsing. This context contains the additional requirements data. 1448 */ 1449function createArcadeShowcase(objectGraph, arcadeShowcaseModule, articleData, context) { 1450 const supportedOnPlatform = objectGraph.host.isiOS || objectGraph.host.isMac || objectGraph.client.isVision; 1451 if (!supportedOnPlatform) { 1452 return null; 1453 } 1454 // Default to upsell on relation, falling back to upsell that may have been fetched separately for orphaned acquisition modules. 1455 let upsellData = arcadeCommon.upsellFromRelationshipOf(objectGraph, articleData); 1456 if (!upsellData && context.additionalData) { 1457 const upsellResponse = context.additionalData.get("upsellForNonacquisitionCanvas"); 1458 upsellData = arcadeCommon.upsellFromContentsOfUpsellResponse(objectGraph, upsellResponse); 1459 } 1460 if (!serverData.isDefinedNonNull(upsellData)) { 1461 return null; 1462 } 1463 const baseMetricsOptions = { 1464 pageInformation: context.metricsPageInformation, 1465 locationTracker: context.metricsLocationTracker, 1466 }; 1467 // Flow to See All games if subscribed 1468 const subscribedAction = arcadeCommon.openArcadeMainAction(objectGraph, context.metricsPageInformation, context.metricsLocationTracker, objectGraph.client.isVision); 1469 if (preprocessor.GAMES_TARGET) { 1470 subscribedAction.title = objectGraph.loc.string("OfferButton.Arcade.Title.Explore"); 1471 } 1472 else { 1473 subscribedAction.title = objectGraph.loc.string("ARCADE_ACTION_TITLE_EXPLORE", "EXPLORE"); 1474 } 1475 // Flow to Arcade Subscribe page if unsubscribed. 1476 let unsubscribedAction; 1477 const unsubscribedActionTitle = breakoutsCommon.callToActionLabelFromData(objectGraph, upsellData.marketingItemData); 1478 if (serverData.isDefinedNonNullNonEmpty(unsubscribedActionTitle)) { 1479 // We support an inline offer here instead, when the pricing token is there. 1480 unsubscribedAction = arcadeUpsell.arcadeOfferButtonActionFromData(objectGraph, upsellData.marketingItemData, models.marketingItemContextFromString("editorialItemCanvas"), baseMetricsOptions); 1481 if (serverData.isDefinedNonNull(unsubscribedAction)) { 1482 unsubscribedAction.title = unsubscribedActionTitle; 1483 } 1484 } 1485 else { 1486 // If Upsell EI is misconfigured and missing `breakoutCallToActionLabel`, default to opening Arcade app for unsubscribed state. 1487 unsubscribedAction = arcadeCommon.openArcadeMainAction(objectGraph, context.metricsPageInformation, context.metricsLocationTracker, objectGraph.client.isVision); 1488 if (preprocessor.GAMES_TARGET) { 1489 unsubscribedAction.title = objectGraph.loc.string("OfferButton.Arcade.Title.Explore"); 1490 } 1491 else { 1492 unsubscribedAction.title = objectGraph.loc.string("ARCADE_ACTION_TITLE_EXPLORE", "EXPLORE"); 1493 } 1494 } 1495 const arcadeShowcase = new models.ArcadeShowcase(unsubscribedAction, subscribedAction); 1496 const unsubscribedDescription = arcadeUpsell.descriptionFromData(objectGraph, upsellData.marketingItemData); 1497 arcadeShowcase.unsubscribedDescription = unsubscribedDescription; 1498 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); 1499 if (preprocessor.GAMES_TARGET) { 1500 offerDisplayProperties.titles["subscribed"] = objectGraph.loc.string("OfferButton.Arcade.Title.Explore"); 1501 } 1502 else { 1503 offerDisplayProperties.titles["subscribed"] = objectGraph.loc.string("ARCADE_ACTION_TITLE_EXPLORE", "EXPLORE"); 1504 } 1505 arcadeShowcase.offerDisplayProperties = offerDisplayProperties; 1506 const showcaseMetricsOptions = { 1507 ...baseMetricsOptions, 1508 targetType: "arcadeShowcase", 1509 title: unsubscribedActionTitle, 1510 id: arcadeShowcaseModule.id, 1511 kind: "arcadeShowcase", 1512 softwareType: null, 1513 displaysArcadeUpsell: true, 1514 }; 1515 metricsHelpersImpressions.addImpressionFields(objectGraph, arcadeShowcase, showcaseMetricsOptions); 1516 // Build Artwork for iOS only 1517 if (objectGraph.host.isiOS || objectGraph.client.isVision) { 1518 // Context should have additional data to source icons. 1519 if (serverData.isNull(context.additionalData)) { 1520 return null; 1521 } 1522 const iconResponse = context.additionalData.get("arcadeIcons"); 1523 if (serverData.isDefinedNonNullNonEmpty(iconResponse)) { 1524 const iconMetricsOptions = { 1525 pageInformation: context.metricsPageInformation, 1526 locationTracker: context.metricsLocationTracker, 1527 }; 1528 const iconsDataCollection = mediaDataStructure.dataCollectionFromResultsListContainer(iconResponse); 1529 arcadeShowcase.iconArtworks = content.impressionableAppIconsFromDataCollection(objectGraph, iconsDataCollection, iconMetricsOptions, { 1530 useCase: 2 /* content.ArtworkUseCase.LockupIconMedium */, 1531 }); 1532 } 1533 } 1534 const shelf = new models.Shelf("arcadeShowcase"); 1535 shelf.items = [arcadeShowcase]; 1536 const shelfHasBackgroundColor = objectGraph.host.isiOS || objectGraph.client.isVision; 1537 if (shelfHasBackgroundColor) { 1538 shelf.background = { 1539 type: "color", 1540 color: arcadeShowcaseShelfBackgroundColor, 1541 }; 1542 } 1543 return shelf; 1544} 1545// endregion 1546function contentFromModule(objectGraph, module, context) { 1547 const contents = mediaRelationships.relationshipData(objectGraph, module, "contents"); 1548 if (!contents) { 1549 return null; 1550 } 1551 return contents; 1552} 1553function addImpressionsFieldsToModel(objectGraph, model, context, impressionData) { 1554 if (!model) { 1555 return; 1556 } 1557 let impressionType = context.module; 1558 if (context.subStyle) { 1559 impressionType = impressionType + "_" + context.subStyle; 1560 } 1561 if (serverData.isNull(impressionData)) { 1562 impressionData = { 1563 id: `${context.index}`, 1564 impressionIndex: context.index, 1565 idType: "sequential", 1566 impressionType: impressionType, 1567 kind: "iosModule", 1568 }; 1569 } 1570 model.impressionMetrics = new models.ImpressionMetrics(metricsHelpersUtil.sanitizedMetricsDictionary(impressionData)); 1571} 1572//# sourceMappingURL=article.js.map