// // search.ts // AppStoreKit // // Created by Kevin MacWhinnie on 8/15/16. // Copyright (c) 2016 Apple Inc. All rights reserved. // import { isNothing, isSome } from "@jet/environment"; import * as validation from "@jet/environment/json/validation"; import { FetchTimingMetricsBuilder } from "@jet/environment/metrics/fetch-timing-metrics-builder"; import { PageInvocationPoint } from "@jet/environment/types/metrics"; import * as models from "../../api/models"; import { SearchResultsLearnMoreNotice } from "../../api/models"; import * as serverData from "../../foundation/json-parsing/server-data"; import * as mediaDataStructure from "../../foundation/media/data-structure"; import * as contentArtwork from "../content/artwork/artwork"; import * as metricsBuilder from "../metrics/builder"; import * as metricsHelpersClicks from "../metrics/helpers/clicks"; import * as metricsHelpersImpressions from "../metrics/helpers/impressions"; import * as metricsHelpersLocation from "../metrics/helpers/location"; import * as metricsHelpersMisc from "../metrics/helpers/misc"; import * as metricsHelpersPage from "../metrics/helpers/page"; import * as guidedSearch from "./guided-search/guided-search"; import { addGuidedSearchParentImpressionMetrics, addSearchResultParentImpressionMetrics, } from "./guided-search/guided-search-metrics"; import * as searchAdsODML from "./search-ads-odml"; import * as searchCommon from "./search-common"; import { createDefaultSelectedFacetOptions, createSearchFacets, createSearchPageFacets } from "./search-facets"; import * as searchResultsFetching from "./search-results-fetching"; import { createSearchResultsLearnMoreNoticeLinkableText } from "./search-results-learn-more-notice"; import * as searchResultsPipeline from "./search-results-pipeline"; import * as searchSpellCorrection from "./search-spell-correction"; import * as searchToken from "./search-token"; // MARK: - Search Hints /** * Convert the response from the search hints endpoint into a model object. * @param {String} prefixTerm The term the search hints are for (i.e. searchPrefix). * @param {String} searchUrl The Url if we were to search for this term right away * @param {*} response The API response. * @return {SearchHintSet} The search hint set containing the search hint array */ export function searchHintsFromApiResponse(objectGraph, prefixTerm, hintsContainer) { return validation.context("searchHintsFromApiResponse", () => { var _a, _b, _c, _d; const metricsOptions = { targetType: "listItem", pageInformation: metricsHelpersPage.pageInformationForSearchHintsPage(objectGraph, prefixTerm, hintsContainer.hintsRequestUrl, hintsContainer.dataSetId), locationTracker: metricsHelpersLocation.newLocationTracker(), }; // Build user hint that matches what user typed. Appears in first position. let userTypedHintAction = null; if ((_a = hintsContainer.userTypedTerm) === null || _a === void 0 ? void 0 : _a.length) { userTypedHintAction = new models.SearchAction(hintsContainer.userTypedTerm, hintsContainer.userTypedTerm, null, "userTypedHint"); userTypedHintAction.spellCheckEnabled = true; userTypedHintAction.prefixTerm = prefixTerm; metricsHelpersImpressions.addImpressionMetricsToHintsSearchAction(objectGraph, userTypedHintAction, metricsOptions); metricsHelpersClicks.addEventsToSearchAction(objectGraph, userTypedHintAction, metricsOptions.targetType, metricsOptions.locationTracker, metricsOptions.pageInformation); metricsHelpersLocation.nextPosition(metricsOptions.locationTracker); } // Build standard hints. const searchHintActions = (_c = (_b = hintsContainer.rawHints) === null || _b === void 0 ? void 0 : _b.map((rawHint) => { return searchHintAction(objectGraph, rawHint, prefixTerm, metricsOptions); })) !== null && _c !== void 0 ? _c : []; // Prepend user hint if any. if (userTypedHintAction != null) { searchHintActions.unshift(userTypedHintAction); } const hintSet = new models.SearchHintSet(searchHintActions, (_d = hintsContainer.ghostHintsTerm) !== null && _d !== void 0 ? _d : null); /** * Send `input` Search Events when search hints returns. * For SSS, this is the granularity we agreed on, instead of sending it in native per keystroke. * Unlike standard page metrics, we only: * - Fire a 'input' search event * - Setup pageFields for page fields generator. */ const searchEvent = metricsBuilder.createMetricsSearchData(objectGraph, prefixTerm, "key", "input", hintsContainer.hintsRequestUrl, { ...metricsHelpersMisc.fieldsFromPageInformation(metricsOptions.pageInformation) }); hintSet.pageMetrics.pageFields = metricsHelpersMisc.fieldsFromPageInformation(metricsOptions.pageInformation); hintSet.pageMetrics.addData(searchEvent, [PageInvocationPoint.pageEnter]); hintSet.searchClearAction = createSearchCancelledOrClearedAction(objectGraph, "clear", metricsOptions.pageInformation, metricsOptions.locationTracker, prefixTerm); hintSet.searchCancelAction = createSearchCancelledOrClearedAction(objectGraph, "cancel", metricsOptions.pageInformation, metricsOptions.locationTracker, prefixTerm); return hintSet; }); } /** * Creates a search hint action from the search hint data * @param objectGraph The App Store object graph * @param hintData The hint object data * @param prefixTerm The hint prefix term * @param metricsOptions The metrics options to use for click and impressions metrics for this hint * @returns A search hint action for the hint data */ function searchHintAction(objectGraph, hintData, prefixTerm, metricsOptions) { var _a, _b, _c, _d, _e; const searchEntity = (_a = searchEntityFromHintData(hintData)) !== null && _a !== void 0 ? _a : undefined; const searchAction = new models.SearchAction((_b = hintData.displayTerm) !== null && _b !== void 0 ? _b : "", (_c = hintData.searchTerm) !== null && _c !== void 0 ? _c : "", null, "hints", searchEntity, hintData.source); searchAction.artwork = contentArtwork.createArtworkForSystemImage(objectGraph, (_d = models.searchEntitySystemImage(searchEntity)) !== null && _d !== void 0 ? _d : "magnifyingglass"); searchAction.spellCheckEnabled = true; searchAction.prefixTerm = prefixTerm; metricsHelpersImpressions.addImpressionMetricsToHintsSearchAction(objectGraph, searchAction, metricsOptions); metricsHelpersClicks.addEventsToSearchAction(objectGraph, searchAction, metricsOptions.targetType, metricsOptions.locationTracker, (_e = metricsOptions.pageInformation) !== null && _e !== void 0 ? _e : undefined); metricsHelpersLocation.nextPosition(metricsOptions.locationTracker); return searchAction; } /** * * @param objectGraph The App Store object graph * @param entity The entity value string from the hint object * @param context The context value string from the hint object * @returns The correct entity for the hint based on legacy entity types * * Entities: * "arcade" ==> "apps" with context "arcade" "developer" ==> "developers" "story" ==> "editorial-items" "watch" ==> "apps" with context "watch" */ function searchEntityFromHintData(rawHint) { const hintEntity = rawHint.entity; switch (hintEntity) { case "apps": return rawHint.context; case "developers": return "developer"; case "editorial-items": return "story"; default: return null; } } // MARK: - Trending Searches /** * Convert the response from the trending searches endpoint into a model object. * @param {*} response The API response. * @return {TrendingSearch} A trending searches model object. */ export function trendingSearchesFromApiResponse(objectGraph, response) { return validation.context("trendingSearchesFromApiResponse", () => { const locationTracker = metricsHelpersLocation.newLocationTracker(); const searches = serverData.asArrayOrEmpty(response, "trendingSearches").map((rawSearch) => { const term = serverData.asString(rawSearch, "label"); const searchAction = new models.SearchAction(term, term, serverData.asString(rawSearch, "url"), "trending"); metricsHelpersClicks.addEventsToSearchAction(objectGraph, searchAction, "button", locationTracker); metricsHelpersLocation.nextPosition(locationTracker); return searchAction; }); const title = searches.length > 0 ? serverData.asString(response, "header.label") : null; const trendingSearches = new models.TrendingSearches(title, searches); switch (objectGraph.client.deviceType) { case "pad": trendingSearches.maxNumberOfSearches = 10; break; case "phone": trendingSearches.maxNumberOfSearches = 7; break; default: break; } return trendingSearches; }); } // MARK: - Search Results /** * Gets the shelf ID for search results, on a specfic segment if applicable * @param segmentTitle The title of the segment this shelf is on, if using segmented search * @returns The id for the search results shelf * @note This needs to include the segment title for segment search results since the contents on * each segment would otherwise have the same shelf id and therefore would lead to the content never changing */ function getSearchResultsShelfId(segmentTitle) { if (isSome(segmentTitle) && segmentTitle.length !== 0) { return `SearchResults.${segmentTitle}.shelfId`; } else { return "SearchResults.shelfId"; } } /** * Gets metrics pageid to use for the search results page. * @param segmentType The segment this page is displaying if any * @returns The id for the search results page */ function getSearchResultsPageId(segmentType) { switch (segmentType) { case models.SegmentedSearchResultsPageSegmentType.iOS: return "ios"; case models.SegmentedSearchResultsPageSegmentType.visionOS: return "visionos"; default: return "SearchTopResults"; } } /** * Creates a new empty `SearchResults` object. * @return {SearchResults} An empty search results model object. */ export function emptyResults(objectGraph, requestFacets) { const searchResults = new models.SearchResults(); if (serverData.isDefinedNonNull(requestFacets)) { searchResults.facets = createSearchFacets(objectGraph, requestFacets); searchResults.pageFacets = createSearchPageFacets(objectGraph); searchResults.selectedFacetOptions = createDefaultSelectedFacetOptions(objectGraph); } searchResults.results = []; return searchResults; } /** * Creates a new empty `SearchResultsPage` object. * @return {SearchResults} An empty search results model object. */ export function emptyResultsPage(objectGraph, requestFacets) { const searchResultsPage = new models.SearchResultsPage([]); if (serverData.isDefinedNonNull(requestFacets)) { searchResultsPage.facets = createSearchFacets(objectGraph, requestFacets); searchResultsPage.pageFacets = createSearchPageFacets(objectGraph); searchResultsPage.selectedFacetOptions = createDefaultSelectedFacetOptions(objectGraph); } return searchResultsPage; } /** * A container like class to manage a search results page segment and surrounding context */ class SegmentedSearchResultsPageSegmentContext { } /** * Builds `BaseSearchPage` from data. */ async function baseSearchPageFromResponse(objectGraph, combinedSearchData) { return await validation.context("searchResultsFromResponse", async () => { var _a; const fetchTimingMetricsBuilder = (_a = objectGraph.fetchTimingMetricsBuilder) !== null && _a !== void 0 ? _a : new FetchTimingMetricsBuilder(); const page = await fetchTimingMetricsBuilder.measureModelConstructionAsync(async () => { var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v; const response = combinedSearchData.catalogResponse; const requestMetadata = combinedSearchData.requestMetadata; const searchRequestUrl = requestMetadata.searchRequestUrl; const sponsoredSearchRequestData = combinedSearchData.sponsoredSearchRequestData; const guidedSearchData = response.results.guidedSearch; // Term Context const termContext = searchCommon.createTermContextForSpellcheckedSequentialResponse(objectGraph, requestMetadata.requestDescriptor, response); // Metrics const wasOdmlSuccessful = searchAdsODML.wasODMLSuccessful(objectGraph, combinedSearchData.sponsoredSearchAdvertData); const metricsOptions = { locationTracker: metricsHelpersLocation.newLocationTracker(), pageInformation: metricsHelpersPage.pageInformationForSearchPage(objectGraph, requestMetadata.requestDescriptor, response, termContext, searchRequestUrl, getSearchResultsPageId(), sponsoredSearchRequestData, wasOdmlSuccessful, guidedSearchData), createUniqueImpressionId: true, }; // Root container const isShelfBasedSearch = objectGraph.featureFlags.isEnabled("shelves_2_0_search") || objectGraph.client.isiOS || objectGraph.client.isTV || objectGraph.client.isVision || objectGraph.client.isWeb; const baseSearchPage = isShelfBasedSearch ? new models.SearchResultsPage() : new models.SearchResults(); // Guided Search Experimentation (pinned vs mid-scroll) const guidedSearchPosition = searchResultsPipeline.guidedSearchPositionFromSearchResponseMeta(response.meta, objectGraph); if (isNothing(guidedSearchPosition)) { /** * Guided Search * - note: This is important to occur before `createSearchResults` since its impression index should be lower than the container for search results. */ addModelsForGuidedSearch(objectGraph, baseSearchPage, requestMetadata, guidedSearchData === null || guidedSearchData === void 0 ? void 0 : guidedSearchData.facets, metricsOptions); } const shelfMetricsOptions = { id: "search-results", kind: null, softwareType: null, targetType: "SearchResults", title: "Search Results", pageInformation: metricsOptions.pageInformation, locationTracker: metricsOptions.locationTracker, idType: "relationship", }; let resultsShelf; if (isShelfBasedSearch) { resultsShelf = new models.Shelf("searchResult"); resultsShelf.id = getSearchResultsShelfId(); resultsShelf.isHorizontal = false; if (objectGraph.client.isWeb) { resultsShelf.title = objectGraph.loc .string("Search.ResultsTitle") .replace("@@search_term@@", termContext.term); } // We need to create and apply impressions fields to the shelf before creating the search results, // so that the shelf is sitting in the right location relative to its children from an impressions perspective. metricsHelpersImpressions.addImpressionFields(objectGraph, resultsShelf, shelfMetricsOptions); metricsHelpersLocation.pushContentLocation(objectGraph, shelfMetricsOptions, "Search Results"); } let advertData = dataForAdvertsFromCombinedData(objectGraph, combinedSearchData); const appliedPolicy = (_a = combinedSearchData.sponsoredSearchAdvertData) === null || _a === void 0 ? void 0 : _a.appliedPolicy; if (isSome(appliedPolicy)) { // If there was an applied policy, suppress the ad. advertData = []; if (appliedPolicy === "ageRestricted") { (_b = metricsOptions.pageInformation) === null || _b === void 0 ? void 0 : _b.iAdInfo.setMissedOpportunity(objectGraph, "ODP_NOAD", "searchResults"); } } const unsafeSearch = (_f = (_e = (_d = (_c = response.meta) === null || _c === void 0 ? void 0 : _c.results) === null || _d === void 0 ? void 0 : _d.search) === null || _e === void 0 ? void 0 : _e.searchSafety) !== null && _f !== void 0 ? _f : false; const missedOpportunityReason = ((_k = (_j = (_h = (_g = response.meta) === null || _g === void 0 ? void 0 : _g.results) === null || _h === void 0 ? void 0 : _h.search) === null || _j === void 0 ? void 0 : _j.reason) === null || _k === void 0 ? void 0 : _k.kind) === "no-results" ? "NLS_NORESULTS" : "NLS_NOAD"; if (unsafeSearch) { // If the search was deemed unsafe, suppress Ad and record the missed opportunity. advertData = []; (_l = metricsOptions.pageInformation) === null || _l === void 0 ? void 0 : _l.iAdInfo.setMissedOpportunity(objectGraph, missedOpportunityReason, "searchResults"); } /** * Advert + Search Results */ const builderResults = await searchResultsPipeline.createSearchResults(objectGraph, requestMetadata, response.meta, metricsOptions, dataForSearchResultsFromCombinedData(objectGraph, combinedSearchData), advertData, guidedSearchData === null || guidedSearchData === void 0 ? void 0 : guidedSearchData.facets, installedStatesForAdvertsData(combinedSearchData), appStatesForAdvertsData(combinedSearchData)); if (unsafeSearch && builderResults.builtSearchResults.length !== 0) { const searchAdOpportunity = builderResults.builtSearchResults[0].lockup .searchAdOpportunity; searchAdOpportunity === null || searchAdOpportunity === void 0 ? void 0 : searchAdOpportunity.setMissedOpportunityReason(missedOpportunityReason); searchAdOpportunity === null || searchAdOpportunity === void 0 ? void 0 : searchAdOpportunity.setTemplateType("APPLOCKUP"); } // Add result shelves to page. if (isShelfBasedSearch && resultsShelf) { const searchResultsPage = baseSearchPage; resultsShelf.items = builderResults.builtSearchResults; searchResultsPage.resultsParentImpressionMetrics = resultsShelf.impressionMetrics; searchResultsPage.shelves.push(resultsShelf); // Display the context message for the results (or lack thereof). const contextCard = createSearchResultsContextCard(response.results.queryContext, objectGraph); const resultsReason = (_p = (_o = (_m = response.meta) === null || _m === void 0 ? void 0 : _m.results) === null || _o === void 0 ? void 0 : _o.search) === null || _p === void 0 ? void 0 : _p.reason; if ((resultsReason === null || resultsReason === void 0 ? void 0 : resultsReason.kind) === "no-results") { searchResultsPage.unavailableReason = { title: objectGraph.loc.stringWithFallback("Search.Results.Empty.Title", "No results"), message: resultsReason.text, action: actionFromSearchResultsLinks(resultsReason.links), contextCard: contextCard, }; } else if (contextCard) { const paragraphShelf = new models.Shelf("searchResultsContextCard"); paragraphShelf.items = [contextCard]; const contextCardPosition = (_t = (_s = (_r = (_q = response.meta) === null || _q === void 0 ? void 0 : _q.displayStyle) === null || _r === void 0 ? void 0 : _r.queryContext) === null || _s === void 0 ? void 0 : _s.position) !== null && _t !== void 0 ? _t : 0; if (contextCardPosition > 0) { const postCardShelfContents = resultsShelf.items.splice(contextCardPosition); const postCardResulsShelf = { ...resultsShelf, id: "searchResults2", items: postCardShelfContents, isValid: resultsShelf.isValid, }; searchResultsPage.shelves.push(paragraphShelf); searchResultsPage.shelves.push(postCardResulsShelf); } else { searchResultsPage.shelves.unshift(paragraphShelf); } } // Remove unsafe searches from recents if no results were provided. if (unsafeSearch && builderResults.builtSearchResults.length === 0) { (_v = (_u = objectGraph.onDeviceSearchHistoryManager).removeRecentSearchTerm) === null || _v === void 0 ? void 0 : _v.call(_u, termContext.term); } } else { const searchResults = baseSearchPage; searchResults.results = builderResults.builtSearchResults; addSearchResultParentImpressionMetrics(objectGraph, searchResults, metricsOptions); } /** * Next Page */ if (builderResults.deferredSearchResults.length > 0) { baseSearchPage.nextPage = searchToken.createSearchToken(objectGraph, builderResults.deferredSearchResults, requestMetadata, response.meta, metricsOptions); } if (isShelfBasedSearch) { metricsHelpersLocation.popLocation(shelfMetricsOptions.locationTracker); } /** * Spell Correction Message */ baseSearchPage.message = searchSpellCorrection.spellCorrectionMessageFromTermContext(objectGraph, termContext, metricsOptions); /** * Search Entity */ const searchEntity = requestMetadata.requestDescriptor.searchEntity; const searchEntitySpecified = !serverData.isNullOrEmpty(searchEntity); if (!searchEntitySpecified) { baseSearchPage.facets = createSearchFacets(objectGraph, requestMetadata.requestDescriptor.facets, combinedSearchData.categoriesFilterData); baseSearchPage.pageFacets = createSearchPageFacets(objectGraph, combinedSearchData.categoriesFilterData); baseSearchPage.selectedFacetOptions = serverData.isDefinedNonNullNonEmpty(combinedSearchData.requestMetadata.requestDescriptor.selectedFacetOptions) ? combinedSearchData.requestMetadata.requestDescriptor.selectedFacetOptions : createDefaultSelectedFacetOptions(objectGraph); } // Attach search term context baseSearchPage.searchTermContext = termContext; // Enable autoplay search results on all clients except tv. baseSearchPage.isAutoPlayEnabled = objectGraph.client.deviceType !== "tv"; baseSearchPage.isCondensedSearchLockupsEnabled = objectGraph.client.isPhone; // Search Transparency baseSearchPage.transparencyLink = createSearchResultsLearnMoreNoticeLinkableText(objectGraph, metricsOptions); metricsHelpersPage.addMetricsEventsToPageWithInformation(objectGraph, baseSearchPage, metricsOptions.pageInformation); baseSearchPage.searchClearAction = createSearchCancelledOrClearedAction(objectGraph, "clear", metricsOptions.pageInformation, metricsOptions.locationTracker, termContext.term); baseSearchPage.searchCancelAction = createSearchCancelledOrClearedAction(objectGraph, "cancel", metricsOptions.pageInformation, metricsOptions.locationTracker, termContext.term); return baseSearchPage; }); return page; }); } // Creates context card model for search results from media API data. export function createSearchResultsContextCard(queryContext, objectGraph) { return validation.context("createSearchResultsContextCard", () => { var _a, _b; if (isNothing(queryContext) || !["iOS", "macOS"].includes(objectGraph.host.platform)) { return undefined; // no server data or platform unsupported } const linkActions = (_a = queryContext.links) === null || _a === void 0 ? void 0 : _a.map(actionFromSearchResultsLink); const linkedSubstrings = linkActions === null || linkActions === void 0 ? void 0 : linkActions.reduce((map, linkAction) => { var _a; if ((_a = linkAction.title) === null || _a === void 0 ? void 0 : _a.length) { if (objectGraph.host.isMac) { linkAction.title += "\u00a0\u2197"; } map[linkAction.title] = linkAction; } return map; }, {}); let rawText = queryContext.text; if ((linkActions === null || linkActions === void 0 ? void 0 : linkActions.length) === 1 && ((_b = linkActions[0].title) === null || _b === void 0 ? void 0 : _b.length)) { // Add single link to end of text, separated by a space. rawText += " " + linkActions[0].title; } else if (linkActions && linkActions.length > 1) { // Add multiple lines below end of text, separated by a new line. if (rawText.length > 0) { rawText += "\n"; } rawText += linkActions .map((action) => action.title) .filter((title) => title === null || title === void 0 ? void 0 : title.length) .join("\n"); } const styledText = new models.StyledText(rawText); const linkableText = new models.LinkableText(styledText, linkedSubstrings); const contextCard = new models.SearchResultsContextCard(linkableText); return contextCard; }); } /// Creates and returns the first url action from the provided link data included in the search results response. function actionFromSearchResultsLinks(linksData) { return validation.context("actionFromSearchResultsLinks", () => { const bestLinkData = linksData === null || linksData === void 0 ? void 0 : linksData.find((linkData) => linkData.url.length > 0); return bestLinkData ? actionFromSearchResultsLink(bestLinkData) : undefined; }); } /// Creates and returns an action from the provided link data included in the search results response. function actionFromSearchResultsLink(linkData) { return validation.context("actionFromSearchResultsLink", () => { var _a; const linkAction = new models.ExternalUrlAction(linkData.url, false); linkAction.title = (_a = linkData.label) === null || _a === void 0 ? void 0 : _a.replace(" ", "\u00a0"); // Use non-breaking spaces for link labels. linkAction.artwork = new models.Artwork("systemimage://arrow.up.forward", 0, 0, []); return linkAction; }); } function installedStatesForAdvertsData(combinedSearchData) { var _a, _b; return (_b = (_a = combinedSearchData.sponsoredSearchAdvertData) === null || _a === void 0 ? void 0 : _a.installedStates) !== null && _b !== void 0 ? _b : {}; } function appStatesForAdvertsData(combinedSearchData) { var _a, _b; return (_b = (_a = combinedSearchData.sponsoredSearchAdvertData) === null || _a === void 0 ? void 0 : _a.appStates) !== null && _b !== void 0 ? _b : {}; } export async function searchResultsFromResponse(objectGraph, combinedSearchData) { return await baseSearchPageFromResponse(objectGraph, combinedSearchData); } export async function searchResultsPageFromResponse(objectGraph, combinedSearchData) { return await baseSearchPageFromResponse(objectGraph, combinedSearchData); } /** * Creates a new empty `SegmentedSearchResultsPage` object. * @return An empty segmented search results model object. */ export function emptySegmentedResultsPage(objectGraph) { return new models.SegmentedSearchResultsPage(); } /** * Creates the segmented search results page form the response * @param objectGraph The app store object graph * @param combinedSearchData The combined data for the segmented search results response * @returns A promise of the segmented search results page */ export async function segmentedSearchResultsPageFromResponse(objectGraph, combinedSearchData) { return await validation.context("segmentedSearchResultsPageFromResponse", async () => { const response = combinedSearchData.catalogResponse; const requestMetadata = combinedSearchData.requestMetadata; if (isSome(response.results.search.groups)) { const fetchPages = response.results.search.groups.map(async (group) => { const segmentResponse = await baseSegmentFromGroupResponse(objectGraph, combinedSearchData, response, group, requestMetadata); return segmentResponse; }); return await Promise.all(fetchPages).then((pageContexts) => { const page = new models.SegmentedSearchResultsPage(); if (serverData.isNullOrEmpty(pageContexts)) { return page; } const segments = pageContexts.map((context) => { return context.segment; }); page.segments = segments; const initialSegmentId = response.results.search.autoSelectedGroupId || segments[0].groupId; page.selectedSegmentId = initialSegmentId; addSegmentChangeActionsToPages(objectGraph, pageContexts, initialSegmentId); return page; }); } else { return new models.SegmentedSearchResultsPage(); } }); } /** * Adds all segment change actions to each search result page segment * @param objectGraph The App Store object graph * @param contexts The segment contexts for the page * @param initialSegmentId The initial segment Id. */ function addSegmentChangeActionsToPages(objectGraph, contexts, initialSegmentId) { const firstNonEmptySegmentContext = contexts.find((context) => { const hasNonEmptyPage = context.segment.page.shelves.some((shelf) => { const isNotEmpty = shelf.items.length > 0; return isNotEmpty; }); return hasNonEmptyPage; }); const firstNonEmptySegmentId = firstNonEmptySegmentContext === null || firstNonEmptySegmentContext === void 0 ? void 0 : firstNonEmptySegmentContext.segment.groupId; const segments = contexts.map((context) => { return context.segment; }); const nativeSegmentGroupId = segmentGroupIdForPlatform(objectGraph, segments); // Check if the native segment has content. const nativeSegmentIsNonEmpty = segments.some((segment) => { if (segment.groupId !== nativeSegmentGroupId) { return false; } return segment.page.shelves.some((shelf) => { return shelf.items.length > 0; }); }); contexts.forEach((context) => { context.segment.emptySegmentChangeAction = segmentChangeAction(objectGraph, SegmentChangeReason.EmptyResults, context.segment.page.id, context.metricsOptions, firstNonEmptySegmentId, context.segment.groupId); // Only add the non-native segment change action if the native segment has content, and // the initial segment wasn't the native segment. if (nativeSegmentIsNonEmpty && initialSegmentId !== nativeSegmentGroupId) { context.segment.nonNativeSegmentChangeAction = segmentChangeAction(objectGraph, SegmentChangeReason.NonNative, context.segment.page.id, context.metricsOptions, nativeSegmentGroupId, context.segment.groupId); } context.segment.segmentPickerSegmentChangeAction = segmentChangeAction(objectGraph, SegmentChangeReason.Picker, context.segment.page.id, context.metricsOptions, context.segment.groupId); }); } /** * * @param objectGraph The app store object graph * @param combinedSearchData The combined search results which contains the response for segmented results * @param groupResponse The response for the segmented results * @param searchData The data for a single search results segment * @param requestMetadata The metadata from the search request * @returns A promise of a segmented search results page segment */ async function baseSegmentFromGroupResponse(objectGraph, combinedSearchData, groupResponse, searchData, requestMetadata) { return await validation.context("baseSearchPageFromGroupResponse", async () => { const searchRequestUrl = requestMetadata.searchRequestUrl; const segmentType = segmentTypeFromGroupId(searchData.groupId); // Term Context const termContext = searchCommon.createTermContextForSpellcheckedGroupedResponse(objectGraph, requestMetadata.requestDescriptor, groupResponse); // Metrics const metricsOptions = { locationTracker: metricsHelpersLocation.newLocationTracker(), pageInformation: metricsHelpersPage.pageInformationForSearchPage(objectGraph, requestMetadata.requestDescriptor, groupResponse, termContext, searchRequestUrl, getSearchResultsPageId(segmentType), null, false, null), createUniqueImpressionId: true, }; const shelfMetricsOptions = { id: "search-results", kind: null, softwareType: null, targetType: "SearchResults", title: "Search Results", pageInformation: metricsOptions.pageInformation, locationTracker: metricsOptions.locationTracker, idType: "relationship", }; // Root container const searchResultsPage = new models.SearchResultsPage(); const searchSegment = new models.SegmentedSearchResultsPageSegment(); const resultsShelf = new models.Shelf("searchResult"); resultsShelf.isHorizontal = false; // We need to create and apply impressions fields to the shelf before creating the search results, // so that the shelf is sitting in the right location relative to its children from an impressions perspective. metricsHelpersImpressions.addImpressionFields(objectGraph, resultsShelf, shelfMetricsOptions); metricsHelpersLocation.pushContentLocation(objectGraph, shelfMetricsOptions, "Search Results"); const resultsData = mediaDataStructure.dataCollectionFromDataContainer(searchData); const builderResults = await searchResultsPipeline.createSearchResults(objectGraph, requestMetadata, groupResponse.meta, metricsOptions, resultsData); const learnMoreNoticeLinkableText = createSearchResultsLearnMoreNoticeLinkableText(objectGraph, metricsOptions); const shelves = searchResultsShelvesFromBuilderResults(objectGraph, learnMoreNoticeLinkableText, builderResults, resultsShelf); searchResultsPage.shelves.push(...shelves); searchResultsPage.resultsParentImpressionMetrics = resultsShelf.impressionMetrics; if (builderResults.deferredSearchResults.length > 0) { searchResultsPage.nextPage = searchToken.createSearchToken(objectGraph, builderResults.deferredSearchResults, requestMetadata, groupResponse.meta, metricsOptions); } metricsHelpersLocation.popLocation(shelfMetricsOptions.locationTracker); /** * Spell Correction Message */ searchResultsPage.message = searchSpellCorrection.spellCorrectionMessageFromTermContext(objectGraph, termContext, metricsOptions); // Enable autoplay search results on all clients except tv. searchResultsPage.isAutoPlayEnabled = objectGraph.client.deviceType !== "tv"; searchResultsPage.isCondensedSearchLockupsEnabled = objectGraph.client.isPhone; // Search Transparency searchResultsPage.transparencyLink = learnMoreNoticeLinkableText; metricsHelpersPage.addMetricsEventsToPageWithInformation(objectGraph, searchResultsPage, metricsOptions.pageInformation); searchSegment.groupId = searchData.groupId; resultsShelf.id = getSearchResultsShelfId(searchSegment.groupId); searchSegment.page = searchResultsPage; searchSegment.title = segmentTitleForSegmentType(objectGraph, false, segmentType); return { segment: searchSegment, metricsOptions: metricsOptions, }; }); } /** * Get an array of shelves from the search builder results. * This is used to decide where to insert the search results learn more notice across * initial and paginated page builds. * * This is currently only used on visionOS search paths, but has the appropriate checks to ensure * it doesn't run on unsupported platforms, so could be used in all search code paths. * @param objectGraph The Object Graph. * @param learnMoreNoticeLinkableText The linkable text to display as part of the learn more notice. * @param builderResults The results from `createSearchResults`. * @param resultsShelf The initial shelf created to hold the results. This is passed in * because in many cases a reference to this is required later when building out the page. * @param startingContentOffsetWithinResults The starting content offset before the newly built * content is added. For an initial page build, this will be zero, and for paginated pages this * is stored on the token. * @returns An array of shelves for a search results page, with the learn more notice inserted * if required. */ function searchResultsShelvesFromBuilderResults(objectGraph, learnMoreNoticeLinkableText, builderResults, resultsShelf, startingContentOffsetWithinResults = 0) { let learnMoreNoticeIndex = searchResultsLearnMoreNoticeIndex(objectGraph); const builtResultsCount = builderResults.builtSearchResults.length; const hasDeferredResults = builderResults.deferredSearchResults.length > 0; // The total built results for the page so far. This is the initial page, plus any paginated content. const totalBuiltResults = startingContentOffsetWithinResults + builtResultsCount; // There are a number of conditions we don't want to insert the learn more notice: // 1. No index at which to insert it - this generally means the platform doesn't support this method of insertion. // 2. No linkable text object - the bag key wasn't present. // 3. The starting offset is greater than or equal to the insertion index - we can assume we've already shown the // notice in a previous page build. // 4. We have deferred results and the total built results count is less than the threshold - we'll insert the // notice in a subsequent fetch. // 5. We have no built results and no deferred results, ie. the page will be empty. // If we fail any of these conditions, we will just attach all the items into the single shelf and return that. if (learnMoreNoticeIndex === undefined || learnMoreNoticeLinkableText === undefined || startingContentOffsetWithinResults >= learnMoreNoticeIndex || (hasDeferredResults && totalBuiltResults < learnMoreNoticeIndex) || (totalBuiltResults === 0 && !hasDeferredResults)) { resultsShelf.items = builderResults.builtSearchResults; return [resultsShelf]; } // Adjust the index by the starting offset, to ensure we take any items built in the initial fetch into account. learnMoreNoticeIndex = learnMoreNoticeIndex - startingContentOffsetWithinResults; // We have satisfied the insertion conditions for the learn more notice - we need to split the shelf in two to incorporate // the learn more notice as its own shelf in between. This is due to layout restrictions on visionOS where we cannot // accommodate a full width item in a grid of two items per row. resultsShelf.items = builderResults.builtSearchResults.slice(0, learnMoreNoticeIndex); const learnMoreNoticeShelf = new models.Shelf("searchResultsLearnMoreNotice"); learnMoreNoticeShelf.isHorizontal = false; const searchResult = new SearchResultsLearnMoreNotice(learnMoreNoticeLinkableText); learnMoreNoticeShelf.items = [searchResult]; const shelves = [resultsShelf, learnMoreNoticeShelf]; const secondResultsShelfItems = builderResults.builtSearchResults.slice(learnMoreNoticeIndex); if (secondResultsShelfItems.length > 0) { const secondResultsShelf = new models.Shelf("searchResult"); // Give the second results shelf the same impression metrics. This will result in each shelf contributing to the same impression metrics, // the only difference is we will see two "viewedInfo" entries for each distinct shelf within the top level impressions item. secondResultsShelf.impressionMetrics = resultsShelf.impressionMetrics; secondResultsShelf.isHorizontal = false; secondResultsShelf.items = secondResultsShelfItems; shelves.push(secondResultsShelf); } return shelves; } /** * Get the index for where the Search Results Learn More Notice should be inserted. * Returns undefined if either: * - the editorial ID is not available in the bag, or * - the platform does not support this style of presentation. * @param objectGraph The Object Graph */ function searchResultsLearnMoreNoticeIndex(objectGraph) { const editorialItemId = objectGraph.bag.searchResultsLearnMoreEditorialId; if (isNothing(editorialItemId) || (editorialItemId === null || editorialItemId === void 0 ? void 0 : editorialItemId.length) === 0) { return undefined; } if (objectGraph.client.isVision) { return 6; } return undefined; } export async function paginatedSearchResultsWithToken(objectGraph, token) { return await validation.context("paginatedSearchResultsWithToken", async () => { const nextItemsToFetch = searchToken.getNextItemsToFetch(objectGraph, token); const advancedToken = searchToken.advanceSearchTokenResults(objectGraph, token); if (nextItemsToFetch.length === 0) { return await Promise.resolve(emptyResults(objectGraph)); } return await searchResultsFetching .fetchSearchResultItems(objectGraph, nextItemsToFetch) .then(async (dataContainer) => { const resultsDatum = mediaDataStructure.dataCollectionFromDataContainer(dataContainer); return await searchResultsPipeline .createSearchResults(objectGraph, token.requestMetadata, token.responseMetadata, token.metricsOptions, resultsDatum) .then((builderResults) => { const searchResults = new models.SearchResults(); searchResults.results = builderResults.builtSearchResults; searchResults.nextPage = advancedToken; return searchResults; }); }); }); } export async function paginatedSearchResultsPageWithToken(objectGraph, token) { return await validation.context("paginatedSearchResultsPageWithToken", async () => { const nextItemsToFetch = searchToken.getNextItemsToFetch(objectGraph, token); const advancedToken = searchToken.advanceSearchTokenResults(objectGraph, token); if (nextItemsToFetch.length === 0) { return await Promise.resolve(emptyResultsPage(objectGraph)); } return await searchResultsFetching .fetchSearchResultItems(objectGraph, nextItemsToFetch) .then(async (dataContainer) => { const resultsDatum = mediaDataStructure.dataCollectionFromDataContainer(dataContainer); const shelfMetricsOptions = { id: "search-results", kind: null, softwareType: null, targetType: "SearchResults", title: "Search Results", pageInformation: token.metricsOptions.pageInformation, locationTracker: token.metricsOptions.locationTracker, idType: "relationship", }; // Set up the "new" shelf const resultsShelf = new models.Shelf("searchResult"); resultsShelf.id = getSearchResultsShelfId(); resultsShelf.isHorizontal = false; // Shelf Impressions: Add impression fields. // This shelf, and the associated metrics data isn't really used - it's basically just a vehicle for the new items to be // merged into the old page/shelf. Even so, we create and apply impressions metrics correctly before setting the content // location and current position so it looks correct. metricsHelpersImpressions.addImpressionFields(objectGraph, resultsShelf, shelfMetricsOptions); // Set the content location to the "search-results" shelf. metricsHelpersLocation.pushContentLocation(objectGraph, shelfMetricsOptions, "Search Results"); // Update position to content offset within the same search shelf prior to building the new results. metricsHelpersLocation.setCurrentPosition(shelfMetricsOptions.locationTracker, token.contentOffsetWithinResultsShelf); return await searchResultsPipeline .createSearchResults(objectGraph, token.requestMetadata, token.responseMetadata, token.metricsOptions, resultsDatum) .then((builderResults) => { const learnMoreNoticeLinkableText = createSearchResultsLearnMoreNoticeLinkableText(objectGraph, shelfMetricsOptions); const shelves = searchResultsShelvesFromBuilderResults(objectGraph, learnMoreNoticeLinkableText, builderResults, resultsShelf, token.contentOffsetWithinResultsShelf); const searchResultsPage = new models.SearchResultsPage(shelves); // Ensure we increment the offset for any future paginated results. if (serverData.isDefinedNonNull(advancedToken)) { advancedToken.contentOffsetWithinResultsShelf = metricsHelpersLocation.currentPosition(shelfMetricsOptions.locationTracker); searchResultsPage.nextPage = advancedToken; } searchResultsPage.isCondensedSearchLockupsEnabled = objectGraph.client.isPhone; searchResultsPage.resultsParentImpressionMetrics = resultsShelf.impressionMetrics; metricsHelpersLocation.popLocation(token.metricsOptions.locationTracker); searchResultsPage.searchClearAction = createSearchCancelledOrClearedAction(objectGraph, "clear", token.metricsOptions.pageInformation, token.metricsOptions.locationTracker, token.requestMetadata.requestDescriptor.term); searchResultsPage.searchCancelAction = createSearchCancelledOrClearedAction(objectGraph, "cancel", token.metricsOptions.pageInformation, token.metricsOptions.locationTracker, token.requestMetadata.requestDescriptor.term); return searchResultsPage; }); }); }); } // region Extracting Data /** * Returns the array of data objects to build search results with. */ function dataForSearchResultsFromCombinedData(objectGraph, combinedSearchData) { return mediaDataStructure.dataCollectionFromDataContainer(combinedSearchData.catalogResponse.results.search); } /** * Returns the array of data objects to build search adverts with. */ function dataForAdvertsFromCombinedData(objectGraph, combinedSearchData) { const rawAdvertsData = mediaDataStructure.dataCollectionFromDataContainer(combinedSearchData.catalogResponse.results["ads-result"]); return searchAdsODML.applyNativeAdvertData(objectGraph, rawAdvertsData, combinedSearchData.sponsoredSearchAdvertData); } // endregion // region Guided Search /** * Add models for Guided Search into `SearchResults` model, specifically: * - `GuidedSearchToken`s * - `GuidedSearchQuery`s * - Metrics Container for tokens. */ function addModelsForGuidedSearch(objectGraph, searchResultsPage, requestMetadata, facetData, metricsOptions) { if (!objectGraph.host.isiOS) { return; } const request = requestMetadata.requestDescriptor; metricsHelpersLocation.pushBasicLocation(objectGraph, { pageInformation: metricsOptions.pageInformation, locationTracker: metricsOptions.locationTracker, targetType: "SearchRevisions", }, ""); const tokens = []; // Tokens from facet data, if any if (serverData.isDefinedNonNullNonEmpty(facetData)) { for (const data of facetData) { const token = guidedSearch.createGuidedSearchToken(objectGraph, "toggle", request, data, metricsOptions); if (token) { tokens.push(token); metricsHelpersLocation.nextPosition(metricsOptions.locationTracker); } } } // Token from selected entity hint, iff there aren't tokens already if (serverData.isNullOrEmpty(tokens) && requestMetadata.requestDescriptor.searchEntity) { const entityClearingToken = guidedSearch.createGuidedSearchTokenClearingEntityFilter(objectGraph, requestMetadata.requestDescriptor, metricsOptions); tokens.push(entityClearingToken); metricsHelpersLocation.nextPosition(metricsOptions.locationTracker); } const queries = guidedSearch.createGuidedSearchQueries(objectGraph, requestMetadata.requestDescriptor, facetData); metricsHelpersLocation.popLocation(metricsOptions.locationTracker); if (serverData.isDefinedNonNullNonEmpty(tokens)) { searchResultsPage.guidedSearchTokens = tokens; searchResultsPage.guidedSearchQueries = queries; addGuidedSearchParentImpressionMetrics(objectGraph, searchResultsPage, metricsOptions); metricsHelpersLocation.nextPosition(metricsOptions.locationTracker); // increment **after** assigning guided token parent impression. } } /// The contextual reason for the segment change var SegmentChangeReason; (function (SegmentChangeReason) { SegmentChangeReason[SegmentChangeReason["EmptyResults"] = 0] = "EmptyResults"; SegmentChangeReason[SegmentChangeReason["Picker"] = 1] = "Picker"; SegmentChangeReason[SegmentChangeReason["NonNative"] = 2] = "NonNative"; })(SegmentChangeReason || (SegmentChangeReason = {})); /** * Creates a segment change action for moving from one segment to another for a specific reason * @param objectGraph The App Store object graph * @param reason The reason why the segment is changing * @param pageId The search results page id * @param metricsOptions The metrics options for the segment this action will attach to * @param switchingToGroupId The groupId of the segment we are switching to * @param switchingFromGroupId The groupId of the segment we are switching from (the current segment) * @returns A segment change action for the specific context and locations */ function segmentChangeAction(objectGraph, reason, pageId, metricsOptions, switchingToGroupId, switchingFromGroupId) { const includeAppsSuffix = reason !== SegmentChangeReason.Picker; const switchingToSegmentTitle = segmentTitleForSegmentType(objectGraph, includeAppsSuffix, segmentTypeFromGroupId(switchingToGroupId)); let action; switch (reason) { case SegmentChangeReason.EmptyResults: if (isNothing(switchingToSegmentTitle) || switchingFromGroupId === switchingToGroupId) { return undefined; } metricsHelpersLocation.pushBasicLocation(objectGraph, { pageInformation: metricsOptions.pageInformation, locationTracker: metricsOptions.locationTracker, targetType: "SearchResults", }, "emptyResultsSegmentSwitch"); const emptyResultsTitle = objectGraph.loc .string("SEARCH_RESULTS_SWITCH_TO_OTHER_RESULTS") .replace("{platformApps}", switchingToSegmentTitle); action = new models.SearchPageSegmentChangeAction(switchingToGroupId, switchingToSegmentTitle, new models.StyledText(emptyResultsTitle)); metricsHelpersClicks.addClickEventToSearchPageSegmentChangeAction(objectGraph, action, "button", metricsOptions.locationTracker); metricsHelpersLocation.popLocation(metricsOptions.locationTracker); break; case SegmentChangeReason.Picker: if (isNothing(switchingToSegmentTitle)) { return undefined; } metricsHelpersLocation.pushBasicLocation(objectGraph, { pageInformation: metricsOptions.pageInformation, locationTracker: metricsOptions.locationTracker, targetType: "SearchResults", }, "searchResultsSegmentSwitch"); action = new models.SearchPageSegmentChangeAction(switchingToGroupId, switchingToSegmentTitle); metricsHelpersClicks.addClickEventToSearchPageSegmentChangeAction(objectGraph, action, "button", metricsOptions.locationTracker); metricsHelpersLocation.popLocation(metricsOptions.locationTracker); break; case SegmentChangeReason.NonNative: const currentSegmentType = segmentTypeFromGroupId(switchingFromGroupId); if (currentSegmentType === segmentTypeForPlatform(objectGraph)) { return undefined; } const switchingFromSegmentTitle = segmentTitleForSegmentType(objectGraph, true, segmentTypeFromGroupId(switchingFromGroupId)); const nonNativeTitle = objectGraph.loc .string("Search.Results.ShowingNonNativeResults") .replace("@@current_platform_apps@@", switchingFromSegmentTitle) .replace("@@native_platform_apps@@", switchingToSegmentTitle); metricsHelpersLocation.pushBasicLocation(objectGraph, { pageInformation: metricsOptions.pageInformation, locationTracker: metricsOptions.locationTracker, targetType: "SearchResults", }, "nonNativeResultsSegmentSwitch"); action = new models.SearchPageSegmentChangeAction(switchingToGroupId, switchingToSegmentTitle, new models.StyledText(nonNativeTitle, "text/x-apple-as3-nqml")); metricsHelpersLocation.popLocation(metricsOptions.locationTracker); break; default: break; } const clickActionOptions = { actionType: "navigate", id: pageId, targetType: "button", pageInformation: metricsOptions.pageInformation, locationTracker: metricsOptions.locationTracker, }; metricsHelpersClicks.addClickEventToAction(objectGraph, action, clickActionOptions); return action; } /** * Gets the segment type for the given group id * @param groupId The group id of the segment * @returns The segment type for the group id */ function segmentTypeFromGroupId(groupId) { if (isSome(groupId)) { switch (groupId) { case "iOS": case "ios": return models.SegmentedSearchResultsPageSegmentType.iOS; case "xrOS": case "xros": return models.SegmentedSearchResultsPageSegmentType.visionOS; default: return undefined; } } return undefined; } /** * Gets the segment type for the current platform * @param objectGraph The App Store object graph * @returns The segment type for the current platform */ function segmentTypeForPlatform(objectGraph) { switch (objectGraph.client.deviceType) { case "vision": return models.SegmentedSearchResultsPageSegmentType.visionOS; case "pad": case "phone": return models.SegmentedSearchResultsPageSegmentType.iOS; default: return undefined; } } /** * Gets the segment title for the given segment type * @param objectGraph The App Store object graph * @param includeAppsSuffix Whether to include the word "Apps" at the end of the title * @param segmentType The segment type we want the title of * @returns The segment title for the segment type */ function segmentTitleForSegmentType(objectGraph, includeAppsSuffix, segmentType) { switch (segmentType) { case models.SegmentedSearchResultsPageSegmentType.visionOS: return includeAppsSuffix ? objectGraph.loc.string("SEARCH_RESULTS_VISION_APPS_TITLE") : objectGraph.loc.string("SEARCH_RESULTS_VISION_TITLE"); case models.SegmentedSearchResultsPageSegmentType.iOS: return includeAppsSuffix ? objectGraph.loc.string("SEARCH_RESULTS_IPHONE_IPAD_APPS_TITLE") : objectGraph.loc.string("SEARCH_RESULTS_IPHONE_IPAD_TITLE"); default: return undefined; } } /** * Gets the group id for the current platform * @param objectGraph The App Store object graph * @param segments The segments on the search results page * @returns The group id for the current platform */ function segmentGroupIdForPlatform(objectGraph, segments) { const nativeSegmentType = segmentTypeForPlatform(objectGraph); const nativeSegment = segments.find((segment) => { return segmentTypeFromGroupId(segment.groupId) === nativeSegmentType; }); return nativeSegment === null || nativeSegment === void 0 ? void 0 : nativeSegment.groupId; } /** * * @param objectGraph The App Store Object Graph * @param searchClearActionType The way the user cleared the search (x in search field or cancel button in toolbar) * @param pageInformation The metrics page information * @param locationTracker The metrics location tracker * @param searchTerm The current search term * @returns An action to trigger when the search is cancelled or cleared */ export function createSearchCancelledOrClearedAction(objectGraph, searchClearActionType, pageInformation, locationTracker, searchTerm) { const action = new models.BlankAction(); let type; let targetId; switch (searchClearActionType) { case "cancel": type = "dismiss"; targetId = "cancel"; break; case "clear": type = "delete"; targetId = "clear"; break; default: break; } metricsHelpersClicks.addClickEventToSearchCancelOrDismissAction(objectGraph, action, { targetType: "button", id: targetId, idType: undefined, actionType: type, pageInformation: pageInformation, locationTracker: locationTracker, }, "button", searchTerm); return action; } //# sourceMappingURL=search.js.map