From bce557cc2dc767628bed6aac87301a1be7c5431b Mon Sep 17 00:00:00 2001 From: rxliuli Date: Tue, 4 Nov 2025 05:03:50 +0800 Subject: init commit --- src/jet/dependencies/bag.ts | 290 +++++++++++++++ src/jet/dependencies/client.ts | 96 +++++ src/jet/dependencies/console.ts | 26 ++ src/jet/dependencies/feature-flags.ts | 20 ++ src/jet/dependencies/locale.ts | 99 ++++++ src/jet/dependencies/localization.ts | 523 ++++++++++++++++++++++++++++ src/jet/dependencies/make-dependencies.ts | 45 +++ src/jet/dependencies/media-token-service.ts | 11 + src/jet/dependencies/metrics-identifiers.ts | 13 + src/jet/dependencies/net.ts | 117 +++++++ src/jet/dependencies/object-graph.ts | 59 ++++ src/jet/dependencies/properties.ts | 5 + src/jet/dependencies/seo.ts | 254 ++++++++++++++ src/jet/dependencies/storage.ts | 44 +++ src/jet/dependencies/user.ts | 30 ++ 15 files changed, 1632 insertions(+) create mode 100644 src/jet/dependencies/bag.ts create mode 100644 src/jet/dependencies/client.ts create mode 100644 src/jet/dependencies/console.ts create mode 100644 src/jet/dependencies/feature-flags.ts create mode 100644 src/jet/dependencies/locale.ts create mode 100644 src/jet/dependencies/localization.ts create mode 100644 src/jet/dependencies/make-dependencies.ts create mode 100644 src/jet/dependencies/media-token-service.ts create mode 100644 src/jet/dependencies/metrics-identifiers.ts create mode 100644 src/jet/dependencies/net.ts create mode 100644 src/jet/dependencies/object-graph.ts create mode 100644 src/jet/dependencies/properties.ts create mode 100644 src/jet/dependencies/seo.ts create mode 100644 src/jet/dependencies/storage.ts create mode 100644 src/jet/dependencies/user.ts (limited to 'src/jet/dependencies') diff --git a/src/jet/dependencies/bag.ts b/src/jet/dependencies/bag.ts new file mode 100644 index 0000000..32f6bc7 --- /dev/null +++ b/src/jet/dependencies/bag.ts @@ -0,0 +1,290 @@ +import type { Bag as NativeBag, BagKeyDescriptor } from '@jet/environment'; +import type { Opt } from '@jet/environment'; +import type { Logger, LoggerFactory } from '@amp/web-apps-logger'; + +import type { Locale } from './locale'; +import { + EU_STOREFRONTS, + SUPPORTED_STOREFRONTS_FOR_VISION, + UNSUPPORTED_STOREFRONTS_FOR_ARCADE, +} from '~/constants/storefront'; + +export type BagRetrievalMethod = Exclude; + +export function makeUnimplementedKeyRequestWarning( + method: BagRetrievalMethod, + key: string, +) { + return `requested unimplemented \`${method}\` key \`${key}\``; +} + +export class WebBag implements NativeBag { + private readonly log: Logger; + private readonly locale: Locale; + + constructor(loggerFactory: LoggerFactory, locale: Locale) { + this.log = loggerFactory.loggerFor('Bag'); + this.locale = locale; + } + + private provideNoValue(method: BagRetrievalMethod, key: string): null { + this.log.warn(makeUnimplementedKeyRequestWarning(method, key)); + + return null; + } + + registerBagKeys(_keys: BagKeyDescriptor[]): void { + // We hardcode, so registration is a no-op + } + + double(key: string): Opt { + switch (key) { + case 'game-controller-recommended-rollout-rate': + return 1.0; // set to 1.0 to enable `learn more` button for game controller capability + case 'icon-artwork-rollout-rate': + return 1.0; // set to 1.0 to enable new icon artwork style + default: + return this.provideNoValue('double', key); + } + } + + integer(key: string): Opt { + return this.provideNoValue('integer', key); + } + + boolean(key: string): Opt { + switch (key) { + case 'enableAppEvents': + return true; + case 'enable-app-accessibility-labels': + return true; + case 'enable-app-store-age-ratings': + return true; + case 'enable-external-purchase': + return true; + case 'enable-privacy-nutrition-labels': + return true; + case 'enable-system-app-reviews': + return true; + case 'enable-vision-platform': + return SUPPORTED_STOREFRONTS_FOR_VISION.has( + this.locale.activeStorefront, + ); + case 'arcade-enabled': + return !UNSUPPORTED_STOREFRONTS_FOR_ARCADE.has( + this.locale.activeStorefront, + ); + + // Enable required `GroupingPage` features + case 'enable-featured-categories-on-groupings': + case 'enable-category-bricks-on-groupings': + return true; + case 'enable-seller-info': + return true; + case 'enable-preview-platform-for-web': + return false; + case 'enableProductPageVariants': + return true; + case 'game-center-extend-supported-features': + return true; + case 'enable-product-page-install-size': + return true; + case 'enable-icon-artwork': + return true; + default: + return this.provideNoValue('boolean', key); + } + } + + array(key: string): Opt { + switch (key) { + // URL patterns that are opted into the "edge" domains + // https://github.pie.apple.com/app-store/ios-appstore-app/blob/83834eea5dfcad22d902fe395c4d140ec7fa8cea/src/foundation/media/url-builder.ts#L350 + case 'apps-media-api-edge-end-points': + return [ + // Including a pattern that matches our "search" API endpoint ensures + // that the built URL uses the `apps-media-api-search-edge-host` host + // https://github.pie.apple.com/app-store/ios-appstore-app/blob/83834eea5dfcad22d902fe395c4d140ec7fa8cea/src/foundation/media/url-builder.ts#L352 + '/search', + ]; + case 'enabled-external-purchase-placements': + return ['product-page-banner', 'product-page-info-section']; + case 'tabs/standard': + return [ + { + id: 'today', + title: this.locale.i18n.t( + 'ASE.Web.AppStore.Navigation.LandingPage.Today', + ), + 'image-identifier': 'text.rectangle.page', + }, + { + id: 'apps', + title: this.locale.i18n.t( + 'ASE.Web.AppStore.Navigation.LandingPage.Apps', + ), + 'image-identifier': 'app.3.stack.3d.fill', + }, + { + id: 'apps-and-games', + title: this.locale.i18n.t( + 'ASE.Web.AppStore.Navigation.LandingPage.AppsAndGames', + ), + 'image-identifier': 'rocket.fill', + }, + { + id: 'arcade', + title: this.locale.i18n.t( + 'ASE.Web.AppStore.Navigation.LandingPage.Arcade', + ), + 'image-identifier': 'joystickcontroller.fill', + }, + { + id: 'create', + title: this.locale.i18n.t( + 'ASE.Web.AppStore.Navigation.LandingPage.Create', + ), + 'image-identifier': 'paintbrush.fill', + }, + { + id: 'discover', + title: this.locale.i18n.t( + 'ASE.Web.AppStore.Navigation.LandingPage.Discover', + ), + 'image-identifier': 'star.fill', + }, + { + id: 'games', + title: this.locale.i18n.t( + 'ASE.Web.AppStore.Navigation.LandingPage.Games', + ), + 'image-identifier': 'rocket.fill', + }, + { + id: 'work', + title: this.locale.i18n.t( + 'ASE.Web.AppStore.Navigation.LandingPage.Work', + ), + 'image-identifier': 'paperplane.fill', + }, + { + id: 'play', + title: this.locale.i18n.t( + 'ASE.Web.AppStore.Navigation.LandingPage.Play', + ), + 'image-identifier': 'rocket.fill', + }, + { + id: 'develop', + title: this.locale.i18n.t( + 'ASE.Web.AppStore.Navigation.LandingPage.Develop', + ), + 'image-identifier': 'hammer.fill', + }, + { + id: 'categories', + title: this.locale.i18n.t( + 'ASE.Web.AppStore.Navigation.LandingPage.Categories', + ), + 'image-identifier': 'square.grid.2x2.fill', + }, + { + id: 'search', + title: this.locale.i18n.t( + 'ASE.Web.AppStore.Navigation.LandingPage.Search', + ), + 'image-identifier': 'magnifyingglass', + }, + ]; + default: + return this.provideNoValue('array', key); + } + } + + dictionary(key: string): Opt { + return this.provideNoValue('dictionary', key); + } + + url(key: string): Opt { + switch (key) { + case 'apps-media-api-host': + return 'amp-api-edge.apps.apple.com'; + case 'apps-media-api-edge-host': + return 'amp-api-edge.apps.apple.com'; + case 'apps-media-api-search-edge-host': + return 'amp-api-search-edge.apps.apple.com'; + + default: + return this.provideNoValue('url', key); + } + } + + string(key: string): Opt { + switch (key) { + case 'countryCode': + return this.locale.activeStorefront; + + case 'language-tag': + return this.locale.activeLanguage; + + case 'language': + // TODO: rdar://78159789: util for this? What about zh-Hant, etc. + return this.locale.activeLanguage.split('-')[0]; + + // Some URLs are accessed as strings + // TODO: fix this upstream in `ios-appstore-app` so it uses `.url()` instead + case 'apps-media-api-edge-host': + case 'apps-media-api-search-edge-host': + return this.url(key); + + case 'game-controller-learn-more-editorial-item-id': + return '1687769242'; + + case 'familySubscriptionsLearnMoreEditorialItemId': + return '1563279606'; + + case 'external-purchase-learn-more-editorial-item-id': + if (this.locale.activeStorefront === 'kr') { + return 'id1727067165'; + } + + return 'id1760810284'; + + case 'appPrivacyLearnMoreEditorialItemId': + return 'id1538632801'; + + case 'ageRatingLearnMoreEditorialItemId': + return '1825160725'; + + case 'accessibility-learn-more-editorial-item-id': + return '1814164299'; + + case 'external-purchase-product-page-banner-text-variant': + return '2'; + case 'external-purchase-product-page-annotation-variant': + return '4'; + + case 'transparencyLawEditorialItemId': + if (EU_STOREFRONTS.includes(this.locale.activeStorefront)) { + return 'id1620909697'; + } + + return null; + + case 'appPrivacyDefinitionsEditorialItemId': + return '1539235847'; + + case 'metrics_topic': + return 'xp_amp_appstore_unidentified'; + + case 'in-app-purchases-learn-more-editorial-item-id': + return '1436214772'; + + case 'web-navigation-category-tabs-editorial-item-id': + return '1842456901'; + + default: + return this.provideNoValue('string', key); + } + } +} diff --git a/src/jet/dependencies/client.ts b/src/jet/dependencies/client.ts new file mode 100644 index 0000000..6b8a979 --- /dev/null +++ b/src/jet/dependencies/client.ts @@ -0,0 +1,96 @@ +import type { Locale } from './locale'; + +export class WebClient implements Client { + private readonly locale: Locale; + + deviceType: DeviceType = 'web'; + + // Tell the App Store Client that we're *really* the "web", even if the `DeviceType` + // says otherwise + __isReallyWebClient = true as const; + + // TODO: how do we define this for the "client" web, when it can change over time? + screenSize: { width: number; height: number } = { width: 0, height: 0 }; + + // TODO: how is this used? We can't have a consistent value across multiple sessions + guid: string = 'xxx-xx-xxx'; + + screenCornerRadius: number = 0; + + newPaymentMethodEnabled = false; + + isActivityAvailable = false; + + isElectrocardiogramInstallationAllowed = false; + + isScandiumInstallationAllowed = false; + + isSidepackingEnabled = false; + + isTinkerWatch = false; + + supportsHEIF: boolean = false; + + isMandrakeSupported: boolean = false; + + isCharonSupported: boolean = false; + + buildType: BuildType; + + maxAppContentRating: number = 1000; + + isIconArtworkCapable: boolean = true; + + constructor(buildType: BuildType, locale: Locale) { + this.buildType = buildType; + this.locale = locale; + } + + get storefrontIdentifier(): string { + return this.locale.activeStorefront; + } + + deviceHasCapabilities(_capabilities: string[]): boolean { + return false; + } + + deviceHasCapabilitiesIncludingCompatibilityCheckIsVisionOSCompatibleIOSApp( + _capabilities: string[], + _supportsVisionOSCompatibleIOSBinary: boolean, + ): boolean { + return false; + } + + isActivePairedWatchSystemVersionAtLeastMajorVersionMinorVersionPatchVersion( + _majorVersion: number, + _minorVersion: number, + _patchVersion: number, + ): boolean { + return false; + } + + canDevicePerformAppActionWithAppCapabilities( + _appAction: string, + _appCapabilities: string[] | undefined | null, + ): boolean { + return false; + } + + isAutomaticDownloadingEnabled(): boolean { + return false; + } + + isAuthorizedForUserNotifications(): boolean { + return false; + } + + deletableSystemAppCanBeInstalledOnWatchWithBundleID( + _bundleId: string, + ): boolean { + return false; + } + + isDeviceEligibleForDomain(_domain: string): boolean { + return false; + } +} diff --git a/src/jet/dependencies/console.ts b/src/jet/dependencies/console.ts new file mode 100644 index 0000000..fe0ba64 --- /dev/null +++ b/src/jet/dependencies/console.ts @@ -0,0 +1,26 @@ +import type { LoggerFactory, Logger } from '@amp/web-apps-logger'; +import type { RequiredConsole } from '@jet-app/app-store/foundation/wrappers/console'; + +export class WebConsole implements RequiredConsole { + private readonly logger: Logger; + + constructor(loggerFactory: LoggerFactory) { + this.logger = loggerFactory.loggerFor('jet-console'); + } + + error(...data: unknown[]): void { + this.logger.error(...data); + } + + info(...data: unknown[]): void { + this.logger.info(...data); + } + + log(...data: unknown[]): void { + this.logger.info(...data); + } + + warn(...data: unknown[]): void { + this.logger.warn(...data); + } +} diff --git a/src/jet/dependencies/feature-flags.ts b/src/jet/dependencies/feature-flags.ts new file mode 100644 index 0000000..e745137 --- /dev/null +++ b/src/jet/dependencies/feature-flags.ts @@ -0,0 +1,20 @@ +const ENABLED_FEATURES = new Set([ + // Make the `ProductPageIntentController` return a `ShelfBasedProductPage` instance + 'shelves_2_0_product', + // Enable shelf-based "Top Charts" features + // 'shelves_2_0_top_charts', + // Make the `RibbonBarShelf` contain an array of `RibbonBarItem`s + 'shelves_2_0_generic', + // Enable AX Metadata + 'product_accessibility_support_2025A', +]); + +export class WebFeatureFlags implements FeatureFlags { + isEnabled(feature: string): boolean { + return ENABLED_FEATURES.has(feature); + } + + isGSEUIEnabled(_feature: string): boolean { + return false; + } +} diff --git a/src/jet/dependencies/locale.ts b/src/jet/dependencies/locale.ts new file mode 100644 index 0000000..e48e935 --- /dev/null +++ b/src/jet/dependencies/locale.ts @@ -0,0 +1,99 @@ +import type { Locale as JetLocaleDependency } from '@jet-app/app-store/foundation/dependencies/locale/locale'; +import type { + NormalizedLanguage, + NormalizedStorefront, + NormalizedLocale, + UnnormalizedLocale, +} from '@jet-app/app-store/api/locale'; +import type I18N from '@amp/web-apps-localization'; +import type { Logger, LoggerFactory } from '@amp/web-apps-logger'; + +import type { Jet } from '~/jet/jet'; +import { + DEFAULT_STOREFRONT_CODE, + DEFAULT_LANGUAGE_BCP47, +} from '~/constants/storefront'; +import { + type NormalizedLocaleWithDefault, + normalizeStorefront, + normalizeLanguage, +} from '~/utils/locale'; +import type { Optional } from '@jet/environment'; + +/** + * Contains information related to the locale of the request currently being + * made to the application. + * + * Typically, localization information is expected to be known when the Jet + * instance is initialized. The Web, however, will not know the current + * locale and langauge until after routing has already taken place. + * + * This object exists to contain that lazily-determined locale information, + * so that other dependencies can retreive it from here. It is to be created + * with the rest of the dependencies and passed to them when they are created. + * + * Localization information is set in the {@linkcode Jet#setLocale} method + */ +export class Locale implements JetLocaleDependency { + private readonly logger: Logger; + + private _storefront: NormalizedStorefront | undefined; + private _language: NormalizedLanguage | undefined; + + i18n: I18N | undefined; + + constructor(loggerFactory: LoggerFactory) { + this.logger = loggerFactory.loggerFor('locale'); + } + + get activeStorefront(): NormalizedStorefront { + if (!this._storefront) { + this.logger.warn('`storefront` was accessed before being set'); + return DEFAULT_STOREFRONT_CODE; + } + + return this._storefront; + } + + get activeLanguage(): NormalizedLanguage { + if (!this._language) { + this.logger.warn('`language` was accessed before being set'); + return DEFAULT_LANGUAGE_BCP47; + } + + return this._language; + } + + setActiveLocale(locale: NormalizedLocale): void { + this._storefront = locale.storefront; + this._language = locale.language; + } + + normalize({ + storefront, + language, + }: UnnormalizedLocale): NormalizedLocaleWithDefault { + const { + storefront: normalizedStorefront, + languages, + defaultLanguage, + } = normalizeStorefront(storefront); + + return { + storefront: normalizedStorefront, + ...normalizeLanguage(language || '', languages, defaultLanguage), + }; + } + + deriveLocaleForUrl(locale: NormalizedLocale): { + storefront: string; + language: Optional; + } { + const { isDefaultLanguage } = this.normalize(locale); + + return { + storefront: locale.storefront, + language: isDefaultLanguage ? undefined : locale.language, + }; + } +} diff --git a/src/jet/dependencies/localization.ts b/src/jet/dependencies/localization.ts new file mode 100644 index 0000000..d6961e4 --- /dev/null +++ b/src/jet/dependencies/localization.ts @@ -0,0 +1,523 @@ +import type I18N from '@amp/web-apps-localization'; +import type { LoggerFactory, Logger } from '@amp/web-apps-logger'; +import { isNothing } from '@jet/environment'; + +import type { Locale } from './locale'; +import { abbreviateNumber } from '~/utils/number-formatting'; +import { getFileSizeParts } from '~/utils/file-size'; +import { + getPlural, + interpolateString, +} from '@amp/web-apps-localization/src/translator'; +import type { Locale as SupportedLanguageIdentifier } from '@amp/web-apps-localization'; + +const SECONDS_PER_MINUTE = 60; +const SECONDS_PER_HOUR = 60 * 60; +const SECONDS_PER_DAY = SECONDS_PER_HOUR * 24; +const SECONDS_PER_YEAR = SECONDS_PER_DAY * 365; + +export function makeWebDoesNotImplementException(property: keyof Localization) { + return new Error( + `\`Localization\` method \`${property}\` is not implemented for the "web" platform`, + ); +} + +/** + * Determines if {@linkcode key} appears to be a "client" translation key + * + * "Client" keys are defined in `SCREAMING_SNAKE_CASE` + */ +function isClientLocalizationKey(key: string): boolean { + return /^[A-Z_]+$/.test(key); +} + +/** + * Transforms an App Store Client-used translation key to the format that we have + * a value for. + * + * This accounts for the fact that the "raw" key used by the App Store Client + * is either a "client" key, that we filed an analogue for in our own translations, + * or a "server" key that exists in the App Store Client translations under their + * own namespace. In either case, we need to perform a transformation on the key as + * they use it into a format that we have a value for. + */ +function transformKeyToSupportedFormat(key: string): string { + return isClientLocalizationKey(key) + ? transformClientKeyToSupportedFormat(key) + : transformServerKeyToSupportedFormat(key); +} + +/** + * Transforms an App Store Client server-side translation key into the format + * that we have a value for. + * + * This handles the fact that the App Store Client namespaces all of + * their translation strings under `AppStore.` but does not include + * that namespace when referencing the key. Since their tooling implicitly + * injects that namespace for them, we have to do the same thing manually. + + * @example + * transformServerKeyToSupportedFormat('Account.Purchases'); + * // "AppStore.Account.Purchases" + */ +function transformServerKeyToSupportedFormat(key: string): string { + return `AppStore.${key}`; +} + +/** + * Capitalizes the first character in {@linkcode input} + */ +function capitalizeFirstCharacter(input: string): string { + const [first, ...rest] = input; + + return first.toUpperCase() + rest.join(''); +} + +/** + * Transforms an App Store Client client-side translation key into the format + * that we have a value for. + * + * "Client" keys used by the App Store Client are typically provided by the OS; + * this is not available to a web application, we need an alternative to providing + * values for these translation keys. + * + * To accomplish this, we have submitted these keys to the server-side localization + * service ourelves, under a specific namespace that designates that they are the + * client-side keys that we needed to define. Other formatting changes are made to + * the key at the request of the LOC team. + * + * @example + * transformClientKeyToSupportedFormat('ACCOUNT_PURCHASES'); + * // "ASE.Web.AppStoreClient.Account.Purchases" + */ +function transformClientKeyToSupportedFormat(key: string): string { + const keyInSrvLocFormat = key + .toLowerCase() + .split('_') + .map((segment) => capitalizeFirstCharacter(segment)) + .join('.'); + + return `ASE.Web.AppStoreClient.${keyInSrvLocFormat}`; +} + +/** + * "Web" implementation of the `AppStoreKit` {@linkcode Localization} dependency + */ +export class WebLocalization implements Localization { + private readonly locale: Locale; + private readonly logger: Logger; + + constructor(locale: Locale, loggerFactory: LoggerFactory) { + this.locale = locale; + this.logger = loggerFactory.loggerFor('jet/dependency/localization'); + } + + get i18n(): I18N { + if (this.locale.i18n) { + return this.locale.i18n; + } + + throw new Error('`i18n` not yet configured '); + } + + /** + * The `BCP 47` identifier for the active locale + * + * @see {@link https://developer.apple.com/documentation/foundation/locale | Foundation Frameworks Locale Documentation} + * @see {@link https://en.wikipedia.org/wiki/IETF_language_tag | BCP 47} + */ + get identifier() { + return this.locale.activeLanguage; + } + + decimal( + n: number | null | undefined, + decimalPlaces?: number | null | undefined, + ): string | null { + if (isNothing(n)) { + return null; + } + + let langCode: string = this.locale.activeLanguage; + + if (!langCode.includes('-')) { + langCode = `${this.locale.activeLanguage}-${this.locale.activeStorefront}`; + } + + const numberingSystem = new Intl.NumberFormat( + langCode, + ).resolvedOptions().numberingSystem; + + const formatter = new Intl.NumberFormat(this.locale.activeLanguage, { + numberingSystem, + minimumFractionDigits: decimalPlaces ?? undefined, + maximumFractionDigits: decimalPlaces ?? undefined, + }); + + return formatter.format(n); + } + + string(key: string): string { + const keyInSupportedFormat = transformKeyToSupportedFormat(key); + + // `.getUninterpolatedString` is used instead of `.t` here to match + // the behavior of the `.stringWithCount` method + return this.i18n.getUninterpolatedString(keyInSupportedFormat); + } + + stringForPreferredLocale(_key: string, _locale: string | null): string { + throw makeWebDoesNotImplementException('stringForPreferredLocale'); + } + + stringWithCount(key: string, count: number): string { + let keyInSupportedFormat = transformKeyToSupportedFormat(key); + + // The App Store Client has some behavior around pluralization that differs + // from how the Media Apps localization normally works. In order to handle + // this, we have to avoid the default pluralization behavior of the `.i18n.t` + // method and do the pluralization ourselves + const keyWithPluralizationSuffix = getPlural( + count, + keyInSupportedFormat, + this.identifier as SupportedLanguageIdentifier, + ); + + // The key difference in pluralization logic is that the `other` case is + // actually handled by the "base" key without any suffix. + // Therefore, we should only use the pluralized key if it does not reflect + // the `other` case + if (!keyWithPluralizationSuffix.endsWith('.other')) { + keyInSupportedFormat = keyWithPluralizationSuffix; + } + + const uninterpolatedValue = + this.i18n.getUninterpolatedString(keyInSupportedFormat); + + // Since the `count` might be interpolated into the localization string, + // we need to run the interpolation ourselves on uninterpolated value + return interpolateString( + key, + uninterpolatedValue, + { count }, + null, + this.identifier as SupportedLanguageIdentifier, + ); + } + + stringWithCounts(_key: string, _counts: number[]): string { + throw makeWebDoesNotImplementException('stringWithCounts'); + } + + uppercased(_value: string): string { + throw makeWebDoesNotImplementException('uppercased'); + } + + /** + * Converts a number of bytes into a localized file size string + * + * @param bytes The number of bytes to convert + * @return The localized file size string + */ + fileSize(bytes: number): string | null { + let { count, unit } = getFileSizeParts(bytes); + + return this.i18n.t(`ASE.Web.AppStore.FileSize.${unit}`, { + count, + }); + } + + formattedCount(count: number | null | undefined): string | null { + if (isNothing(count)) { + return null; + } + + return abbreviateNumber(count, this.locale.activeLanguage); + } + + formattedCountForPreferredLocale( + count: number | null, + locale: string | null, + ): string | null { + if (isNothing(count)) { + return null; + } + + return isNothing(locale) + ? abbreviateNumber(count, this.locale.activeLanguage) + : abbreviateNumber(count, locale); + } + + /** + * Convert a date into a time ago label, showing how long ago + * the date occurred. + * + * @param date The date object to convert + * @return The localized string representing the amount of time that has passed + */ + timeAgo(date: Date | null | undefined): string | null { + if (!date || !(date instanceof Date) || isNaN(date.getTime())) { + return null; + } + + const relativeTimeIntl = new Intl.RelativeTimeFormat( + this.locale.activeLanguage, + { + style: 'narrow', + }, + ); + + const now = new Date(); + + const secondsAgo = (now.getTime() - date.getTime()) / 1000; + const minutesAgo = Math.floor(secondsAgo / SECONDS_PER_MINUTE); + const hoursAgo = Math.floor(secondsAgo / SECONDS_PER_HOUR); + const daysAgo = Math.floor(secondsAgo / SECONDS_PER_DAY); + const yearsAgo = Math.floor(secondsAgo / SECONDS_PER_YEAR); + const isSameYear = now.getFullYear() === date.getFullYear(); + const isUpcoming = date.getTime() > now.getTime(); + + if (secondsAgo < 0 && isUpcoming) { + return new Intl.DateTimeFormat(this.locale.activeLanguage, { + month: 'short', + day: 'numeric', + }).format(date); + } + + if (secondsAgo < 60) { + return relativeTimeIntl.format(-secondsAgo, 'seconds'); + } + + if (minutesAgo < 60) { + return relativeTimeIntl.format(-minutesAgo, 'minutes'); + } + + if (hoursAgo < 24) { + return relativeTimeIntl.format(-hoursAgo, 'hours'); + } + + if (daysAgo < 7) { + return relativeTimeIntl.format(-daysAgo, 'days'); + } + + if (isSameYear) { + return new Intl.DateTimeFormat(this.locale.activeLanguage, { + month: 'short', + day: 'numeric', + }).format(date); + } + + if (yearsAgo >= 0) { + return new Intl.DateTimeFormat(this.locale.activeLanguage, { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }).format(date); + } + + // this return statement is here to satisfy typescript, all possible cases are + // satisfied by the above conditionals. + return null; + } + + timeAgoWithContext( + _date: Date | null | undefined, + _context: DateContext, + ): string | null { + return null; + } + + formatDate(format: string, date: Date | null | undefined): string | null { + if (isNothing(date)) { + return null; + } + + let formatterConfiguration: Intl.DateTimeFormatOptions | undefined; + + switch (format) { + case 'MMM d': // e.g. Jan 29 + formatterConfiguration = { + month: 'short', + day: 'numeric', + }; + break; + case 'MMMM d': // e.g. January 29 + formatterConfiguration = { + month: 'long', + day: 'numeric', + }; + break; + case 'j:mm': // e.g. 9:00 + formatterConfiguration = { + hour: 'numeric', + minute: '2-digit', + }; + break; + case 'MMM d, y': // e.g. Jan 29, 2025 + formatterConfiguration = { + month: 'short', + day: 'numeric', + year: 'numeric', + }; + break; + case 'MMMM d, y': // e.g. "January 29, 2025" + formatterConfiguration = { + year: 'numeric', + month: 'long', + day: 'numeric', + }; + break; + case 'EEE j:mm': // e.g. "SAT 9:00PM" + formatterConfiguration = { + weekday: 'short', + hour: 'numeric', + minute: '2-digit', + hour12: true, + }; + break; + case 'd، MMM، yyyy': // e.g. "29 Jan 2025" + formatterConfiguration = { + day: 'numeric', + month: 'short', + year: 'numeric', + }; + break; + case 'MMM d, yyyy': // e.g. "Jan 29, 2025" + formatterConfiguration = { + day: 'numeric', + month: 'short', + year: 'numeric', + }; + break; + case 'd MMM yyyy': // e.g. "29 January 2025" + formatterConfiguration = { + day: 'numeric', + month: 'long', + year: 'numeric', + }; + break; + case 'yyyy MMMM d': // e.g. "2025 January 29" + formatterConfiguration = { + day: 'numeric', + month: 'long', + year: 'numeric', + }; + case 'd M yyyy': + formatterConfiguration = { + day: 'numeric', + month: 'short', + year: 'numeric', + }; + break; + case 'd MMM., yyyy': + formatterConfiguration = { + day: 'numeric', + month: 'long', + year: 'numeric', + }; + break; + case 'dd/MM/yyyy': // e.g. "29/01/2025" + formatterConfiguration = { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }; + break; + case 'd MMM , yyyy': // e.g. "29 Jan , 2025" + formatterConfiguration = { + day: 'numeric', + month: 'short', + year: 'numeric', + }; + break; + case 'd. MMM. yyyy.': // e.g. "29. Jan. 2025." + formatterConfiguration = { + day: 'numeric', + month: 'short', + year: 'numeric', + }; + break; + + case 'd. MMM yyyy': // e.g. "29. Jan 2025" + formatterConfiguration = { + day: 'numeric', + month: 'short', + year: 'numeric', + }; + break; + + case 'yyyy. MMM d.': // e.g. "2025. Jan 29." + formatterConfiguration = { + day: 'numeric', + month: 'short', + year: 'numeric', + }; + break; + + case 'd.M.yyyy': // e.g. "29.1.2025" + formatterConfiguration = { + day: 'numeric', + month: 'numeric', + year: 'numeric', + }; + break; + + case 'd/M/yyyy': // e.g. "29/1/2025" + formatterConfiguration = { + day: 'numeric', + month: 'numeric', + year: 'numeric', + }; + break; + default: + this.logger.warn( + `\`formatDate\` called with unexpected format \`${format}\``, + ); + return null; + } + + return new Intl.DateTimeFormat( + this.locale.activeLanguage, + formatterConfiguration, + ).format(date); + } + + formatDateWithContext( + format: string, + date: Date | null | undefined, + _context: DateContext, + ): string | null { + return this.formatDate(format, date); + } + + formatDateInSentence( + sentence: string, + format: string, + date: Date | null | undefined, + ): string | null { + const formattedDate = this.formatDate(format, date); + + if (isNothing(formattedDate)) { + return null; + } + + return ( + sentence + // "Server-Side" LOC keys us `@@date@@` to mark the date to replace + .replace('@@date@@', formattedDate) + // "Client-Side" LOC keys use `%@` to mark the date to replace + .replace('%@', formattedDate) + ); + } + + relativeDate(date: Date | null | undefined): string | null { + if (isNothing(date)) { + return null; + } + + return date.toString(); + } + + formatDuration(_value: number, _unit: TimeUnit): string | null { + throw makeWebDoesNotImplementException('formatDuration'); + } +} diff --git a/src/jet/dependencies/make-dependencies.ts b/src/jet/dependencies/make-dependencies.ts new file mode 100644 index 0000000..f03c7ca --- /dev/null +++ b/src/jet/dependencies/make-dependencies.ts @@ -0,0 +1,45 @@ +import type { LoggerFactory as AppLoggerFactory } from '@amp/web-apps-logger'; + +import { Random } from '@amp/web-apps-common/src/jet/dependencies/random'; +import { Host } from '@amp/web-apps-common/src/jet/dependencies/host'; +import { WebBag } from './bag'; +import { WebClient } from './client'; +import { WebConsole } from './console'; +import { Locale } from './locale'; +import { WebLocalization } from './localization'; +import { makeProperties } from './properties'; +import { WebMetricsIdentifiers } from './metrics-identifiers'; +import { Net, type FeaturesCallbacks } from './net'; +import { WebStorage } from './storage'; +import { makeUnauthenticatedUser } from './user'; +import { SEO } from './seo'; + +export type Dependencies = ReturnType; + +export function makeDependencies( + loggerFactory: AppLoggerFactory, + fetch: typeof window.fetch, + featuresCallbacks?: FeaturesCallbacks, +) { + const locale = new Locale(loggerFactory); + return { + bag: new WebBag(loggerFactory, locale), + client: new WebClient( + // TODO: set the right `BuildType` based on the environment where the app is running + 'production', + locale, + ), + console: new WebConsole(loggerFactory), + host: new Host(), + localization: new WebLocalization(locale, loggerFactory), + locale, + metricsIdentifiers: new WebMetricsIdentifiers(), + net: new Net(fetch, featuresCallbacks), + properties: makeProperties(), + random: new Random(), + seo: new SEO(locale), + storage: new WebStorage(), + user: makeUnauthenticatedUser(), + URL, + }; +} diff --git a/src/jet/dependencies/media-token-service.ts b/src/jet/dependencies/media-token-service.ts new file mode 100644 index 0000000..45cae9e --- /dev/null +++ b/src/jet/dependencies/media-token-service.ts @@ -0,0 +1,11 @@ +import { MEDIA_API_JWT } from '~/config/media-api'; + +export class WebMediaTokenService implements MediaTokenService { + refreshToken(): Promise { + return Promise.resolve(MEDIA_API_JWT); + } + + resetToken(): void { + // No-op; every request uses the same token for the "web" platform + } +} diff --git a/src/jet/dependencies/metrics-identifiers.ts b/src/jet/dependencies/metrics-identifiers.ts new file mode 100644 index 0000000..e48c9d1 --- /dev/null +++ b/src/jet/dependencies/metrics-identifiers.ts @@ -0,0 +1,13 @@ +export class WebMetricsIdentifiers implements MetricsIdentifiers { + async getIdentifierForContext( + _metricsIdentifierKeyContext: MetricsIdentifierKeyContext, + ): Promise { + return undefined; + } + + async getMetricsFieldsForContexts( + _metricsIdentifierKeyContexts: MetricsIdentifierKeyContext[], + ): Promise { + return undefined; + } +} diff --git a/src/jet/dependencies/net.ts b/src/jet/dependencies/net.ts new file mode 100644 index 0000000..dd7fdb9 --- /dev/null +++ b/src/jet/dependencies/net.ts @@ -0,0 +1,117 @@ +import type { Network, FetchRequest, FetchResponse } from '@jet/environment'; +import { fromEntries } from '@amp/web-apps-utils'; + +import { + shouldUseSearchJWT, + makeSearchJWTAuthorizationHeader, +} from '~/config/media-api'; + +const CORRELATION_KEY_HEADER = 'x-apple-jingle-correlation-key'; + +type FetchFunction = typeof window.fetch; + +// TODO: these URLs are also referenced in `bag` definition; we should have a single +// source-of-truth for these domains +const MEDIA_API_ORIGINS = [ + 'https://amp-api.apps.apple.com', + 'https://amp-api-edge.apps.apple.com', + 'https://amp-api-search-edge.apps.apple.com', +]; + +export interface FeaturesCallbacks { + getITFEValues(): string | undefined; +} + +export class Net implements Network { + private readonly underlyingFetch: FetchFunction; + private readonly getITFEValues: () => string | undefined = () => undefined; + + constructor( + underlyingFetch: FetchFunction, + featuresCallbacks?: FeaturesCallbacks, + ) { + this.underlyingFetch = underlyingFetch; + this.getITFEValues = + featuresCallbacks?.getITFEValues ?? this.getITFEValues; + } + + async fetch(request: FetchRequest): Promise { + const requestStartTime = getTimestampMs(); + const requestURL = new URL(request.url); + + request.headers = request.headers ?? {}; + + if (MEDIA_API_ORIGINS.includes(requestURL.origin)) { + // Need to fake this for the server due to Kong origin checks. + // Has no effect clientside. + request.headers['origin'] = 'https://apps.apple.com'; + + const itfe = this.getITFEValues?.(); + + if (itfe) { + // Add ITFE value as query string when set + requestURL.searchParams.set('itfe', itfe); + } + } + + // The App Store Client will have already injected the JWT from the + // `media-token-service` ObjectGraph dependency into the headers. However, + // some endpoints need a different JWT. Here we determine if that's the + // case and override the existing JWT if necessary. + if (shouldUseSearchJWT(requestURL)) { + request.headers = { + ...request.headers, + ...makeSearchJWTAuthorizationHeader(), + }; + } + + // TODO: rdar://78158575: timeout + const response = await this.underlyingFetch(requestURL.toString(), { + ...request, + cache: request.cache ?? undefined, + credentials: 'include', + headers: request.headers ?? undefined, + method: request.method ?? undefined, + }); + + const responseStartTime = getTimestampMs(); + + const { ok, redirected, status, statusText, url } = response; + + const headers = fromEntries(response.headers); + const body = await response.text(); + + const responseEndTime = getTimestampMs(); + + return { + ok, + headers, + redirected, + status, + statusText, + url, + body, + // TODO: rdar://78158575: redirect: 'manual' to get all metrics? + metrics: [ + { + clientCorrelationKey: response.headers.get( + CORRELATION_KEY_HEADER, + ), + pageURL: response.url, + requestStartTime, + responseStartTime, + responseEndTime, + // TODO: rdar://78158575: responseWasCached? + // TODO: rdar://78158575: parseStartTime/parseEndTime + }, + ], + }; + } +} + +/** + * Returns the current UTC timestamp in milliseconds. + */ +function getTimestampMs(): number { + return Date.now(); +} diff --git a/src/jet/dependencies/object-graph.ts b/src/jet/dependencies/object-graph.ts new file mode 100644 index 0000000..40ad0a9 --- /dev/null +++ b/src/jet/dependencies/object-graph.ts @@ -0,0 +1,59 @@ +import { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; +import { ObjectGraphType } from '@jet-app/app-store/gameservicesui/src/foundation/object-graph-types'; + +import type { Dependencies } from './make-dependencies'; +import { WebFeatureFlags } from './feature-flags'; +import { WebMediaTokenService } from './media-token-service'; + +export { ObjectGraphType }; + +class AppStoreWebObjectGraph extends AppStoreObjectGraph { + /** + * Configures the ObjectGraph from our `Dependencies` definition + * + * @param dependencies + * @returns + */ + configureWithDependencies(dependencies: Dependencies) { + const { + bag, + client, + console, + host, + locale, + localization, + metricsIdentifiers, + net, + properties, + random, + seo, + storage, + user, + } = dependencies; + + return this.addingClient(client) + .addingNetwork(net) + .addingHost(host) + .addingBag(bag) + .addingLoc(localization) + .addingMediaToken(new WebMediaTokenService()) + .addingConsole(console) + .addingAppleSilicon(undefined) + .addingProperties(properties) + .addingLocale(locale) + .addingUser(user) + .addingFeatureFlags(new WebFeatureFlags()) + .addingMetricsIdentifiers(metricsIdentifiers) + .addingSEO(seo) + .addingStorage(storage) + .addingRandom(random); + } +} + +export function makeObjectGraph( + dependencies: Dependencies, +): AppStoreObjectGraph { + const objectGraph = new AppStoreWebObjectGraph('app-store'); + + return objectGraph.configureWithDependencies(dependencies); +} diff --git a/src/jet/dependencies/properties.ts b/src/jet/dependencies/properties.ts new file mode 100644 index 0000000..8956d7f --- /dev/null +++ b/src/jet/dependencies/properties.ts @@ -0,0 +1,5 @@ +export function makeProperties(): PackageProperties { + return { + clientFeatures: {}, + }; +} diff --git a/src/jet/dependencies/seo.ts b/src/jet/dependencies/seo.ts new file mode 100644 index 0000000..0938afa --- /dev/null +++ b/src/jet/dependencies/seo.ts @@ -0,0 +1,254 @@ +import type { Opt } from '@jet/environment/types/optional'; +import type { + ArcadeSeeAllGamesPage, + ArticlePage, + ChartsHubPage, + GenericPage, + ReviewsPage, + SearchLandingPage, + SearchResultsPage, + SeeAllPage, + ShelfBasedProductPage, + TodayPage, + TopChartsPage, +} from '@jet-app/app-store/api/models'; +import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page'; +import type { SEO as SEODependency } from '@jet-app/app-store/foundation/dependencies/seo'; +import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; +import type { DataContainer } from '@jet-app/app-store/foundation/media/data-structure'; + +import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types'; + +import type { Locale } from './locale'; + +import { seoDataForAnyPage, updateCanonicalURL } from '~/utils/seo/common'; +import { seoDataForArticlePage } from '~/utils/seo/article-page'; +import { seoDataForChartsPage } from '~/utils/seo/charts-page'; +import { seoDataForChartsHubPage } from '~/utils/seo/charts-hub-page'; +import { seoDataForDeveloperPage } from '~/utils/seo/developer-page'; +import { seoDataForProductPage } from '~/utils/seo/product-page'; +import { seoDataForAppEventDetailPage } from '~/utils/seo/app-event-detail-page'; +import { seoDataForReviewsPage } from '~/utils/seo/reviews-page'; +import { seoDataForSearchLandingPage } from '~/utils/seo/search-landing-page'; +import { seoDataForSearchResultsPage } from '~/utils/seo/search-results-page'; +import { seoDataForEditorialShelfCollectionPage } from '~/utils/seo/editorial-shelf-collection-page'; +import { seoDataForArcadeSeeAllPage } from '~/utils/seo/arcade-see-all-page'; +import { seoDataForSeeAllPage } from '~/utils/seo/see-all-page'; + +export class SEO implements SEODependency { + private locale: Locale; + + constructor(locale: Locale) { + this.locale = locale; + } + + private get i18n() { + if (this.locale.i18n) { + return this.locale.i18n; + } + + throw new Error('`i18n` not yet configured '); + } + + private getSEODataForGenericPage(page: GenericPage): Opt { + return { + ...seoDataForAnyPage(page, this.i18n), + }; + } + + updateCanonicalURL(page: WebRenderablePage, canonicalURL: string): void { + updateCanonicalURL(page, canonicalURL); + } + + /// MARK: Page SEO Data Hooks + + getSEODataForAppEventPage( + objectGraph: AppStoreObjectGraph, + page: GenericPage, + ): Opt { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForAppEventDetailPage( + page, + this.i18n, + objectGraph.locale.activeLanguage, + ), + }; + } + + getSEODataForArcadeSeeAllPage( + _objectGraph: AppStoreObjectGraph, + page: ArcadeSeeAllGamesPage, + ): Opt { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForArcadeSeeAllPage(page, this.i18n), + }; + } + + getSEODataForArticlePage( + objectGraph: AppStoreObjectGraph, + page: ArticlePage, + response: Opt, + ): Opt { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForArticlePage( + objectGraph, + this.i18n, + page, + response, + objectGraph.locale.activeLanguage, + ), + }; + } + + getSEODataForBundlePage( + objectGraph: AppStoreObjectGraph, + page: ShelfBasedProductPage, + data: Opt, + ): Opt { + return this.getSEODataForProductPage(objectGraph, page, data); + } + + getSEODataForChartsPage( + objectGraph: AppStoreObjectGraph, + page: TopChartsPage, + ): Opt { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForChartsPage( + page, + this.i18n, + objectGraph.locale.activeLanguage, + ), + }; + } + + getSEODataForChartsHubPage( + objectGraph: AppStoreObjectGraph, + page: ChartsHubPage, + ): Opt { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForChartsHubPage( + page, + this.i18n, + objectGraph.locale.activeLanguage, + ), + }; + } + + getSEODataForDeveloperPage( + objectGraph: AppStoreObjectGraph, + page: GenericPage, + response: Opt, + ): Opt { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForDeveloperPage(objectGraph, response, this.i18n), + }; + } + + getSEODataForEditorialPage( + _objectGraph: AppStoreObjectGraph, + page: GenericPage, + ): Opt { + return this.getSEODataForGenericPage(page); + } + + getSEODataForEditorialShelfCollectionPage( + _objectGraph: AppStoreObjectGraph, + page: GenericPage, + ): Opt { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForEditorialShelfCollectionPage(page, this.i18n), + }; + } + + getSEODataForGroupingPage( + _objectGraph: AppStoreObjectGraph, + page: GenericPage, + ): Opt { + return this.getSEODataForGenericPage(page); + } + + getSEODataForProductPage( + objectGraph: AppStoreObjectGraph, + page: ShelfBasedProductPage, + data: Opt, + ): Opt { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForProductPage( + objectGraph, + page, + data, + this.i18n, + objectGraph.locale.activeLanguage, + ), + }; + } + + getSEODataForReviewsPage( + objectGraph: AppStoreObjectGraph, + page: ReviewsPage, + productPage: ShelfBasedProductPage, + ): Opt { + return { + ...this.getSEODataForGenericPage(page), + ...seoDataForReviewsPage(this.i18n, page, productPage, objectGraph), + }; + } + + getSEODataForRoomPage( + _objectGraph: AppStoreObjectGraph, + page: GenericPage, + ): Opt { + return { + ...seoDataForAnyPage(page, this.i18n), + }; + } + + getSEODataForSearchLandingPage( + _objectGraph: AppStoreObjectGraph, + page: SearchLandingPage, + ): Opt { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForSearchLandingPage(page, this.i18n), + }; + } + + getSEODataForSearchResultsPage( + objectGraph: AppStoreObjectGraph, + page: SearchResultsPage, + ): Opt { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForSearchResultsPage( + page, + this.i18n, + objectGraph.locale.activeLanguage, + ), + }; + } + + getSEODataForTodayPage( + _objectGraph: AppStoreObjectGraph, + page: TodayPage, + ): Opt { + return seoDataForAnyPage(page, this.i18n); + } + + getSEODataForSeeAllPage( + _objectGraph: AppStoreObjectGraph, + page: SeeAllPage, + ): Opt { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForSeeAllPage(page, this.i18n), + }; + } +} diff --git a/src/jet/dependencies/storage.ts b/src/jet/dependencies/storage.ts new file mode 100644 index 0000000..fe1da2c --- /dev/null +++ b/src/jet/dependencies/storage.ts @@ -0,0 +1,44 @@ +/** + * `AppStoreKit` `Storage` implementation for the "web" client + * + * Note: The `AppStoreKit` `Storage` interface is declared as a global, which has the (presumably + * accidental) side-effect of implicitly being merged with the DOM library's own `Storage` interface + * (like `localStorage`), since interfaces declared in the same scope are merged together by TypeScript. + * There's no way to tell TypeScript that we only care about the `AppStoreKit` part of it, so + * satifying TypeScript here means that we need to implement both interfaces. + */ +export class WebStorage extends Map implements Storage { + /* == "DOM" `Storage` Interface == */ + + get length() { + return this.size; + } + + getItem(key: string): string | null { + return this.get(key) ?? null; + } + + key(_index: number): string | null { + throw new Error('Method not implemented.'); + } + + removeItem(key: string): void { + this.delete(key); + } + + setItem(key: string, value: string): void { + this.set(key, value); + } + + /* == AppStoreKit `Storage` Interface == */ + + storeString(aString: string, key: string): void { + this.set(key, aString); + } + + retrieveString(key: string): string { + // Fallback value designed based on how the ObjectGraph `StorageWrapper` handles that specific value + // https://github.pie.apple.com/app-store/ios-appstore-app/blob/1761d575b8dc3d7a63e7e36f3320cf9245be9f37/src/foundation/wrappers/storage.ts#L13 + return this.get(key) ?? ''; + } +} diff --git a/src/jet/dependencies/user.ts b/src/jet/dependencies/user.ts new file mode 100644 index 0000000..2dad212 --- /dev/null +++ b/src/jet/dependencies/user.ts @@ -0,0 +1,30 @@ +/** + * Create an "unauthenticated" {@linkcode User} representation + * + * The property values below match the way that `AppStoreKit` will define the `user` + * when the session is not authenticated. + */ +export function makeUnauthenticatedUser(): User { + return { + accountIdentifier: undefined, + dsid: undefined, + firstName: undefined, + // Note: this property is `true` for the native apps but `false` makes + // more sense in the context of the "web" client + isFitnessAppInstallationAllowed: false, + isManagedAppleID: false, + isOnDevicePersonalizationEnabled: false, + isUnderThirteen: false, + katanaId: undefined, + lastName: undefined, + treatmentGroupIdOverride: undefined, + userAgeIfAvailable: undefined, + + onDevicePersonalizationDataContainerForAppIds(appIds) { + return { + personalizationData: {}, + metricsData: {}, + }; + }, + }; +} -- cgit v1.2.3