diff options
| author | rxliuli <rxliuli@gmail.com> | 2025-11-04 05:03:50 +0800 |
|---|---|---|
| committer | rxliuli <rxliuli@gmail.com> | 2025-11-04 05:03:50 +0800 |
| commit | bce557cc2dc767628bed6aac87301a1be7c5431b (patch) | |
| tree | b51a051228d01fe3306cd7626d4a96768aadb944 /src/jet | |
init commit
Diffstat (limited to 'src/jet')
44 files changed, 3534 insertions, 0 deletions
diff --git a/src/jet/action-handlers/browser.ts b/src/jet/action-handlers/browser.ts new file mode 100644 index 0000000..08d1a5a --- /dev/null +++ b/src/jet/action-handlers/browser.ts @@ -0,0 +1,16 @@ +// Browser ONLY logic. Must have the same exports as server.ts +// See: docs/isomorphic-imports.md + +import type { Dependencies } from './types'; + +import { registerHandler as registerFlowActionHandler } from '~/jet/action-handlers/flow-action'; +import { registerHandler as registerExternalURLActionHandler } from '~/jet/action-handlers/external-url-action'; +import { registerHandler as registerCompoundActionHandler } from '~/jet/action-handlers/compound-action'; + +export type { Dependencies }; + +export function registerActionHandlers(dependencies: Dependencies) { + registerCompoundActionHandler(dependencies); + registerFlowActionHandler(dependencies); + registerExternalURLActionHandler(dependencies); +} diff --git a/src/jet/action-handlers/compound-action.ts b/src/jet/action-handlers/compound-action.ts new file mode 100644 index 0000000..9cf1be0 --- /dev/null +++ b/src/jet/action-handlers/compound-action.ts @@ -0,0 +1,33 @@ +import type { LoggerFactory } from '@amp/web-apps-logger'; +import type { Jet } from '~/jet'; +import type { CompoundAction } from '~/jet/models'; + +export type Dependencies = { + jet: Jet; + logger: LoggerFactory; +}; + +export async function registerHandler(dependencies: Dependencies) { + const { jet, logger } = dependencies; + + const log = logger.loggerFor('jet/action-handlers/compound-action'); + + jet.onAction('compoundAction', async (action: CompoundAction) => { + log.info('received CompoundAction:', action); + + const { subactions = [] } = action; + + // Perform actions in sequence + for (const action of subactions) { + await jet.perform(action).catch((e) => { + // Throwing error stops for...of execution + // TODO: rdar://73165545 (Error Handling Across App) + throw new Error( + `an error occurred while handling CompoundAction: ${e}`, + ); + }); + } + + return 'performed'; + }); +} diff --git a/src/jet/action-handlers/external-url-action.ts b/src/jet/action-handlers/external-url-action.ts new file mode 100644 index 0000000..a9d3769 --- /dev/null +++ b/src/jet/action-handlers/external-url-action.ts @@ -0,0 +1,19 @@ +import type { Jet } from '~/jet'; +import type { LoggerFactory } from '@amp/web-apps-logger'; +import type { ExternalUrlAction } from '@jet-app/app-store/api/models'; + +export type Dependencies = { + jet: Jet; + logger: LoggerFactory; +}; + +export function registerHandler(dependencies: Dependencies) { + const { jet, logger } = dependencies; + + const log = logger.loggerFor('jet/action-handlers/external-url-action'); + + jet.onAction('ExternalUrlAction', async (action: ExternalUrlAction) => { + log.info('received external URL action:', action); + return 'performed'; + }); +} diff --git a/src/jet/action-handlers/flow-action.ts b/src/jet/action-handlers/flow-action.ts new file mode 100644 index 0000000..4cdcc5e --- /dev/null +++ b/src/jet/action-handlers/flow-action.ts @@ -0,0 +1,369 @@ +import { isNothing, unwrapOptional } from '@jet/environment'; +import type { Intent } from '@jet/environment/dispatching'; +import type { LoggerFactory } from '@amp/web-apps-logger'; +import { History } from '@amp/web-apps-utils'; + +import type { FlowAction } from '@jet-app/app-store/api/models'; +import { isSearchResultsPageIntent } from '@jet-app/app-store/api/intents/search-results-page-intent'; +import { isChartsPageIntent } from '@jet-app/app-store/api/intents/charts-page-intent'; + +import type { Jet } from '~/jet'; +import { type Page, assertIsPage, FLOW_ACTION_KIND } from '~/jet/models'; +import { mapException } from '~/utils/error'; +import { stripHost } from '~/utils/url'; + +import type { ComponentProps } from 'svelte'; +import type AppComponent from '~/App.svelte'; +import { handleModalPresentation } from '~/jet/utils/handle-modal-presentation'; +import { addRejectedIntent } from '../utils/error-metadata'; + +type AppComponentProps = Partial<ComponentProps<AppComponent>>; + +// This action handler is responsible for all routing and related state +// management. +// +// Take care when making modifications here. There are many subtle invariants +// that must be maintained. They should be documented in comments throughout. +// It might be best to read the whole file to understand this full context +// before attempting even a small fix. +// +// High level overview: +// +// There are two ways for routing state changes to arise in the app: +// +// 1. Direct user interaction with the app (a FlowAction) +// 2. Indirect user interaction via browser back/forward buttons (popstate) +// +// FlowAction is the bedrock of navigation in the app. Anytime the user interacts +// with a button, link, etc. a FlowAction is performed (Jet.perform). When that +// happens, the Jet runtime eventually invokes the handler in this file +// (see jet.onAction below) to change the state of the app. +// +// This file manages the browser history and thus has the dual responsibility +// of handling state changes that come from the back and forward buttons. The +// state stored off when handling a FlowAction is later used by the popstate +// handler to navigate backwards without needing to re-fetch the previous page. +// +// Take note that these two processes are coupled fairly tightly due to the +// popstate needing data from the previous navigation. This is stored in the +// State interface. Take care when updating one flow that a modification is +// likely needed in the other. +// +// At the end of both of these processes, a call to updateApp is made. This +// changes the view model passed down to the top level <App> component. As a +// result of Svelte's reactivity, this could result in the entire page changing +// or just a part of it being amended to or removed. Additionally, the `page` +// passed in (the view model) can also be a promise. In which case, <App> will +// await it and display a loading spinner until it resolves or rejects. +// +// Notable specifics: +// +// Handling a FlowAction roughly has the following steps: +// +// 1. Extract a "destination" intent from the FlowAction. Recall that Jet +// actions communicate a user interaction, but return no value. Jet +// intents can be contained within an action and return data. In this case, +// the intent derived from a FlowAction is used to retrieve the data for +// the new page to which the FlowAction sends the user. +// +// 2. Dispatch the "destination" intent. Here, we resolve the Promise when +// the page is ready, but we'll resolve early with an unresolve page +// promise after 500ms. We take advantage of that the fact that passing a +// Promise to updateApp will show a loading spinner. We wait 500ms, +// because we don't want to immediately show a loading spinner or change +// the page. +// +// 3. Update current page state in the history (ex. scroll position) and then +// push a new history state for the page we're about to display. Note that +// this must be done after the page Promise resolves, because we need to +// store the page view model itself and we only know the canonicalURL of +// it once it resolves. This state is used by popstate to return to the +// page should the user ever leave and then come back to it. +// +// 4. Call updateApp to change the UI presented. At this point, it could be a +// completed page (in which case step 3 will have already happened). The +// app will display the new page immediately. Or, it could still be a +// Promise (in which case step 3 will happen once it resolves and then the +// page will resolve). The <App> will display a loading spinner until this +// resolution happens. +// +// Handling a popstate event follows a similar pattern, but has some additional +// complexity. +// +// The simple case is that the state that we stored off above in step 3 is +// available. In which case, returning to the old page only involves calling +// updateApp with the view model we stored. +// +// But, we don't want to store an infinite history as these view models are +// sizable. We limit history to an arbitrary depth. After the user has +// navigated beyond that depth, we forget the oldest states. If a user ever +// were to back button all the way back to them, there would be no view model +// to restore. But, we do have the URL, so we use that and pretend like we're +// deeplinking into the app again for the first time. Care must be taken here +// to not perform a FlowAction, since that would modify the history. popstate +// events have already modified the browser history to point to the desired +// new state. So, we manually dispatch the page intent and perform other +// actions (such as switching the selected tab) ourselves. We then use the page +// promise as above to call updateApp. +export type Dependencies = { + jet: Jet; + logger: LoggerFactory; + updateApp: (props: AppComponentProps) => void; +}; + +interface State { + page: Page; +} + +export function registerHandler(dependencies: Dependencies) { + const { jet, logger, updateApp } = dependencies; + + const history = new History<State>(logger, { + getScrollablePageElement() { + return ( + document.getElementById('scrollable-page-override') || + document.getElementById('scrollable-page') || + // If we haven't defined a specific scrollable element, + // scroll the whole page + document.getElementsByTagName('html')?.[0] + ); + }, + }); + + const log = logger.loggerFor('jet/action-handlers/flow-action'); + + let isFirstPage = true; + + jet.onAction(FLOW_ACTION_KIND, async (action: FlowAction) => { + log.info('received FlowAction:', action); + // timer for request time start + // TODO: fix perfkit - rdar://111465791 ([Onyx] Foundation - PerfKit) + // const pageSpeedMetric = perfkit.makeNewPageSpeedMetric(); + // pageSpeedMetric.capturePageRequestTime(); + + let intent: Intent<unknown>; + try { + intent = unwrapOptional(action.destination); + } catch (e) { + log.info( + '`FlowAction` received without a destination `Intent`: update the Jet app to attach an `Intent` to this `FlowAction`', + ); + + return; + } + + // If the destination `Intent` must be performed server-side, determine + // the destination URL and perform full browser navigation to that location + if (!isFirstPage && mustPerformServerSide(intent)) { + const { pageUrl } = action; + + if (isNothing(pageUrl)) { + log.error( + `\`${intent.$kind}\` must be performed server-side, but the action lacks a \`pageUrl\` to navigate to`, + ); + return 'performed'; + } + + window.location.href = stripHost(pageUrl); + return 'performed'; + } + + // We capture this variable since below it is used asynchronously, but + // we updated it at the end of this handler (so it could change before + // it's used below). + const shouldReplace = isFirstPage; + + // Resolves either when the page is ready or 800ms have elapsed + // (we want to show a loading spinner after 800ms) + const page = await getPage(intent, action); + + // If the action requires the page to be rendered in a modal. + if (action.presentationContext === 'presentModal') { + handleModalPresentation(page, log, action.page); + return 'performed'; + } + + // This must happen before history.replaceState/pushState + // We call this now, because the next line updates <App> which changes + // the DOM. After that point we can't do things like record scroll + // position, etc. + history.beforeTransition(); + + updateApp({ + page: page.promise.then((page: Page): Page => { + const state = { + page, + }; + + const canonicalURL = mapException( + () => unwrapOptional(page.canonicalURL), + '`page` resolved without a `canonicalURL`, which is required for navigation', + ); + + // TODO: fix perfkit - rdar://111465791 ([Onyx] Foundation - PerfKit) + // perfkit.setPageType(page.pageMetrics?.pageFields?.pageType as string | undefined || 'unknown'); + + if (shouldReplace) { + history.replaceState(state, canonicalURL); + } else { + history.pushState(state, canonicalURL); + } + + didEnterPage(page); + return page; + }), + isFirstPage, + }); + + // Future updates won't be for the first page + isFirstPage = false; + + return 'performed'; + }); + + history.onPopState( + async (url: string, state: State | undefined): Promise<void> => { + // NOTE: We don't call history.beforeTransition() anywhere here, + // because we don't expect to save any state from the previous page + // on back. + + if (state) { + const { page } = state; + + log.info('received popstate, so resetting page:', page); + didEnterPage(page); + updateApp({ page, isFirstPage }); + + return; + } + + // If the state is missing page data, we have to recompute the view model + const routing = await jet.routeUrl(url); + + if (!routing) { + log.error( + 'received popstate without data, but URL was unroutable:', + url, + ); + + // This probably shouldn't happen (since we only ever push valid + // URLs to the history), but if it does, the best we can do is show + // an error. + didEnterPage(null); // to exit the current page + updateApp({ + page: Promise.reject(new Error('404')), + isFirstPage, + }); + return; + } + + log.info( + 'received popstate without data, so routing URL to:', + routing, + ); + + // We can't perform the FlowAction here, as that would cause a new + // history state to be pushed. Since we're in the context of a + // popState, that would cause an infinite history loop where the back + // button goes back but then immediately pushes again to the history + // (so the user doesn't actually go back in history). + // See: rdar://92621382 (Navigating more than 10 pages and then going back breaks back button) + // + // Careful reading will note that this promise will not reject. + // Only the page.promise can reject (and we'll hand that to updateApp, + // which will display the appropriate error for this case). + // + // Like in the handling of FlowAction (above), this blocks for at + // most 800ms before resolving. Either the page is ready, or we + // want to display a loading spinner. updateApp() will show a + // spinner if page.promise is not ready. + const page = await getPage(routing.intent, routing.action); + + updateApp({ + page: page.promise.then((page: Page): Page => { + // No history.replaceState/pushState like in handling FlowAction + // (above) since this is in the context of a popstate. The + // history stack, URL bar, etc. have already been updated. + + didEnterPage(page); + return page; + }), + isFirstPage, + }); + }, + ); + + /** + * Get a Page by dispatching its intent. Returns a promise that resolves + * when the page is ready or after 800ms, whichever is first. + * + * The promise-inside-an-object-inside-a-promise return type is + * intentional. If we just returned Promise<Page>, then this function + * would not resolve until the page was ready. But we want it to resolve + * after 800ms, even if the page isn't ready. + */ + async function getPage( + intent: Intent<unknown>, + sourceAction: FlowAction | undefined, + ): Promise<{ promise: Promise<Page> }> { + const page = (async (): Promise<Page> => { + try { + let page = await jet.dispatch(intent); + log.info('FlowAction destination resolved to:', page); + + assertIsPage(page); + + return page; + } catch (e: any) { + log.error('FlowAction destination rejected:', e); + + // Provide a way to retry the flow action from <ErrorPage> + if (!e.userInfo || e.userInfo.status !== 404) { + e.retryFlowAction = sourceAction; + } + + e.isFirstPage = isFirstPage; + addRejectedIntent(e, intent); + throw e; + } + })(); + + // Wait until the page loads (or up to 500ms, then show loading spinner) + await Promise.race([ + page, + // Note that this has interplay with <PageResolver> + new Promise((resolve) => setTimeout(resolve, 500)), + // TODO: rdar://78166703 Add test to ensure catch no-ops + // + // NOTE: This catch is important. If the page promise rejects, we + // want that to flow down into updateApp, where the appropriate + // error page will be displayed. If we don't no-op here, we'll + // cause the FlowAction to not finish handling (and updateApp will + // never be called). + ]).catch(() => {}); + + // Wrapping in an object to prevent this function's promise from + // not resolving until the page is ready. We want to resolve + // immediately if it's already been 800ms + return { promise: page }; + } + + function didEnterPage(page: Page | null): void { + // Wrapped in an IIFE to avoid blocking anything (or breaking anything + // if this fails) + (async (): Promise<void> => { + try { + await jet.didEnterPage(page); + } catch (e) { + log.error('didEnterPage error:', e); + } + })(); + } +} + +/** + * Determines if an `Intent` must be performed server-side + */ +function mustPerformServerSide(intent: Intent<unknown>): boolean { + return isSearchResultsPageIntent(intent) || isChartsPageIntent(intent); +} diff --git a/src/jet/bootstrap.ts b/src/jet/bootstrap.ts new file mode 100644 index 0000000..32b10a0 --- /dev/null +++ b/src/jet/bootstrap.ts @@ -0,0 +1,125 @@ +import { makeRouterUsingRegisteredControllers } from '@jet/environment/routing'; + +import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; +import { AppStoreIntentDispatcher } from '@jet-app/app-store/foundation/runtime/app-store-intent-dispatcher'; +import { AppStoreRuntime } from '@jet-app/app-store/foundation/runtime/runtime'; + +import { + type Dependencies, + ObjectGraphType, + makeObjectGraph, +} from '~/jet/dependencies'; + +import { AppEventPageIntentController } from '@jet-app/app-store/controllers/app-events/app-event-page-intent-controller'; +import { BundlePageIntentController } from '@jet-app/app-store/controllers/product-page/bundle-page-intent-controller'; +import { EditorialPageIntentController } from '@jet-app/app-store/controllers/editorial-pages/editorial-page-intent-controller'; +import { EditorialShelfCollectionPageIntentController } from '@jet-app/app-store/controllers/editorial-pages/editorial-shelf-collection-page-intent-controller'; +import { GroupingPageIntentController } from '@jet-app/app-store/controllers/grouping/grouping-page-intent-controller'; +import { ProductPageIntentController } from '@jet-app/app-store/controllers/product-page/product-page-intent-controller'; +import { SearchLandingPageIntentController } from '@jet-app/app-store/controllers/search/search-landing-page-intent-controller'; +import { SearchResultsPageIntentController } from '@jet-app/app-store/controllers/search/search-results-controller'; +import { RoutableArticlePageIntentController } from '@jet-app/app-store/controllers/today/routable-article-page-intent-controller'; +import { ArcadeGroupingPageIntentController } from '@jet-app/app-store/controllers/arcade/arcade-grouping-page-intent-controller'; +import { DeveloperPageIntentController } from '@jet-app/app-store/controllers/developer/developer-page-intent-controller'; +import { ChartsPageIntentController } from '@jet-app/app-store/controllers/top-charts/charts-page-intent-controller'; +import { ChartsHubPageIntentController } from '@jet-app/app-store/controllers/top-charts/charts-hub-page-intent-controller'; +import { SeeAllPageIntentController } from '@jet-app/app-store/controllers/product-page/see-all-intent-controller'; +import { RoutableTodayPageIntentController } from '@jet-app/app-store/controllers/today/routable-today-page-intent-controller'; +import { RoomPageIntentController } from '@jet-app/app-store/controllers/room/room-page-intent-controller'; +import { RoutableArcadeSeeAllPageController } from '@jet-app/app-store/controllers/arcade/routable-arcade-see-all-page-controller'; +import * as landingPageNavigationControllers from '@jet-app/app-store/common/web-navigation/platform-landing-page-intent-controllers'; +import { RootRedirectController } from '@jet-app/app-store/common/web-navigation/platform-landing-page-intent-controllers'; +import { EulaPageIntentController } from '@jet-app/app-store/controllers/product-page/eula-page-intent-controller'; +import { CategoryTabsIntentController } from '@jet-app/app-store/controllers/web-navigation/category-tabs-intent-controller'; + +import { ErrorPageIntentController } from '~/jet/intents/error-page-intent-controller'; +import { ChartsPageRedirectIntentController } from '~/jet/intents/charts-page-redirect-intent-controller'; + +import { + RouteUrlIntentController, + LintMetricsEventIntentController, +} from '~/jet/intents'; +import * as staticMessagePageControllers from '~/jet/intents/static-message-pages'; + +function makeIntentDispatcher(): AppStoreIntentDispatcher { + const intentDispatcher = new AppStoreIntentDispatcher(); + + intentDispatcher.register(RouteUrlIntentController); + intentDispatcher.register(LintMetricsEventIntentController); + + // Route Providers + for (const Controller of Object.values(landingPageNavigationControllers)) { + // `RootRedirectController` needs to be registered last, due to it's path match of `/{sf}`, + // it could inadvertently match a landing page route like `/vision`, so we are skipping it here + // and registering it at the bottom of this function. + if (Controller !== RootRedirectController) { + intentDispatcher.register(Controller); + } + } + + for (const StaticMessagePageController of Object.values( + staticMessagePageControllers, + )) { + intentDispatcher.register(StaticMessagePageController); + } + + intentDispatcher.register(ArcadeGroupingPageIntentController); + intentDispatcher.register(BundlePageIntentController); + intentDispatcher.register(EditorialPageIntentController); + intentDispatcher.register(EditorialShelfCollectionPageIntentController); + intentDispatcher.register(GroupingPageIntentController); + intentDispatcher.register(new SearchResultsPageIntentController()); + intentDispatcher.register(SearchLandingPageIntentController); + intentDispatcher.register(DeveloperPageIntentController); + intentDispatcher.register(RoutableArticlePageIntentController); + intentDispatcher.register(RoutableTodayPageIntentController); + intentDispatcher.register(RoomPageIntentController); + intentDispatcher.register(RoutableArcadeSeeAllPageController); + intentDispatcher.register(EulaPageIntentController); + intentDispatcher.register(ChartsPageRedirectIntentController); + intentDispatcher.register(ErrorPageIntentController); + + // "Charts" Pages; "hub" must come first since so it's URL matches before the "detail" page + intentDispatcher.register(ChartsHubPageIntentController); + intentDispatcher.register(ChartsPageIntentController); + + // Product Page Routes; order is important due to overlapping URL patterns + // The product page itself must come last or it will match the more-specific patterns + intentDispatcher.register(AppEventPageIntentController); + intentDispatcher.register(SeeAllPageIntentController); + intentDispatcher.register(ProductPageIntentController); + + intentDispatcher.register(new CategoryTabsIntentController()); + + // We register the root redirect controller last so more specific path patterns can be matched first + intentDispatcher.register(RootRedirectController); + + return intentDispatcher; +} + +/** + * Bootstraps the Jet runtime for Apps + * + * @param dependencies dependencies to initialize the Object Graph with + */ +export function bootstrap(dependencies: Dependencies): { + runtime: AppStoreRuntime; + objectGraph: AppStoreObjectGraph; +} { + const intentDispatcher = makeIntentDispatcher(); + + const baseObjectGraph = makeObjectGraph(dependencies); + + const router = makeRouterUsingRegisteredControllers( + intentDispatcher, + baseObjectGraph, + ); + const appObjectGraph = baseObjectGraph + .adding(ObjectGraphType.router, router) + .adding(ObjectGraphType.dispatcher, intentDispatcher); + + return { + runtime: new AppStoreRuntime(intentDispatcher, appObjectGraph), + objectGraph: appObjectGraph, + }; +} 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<keyof NativeBag, 'registerBagKeys'>; + +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<number> { + 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<number> { + return this.provideNoValue('integer', key); + } + + boolean(key: string): Opt<boolean> { + 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<unknown> { + 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<unknown> { + return this.provideNoValue('dictionary', key); + } + + url(key: string): Opt<string> { + 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<string> { + 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<string>; + } { + 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<typeof makeDependencies>; + +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<string> { + 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<string | undefined> { + return undefined; + } + + async getMetricsFieldsForContexts( + _metricsIdentifierKeyContexts: MetricsIdentifierKeyContext[], + ): Promise<JSONData | undefined> { + 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<FetchResponse> { + 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<SeoData> { + 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<SeoData> { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForAppEventDetailPage( + page, + this.i18n, + objectGraph.locale.activeLanguage, + ), + }; + } + + getSEODataForArcadeSeeAllPage( + _objectGraph: AppStoreObjectGraph, + page: ArcadeSeeAllGamesPage, + ): Opt<SeoData> { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForArcadeSeeAllPage(page, this.i18n), + }; + } + + getSEODataForArticlePage( + objectGraph: AppStoreObjectGraph, + page: ArticlePage, + response: Opt<DataContainer>, + ): Opt<SeoData> { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForArticlePage( + objectGraph, + this.i18n, + page, + response, + objectGraph.locale.activeLanguage, + ), + }; + } + + getSEODataForBundlePage( + objectGraph: AppStoreObjectGraph, + page: ShelfBasedProductPage, + data: Opt<DataContainer>, + ): Opt<SeoData> { + return this.getSEODataForProductPage(objectGraph, page, data); + } + + getSEODataForChartsPage( + objectGraph: AppStoreObjectGraph, + page: TopChartsPage, + ): Opt<SeoData> { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForChartsPage( + page, + this.i18n, + objectGraph.locale.activeLanguage, + ), + }; + } + + getSEODataForChartsHubPage( + objectGraph: AppStoreObjectGraph, + page: ChartsHubPage, + ): Opt<SeoData> { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForChartsHubPage( + page, + this.i18n, + objectGraph.locale.activeLanguage, + ), + }; + } + + getSEODataForDeveloperPage( + objectGraph: AppStoreObjectGraph, + page: GenericPage, + response: Opt<DataContainer>, + ): Opt<SeoData> { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForDeveloperPage(objectGraph, response, this.i18n), + }; + } + + getSEODataForEditorialPage( + _objectGraph: AppStoreObjectGraph, + page: GenericPage, + ): Opt<SeoData> { + return this.getSEODataForGenericPage(page); + } + + getSEODataForEditorialShelfCollectionPage( + _objectGraph: AppStoreObjectGraph, + page: GenericPage, + ): Opt<SeoData> { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForEditorialShelfCollectionPage(page, this.i18n), + }; + } + + getSEODataForGroupingPage( + _objectGraph: AppStoreObjectGraph, + page: GenericPage, + ): Opt<SeoData> { + return this.getSEODataForGenericPage(page); + } + + getSEODataForProductPage( + objectGraph: AppStoreObjectGraph, + page: ShelfBasedProductPage, + data: Opt<DataContainer>, + ): Opt<SeoData> { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForProductPage( + objectGraph, + page, + data, + this.i18n, + objectGraph.locale.activeLanguage, + ), + }; + } + + getSEODataForReviewsPage( + objectGraph: AppStoreObjectGraph, + page: ReviewsPage, + productPage: ShelfBasedProductPage, + ): Opt<SeoData> { + return { + ...this.getSEODataForGenericPage(page), + ...seoDataForReviewsPage(this.i18n, page, productPage, objectGraph), + }; + } + + getSEODataForRoomPage( + _objectGraph: AppStoreObjectGraph, + page: GenericPage, + ): Opt<SeoData> { + return { + ...seoDataForAnyPage(page, this.i18n), + }; + } + + getSEODataForSearchLandingPage( + _objectGraph: AppStoreObjectGraph, + page: SearchLandingPage, + ): Opt<SeoData> { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForSearchLandingPage(page, this.i18n), + }; + } + + getSEODataForSearchResultsPage( + objectGraph: AppStoreObjectGraph, + page: SearchResultsPage, + ): Opt<SeoData> { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForSearchResultsPage( + page, + this.i18n, + objectGraph.locale.activeLanguage, + ), + }; + } + + getSEODataForTodayPage( + _objectGraph: AppStoreObjectGraph, + page: TodayPage, + ): Opt<SeoData> { + return seoDataForAnyPage(page, this.i18n); + } + + getSEODataForSeeAllPage( + _objectGraph: AppStoreObjectGraph, + page: SeeAllPage, + ): Opt<SeoData> { + 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<string, string> 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) ?? '<null>'; + } +} 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: {}, + }; + }, + }; +} diff --git a/src/jet/intents/charts-page-redirect-intent-controller.ts b/src/jet/intents/charts-page-redirect-intent-controller.ts new file mode 100644 index 0000000..06d41ce --- /dev/null +++ b/src/jet/intents/charts-page-redirect-intent-controller.ts @@ -0,0 +1,68 @@ +import type { IntentController } from '@jet/environment/dispatching'; +import type { RouteProvider } from '@jet/environment/routing'; +import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; +import { withActiveIntent } from '@jet-app/app-store/foundation/dependencies/active-intent'; +import { generateRoutes } from '@jet-app/app-store/common/util/generate-routes'; +import { injectWebNavigation } from '@jet-app/app-store/common/web-navigation/inject-web-navigation'; +import { makeChartsPageURL } from '@jet-app/app-store/common/charts/charts-page-url'; +import { makeChartsPageIntent } from '@jet-app/app-store/api/intents/charts-page-intent'; +import { GenericPage } from '@jet-app/app-store/api/models'; +import { isPreviewPlatform } from '@jet-app/app-store/api/models/preview-platform'; +import { notFoundError } from '@jet-app/app-store/foundation/media/network'; + +const makeIntent = (opts) => ({ + ...opts, + $kind: 'ChartsPageRedirect', +}); + +// This will catch URLs like `/charts/iphone` +const { routes: routesWithoutGenreId } = generateRoutes( + makeIntent, + '/charts/{platform}', +); + +// This will catch URLs like `/charts/iphone/utilities-apps/6002` +const { routes: routesWithGenreId } = generateRoutes( + makeIntent, + '/charts/{platform}/{slug}/{genreId}', +); + +function chartsPageRedirectRoutes(objectGraph: AppStoreObjectGraph) { + return [ + ...routesWithoutGenreId(objectGraph), + ...routesWithGenreId(objectGraph), + ]; +} + +export const ChartsPageRedirectIntentController: IntentController<any> & + RouteProvider = { + $intentKind: 'ChartsPageRedirect', + + routes: chartsPageRedirectRoutes, + + async perform(intent, objectGraphWithoutActiveIntent: AppStoreObjectGraph) { + return await withActiveIntent( + objectGraphWithoutActiveIntent, + intent, + async (objectGraph) => { + const page = new GenericPage([]); + const chartPageIntent = makeChartsPageIntent(intent); + + if (!isPreviewPlatform(intent.platform)) { + throw notFoundError(); + } + + // Setting the `canonicalUrl` on the page to normal Charts Page URL (e.g. /{platform}/charts) + // will trigger a 301 redirect to the that page. + page.canonicalURL = makeChartsPageURL( + objectGraph, + chartPageIntent, + ); + + injectWebNavigation(objectGraph, page, intent.platform); + + return page; + }, + ); + }, +}; diff --git a/src/jet/intents/error-page-intent-controller.ts b/src/jet/intents/error-page-intent-controller.ts new file mode 100644 index 0000000..59ac1fd --- /dev/null +++ b/src/jet/intents/error-page-intent-controller.ts @@ -0,0 +1,52 @@ +import type { Intent, IntentController } from '@jet/environment/dispatching'; +import type { Opt } from '@jet/environment/types/optional'; +import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; +import { withActiveIntent } from '@jet-app/app-store/foundation/dependencies/active-intent'; +import { injectWebNavigation } from '@jet-app/app-store/common/web-navigation/inject-web-navigation'; + +import { ErrorPage } from '~/jet/models/error-page'; +import type { Page } from '~/jet/models/page'; +import { getRejectedIntent } from '~/jet/utils/error-metadata'; +import { isWithPlatform } from '~/jet/utils/with-platform'; + +interface ErrorPageIntent extends Intent<Page> { + $kind: 'ErrorPageIntent'; + error: Opt<Error>; +} + +export function makeErrorPageIntent( + options: Omit<ErrorPageIntent, '$kind'>, +): ErrorPageIntent { + return { + ...options, + $kind: 'ErrorPageIntent', + }; +} + +export const ErrorPageIntentController: IntentController<ErrorPageIntent> = { + $intentKind: 'ErrorPageIntent', + + async perform( + intent, + objectGraphWithoutActiveIntent: AppStoreObjectGraph, + ): Promise<Page> { + const { error } = intent; + const rejectedIntent = error ? getRejectedIntent(error) : null; + const platform = + (rejectedIntent && isWithPlatform(rejectedIntent) + ? rejectedIntent.platform + : null) ?? 'iphone'; + + return await withActiveIntent( + objectGraphWithoutActiveIntent, + { ...intent, platform }, + async (objectGraph) => { + const page = new ErrorPage({ error: intent.error }); + + injectWebNavigation(objectGraph, page, platform); + + return page; + }, + ); + }, +}; diff --git a/src/jet/intents/lint-metrics-event/lint-metrics-event-controller.ts b/src/jet/intents/lint-metrics-event/lint-metrics-event-controller.ts new file mode 100644 index 0000000..046914b --- /dev/null +++ b/src/jet/intents/lint-metrics-event/lint-metrics-event-controller.ts @@ -0,0 +1,18 @@ +import type { IntentController } from '@jet/environment/dispatching/base/intent-controller'; +import type { LintedMetricsEvent } from '@jet/environment/types/metrics'; + +import { + type LintMetricsEventIntent, + LintMetricsEventIntentKind, +} from './lint-metrics-event-intent'; + +export const LintMetricsEventIntentController: IntentController<LintMetricsEventIntent> = + { + $intentKind: LintMetricsEventIntentKind.Name, + + async perform( + intent: LintMetricsEventIntent, + ): Promise<LintedMetricsEvent> { + return { fields: intent.fields }; + }, + }; diff --git a/src/jet/intents/lint-metrics-event/lint-metrics-event-intent.ts b/src/jet/intents/lint-metrics-event/lint-metrics-event-intent.ts new file mode 100644 index 0000000..a2a085e --- /dev/null +++ b/src/jet/intents/lint-metrics-event/lint-metrics-event-intent.ts @@ -0,0 +1,23 @@ +import type { Intent } from '@jet/environment/dispatching'; +import type { + LintedMetricsEvent, + MetricsFields, +} from '@jet/environment/types/metrics'; + +export const enum LintMetricsEventIntentKind { + Name = 'LintMetricsEventIntent', +} + +export interface LintMetricsEventIntent extends Intent<LintedMetricsEvent> { + $kind: LintMetricsEventIntentKind.Name; + fields: MetricsFields; +} + +export function makeLintMetricsEventIntent( + options: Omit<LintMetricsEventIntent, '$kind'>, +): LintMetricsEventIntent { + return { + ...options, + $kind: LintMetricsEventIntentKind.Name, + }; +} diff --git a/src/jet/intents/route-url/route-url-controller.ts b/src/jet/intents/route-url/route-url-controller.ts new file mode 100644 index 0000000..8c8fdb6 --- /dev/null +++ b/src/jet/intents/route-url/route-url-controller.ts @@ -0,0 +1,28 @@ +import { isSome } from '@jet/environment/types/optional'; +import type { IntentController } from '@jet/environment/dispatching'; +import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; +import { isRoutableIntent } from '@jet-app/app-store/api/intents/routable-intent'; + +import type { RouteUrlIntent } from '~/jet/intents'; +import { makeFlowAction } from '~/jet/models'; + +export const RouteUrlIntentController: IntentController<RouteUrlIntent> = { + $intentKind: 'RouteUrlIntent', + + async perform(intent: RouteUrlIntent, objectGraph: AppStoreObjectGraph) { + const targetIntent = objectGraph.router.intentFor(intent.url); + + if (isSome(targetIntent) && isRoutableIntent(targetIntent)) { + return { + // intent needed for SSR + intent: targetIntent, + // only ever used by client; only clients have actions + action: makeFlowAction(targetIntent), + storefront: targetIntent.storefront, + language: targetIntent.language, + }; + } + + return null; + }, +}; diff --git a/src/jet/intents/route-url/route-url-intent.ts b/src/jet/intents/route-url/route-url-intent.ts new file mode 100644 index 0000000..841bd25 --- /dev/null +++ b/src/jet/intents/route-url/route-url-intent.ts @@ -0,0 +1,48 @@ +import type { Optional } from '@jet/environment/types/optional'; +import type { Intent } from '@jet/environment/dispatching'; +import type { FlowAction } from '@jet-app/app-store/api/models'; + +import type { + NormalizedStorefront, + NormalizedLanguage, +} from '@jet-app/app-store/api/locale'; + +/** + * A response from the router given an incoming (deeplink) URL. + */ +export interface RouterResponse { + /** + * The intent to dispatch to get the view model for this URL. + */ + intent: Intent<unknown>; + + /** + * action to navigate to a new page of the app. + */ + action: FlowAction; + + storefront: NormalizedStorefront; + + language: NormalizedLanguage; +} + +export interface RouteUrlIntent extends Intent<Optional<RouterResponse>> { + $kind: 'RouteUrlIntent'; + + /** + * The URL to route (ex. "https://podcasts.apple.com/us/show/serial/id123"). + */ + url: string; +} + +export function isRouteUrlIntent( + intent: Intent<unknown>, +): intent is RouteUrlIntent { + return intent.$kind === 'RouteUrlIntent'; +} + +export function makeRouteUrlIntent( + options: Omit<RouteUrlIntent, '$kind'>, +): RouteUrlIntent { + return { $kind: 'RouteUrlIntent', ...options }; +} diff --git a/src/jet/intents/static-message-pages/carrier-page-intent-controller.ts b/src/jet/intents/static-message-pages/carrier-page-intent-controller.ts new file mode 100644 index 0000000..a1b049c --- /dev/null +++ b/src/jet/intents/static-message-pages/carrier-page-intent-controller.ts @@ -0,0 +1,41 @@ +import type { IntentController } from '@jet/environment/dispatching'; +import type { RouteProvider } from '@jet/environment/routing'; +import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; +import { withActiveIntent } from '@jet-app/app-store/foundation/dependencies/active-intent'; +import { generateRoutes } from '@jet-app/app-store/common/util/generate-routes'; +import { injectWebNavigation } from '@jet-app/app-store/common/web-navigation/inject-web-navigation'; + +import { StaticMessagePage } from '~/jet/models/static-message-page'; + +const { routes, makeCanonicalUrl } = generateRoutes( + (opts) => ({ + ...opts, + $kind: 'CarrierPageIntent', + }), + '/carrier', +); + +export const CarrierPageIntentController: IntentController<any> & + RouteProvider = { + $intentKind: 'CarrierPageIntent', + + routes, + + async perform(intent, objectGraphWithoutActiveIntent: AppStoreObjectGraph) { + return await withActiveIntent( + objectGraphWithoutActiveIntent, + intent, + async (objectGraph) => { + const page = new StaticMessagePage({ + titleLocKey: 'ASE.Web.AppStore.Carrier.Title', + contentType: 'carrier', + }); + + page.canonicalURL = makeCanonicalUrl(objectGraph, intent); + + injectWebNavigation(objectGraph, page, intent.platform); + return page; + }, + ); + }, +}; diff --git a/src/jet/intents/static-message-pages/contingent-price-page-intent-controller.ts b/src/jet/intents/static-message-pages/contingent-price-page-intent-controller.ts new file mode 100644 index 0000000..ba2babd --- /dev/null +++ b/src/jet/intents/static-message-pages/contingent-price-page-intent-controller.ts @@ -0,0 +1,49 @@ +import type { IntentController } from '@jet/environment/dispatching'; +import type { RouteProvider } from '@jet/environment/routing'; +import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; +import { withActiveIntent } from '@jet-app/app-store/foundation/dependencies/active-intent'; +import { generateRoutes } from '@jet-app/app-store/common/util/generate-routes'; +import { injectWebNavigation } from '@jet-app/app-store/common/web-navigation/inject-web-navigation'; + +import { StaticMessagePage } from '~/jet/models/static-message-page'; + +const { routes, makeCanonicalUrl } = generateRoutes( + (opts) => ({ + ...opts, + $kind: 'ContingentPriceIntent', + }), + '/contingent-price/{offerId}', + [], + { + extraRules: [ + { + regex: [/(?:\/[a-z]{2})?\/contingent-price/], + }, + ], + }, +); + +export const ContingentPricingIntentController: IntentController<any> & + RouteProvider = { + $intentKind: 'ContingentPriceIntent', + + routes, + + async perform(intent, objectGraphWithoutActiveIntent: AppStoreObjectGraph) { + return await withActiveIntent( + objectGraphWithoutActiveIntent, + intent, + async (objectGraph) => { + const page = new StaticMessagePage({ + titleLocKey: 'ASE.Web.AppStore.WinBack.Title', + contentType: 'contingent-price', + }); + + page.canonicalURL = makeCanonicalUrl(objectGraph, intent); + + injectWebNavigation(objectGraph, page, intent.platform); + return page; + }, + ); + }, +}; diff --git a/src/jet/intents/static-message-pages/invoice-page-intent-controller.ts b/src/jet/intents/static-message-pages/invoice-page-intent-controller.ts new file mode 100644 index 0000000..caa02f4 --- /dev/null +++ b/src/jet/intents/static-message-pages/invoice-page-intent-controller.ts @@ -0,0 +1,41 @@ +import type { IntentController } from '@jet/environment/dispatching'; +import type { RouteProvider } from '@jet/environment/routing'; +import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; +import { withActiveIntent } from '@jet-app/app-store/foundation/dependencies/active-intent'; +import { generateRoutes } from '@jet-app/app-store/common/util/generate-routes'; +import { injectWebNavigation } from '@jet-app/app-store/common/web-navigation/inject-web-navigation'; + +import { StaticMessagePage } from '~/jet/models/static-message-page'; + +const { routes, makeCanonicalUrl } = generateRoutes( + (opts) => ({ + ...opts, + $kind: 'InvoicePageIntent', + }), + '/invoice', +); + +export const InvoicePageIntentController: IntentController<any> & + RouteProvider = { + $intentKind: 'InvoicePageIntent', + + routes, + + async perform(intent, objectGraphWithoutActiveIntent: AppStoreObjectGraph) { + return await withActiveIntent( + objectGraphWithoutActiveIntent, + intent, + async (objectGraph) => { + const page = new StaticMessagePage({ + titleLocKey: 'ASE.Web.AppStore.Invoice.Title', + contentType: 'invoice', + }); + + page.canonicalURL = makeCanonicalUrl(objectGraph, intent); + + injectWebNavigation(objectGraph, page, intent.platform); + return page; + }, + ); + }, +}; diff --git a/src/jet/intents/static-message-pages/win-back-page-intent-controller.ts b/src/jet/intents/static-message-pages/win-back-page-intent-controller.ts new file mode 100644 index 0000000..2b78ba0 --- /dev/null +++ b/src/jet/intents/static-message-pages/win-back-page-intent-controller.ts @@ -0,0 +1,49 @@ +import type { IntentController } from '@jet/environment/dispatching'; +import type { RouteProvider } from '@jet/environment/routing'; +import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; +import { withActiveIntent } from '@jet-app/app-store/foundation/dependencies/active-intent'; +import { generateRoutes } from '@jet-app/app-store/common/util/generate-routes'; +import { injectWebNavigation } from '@jet-app/app-store/common/web-navigation/inject-web-navigation'; + +import { StaticMessagePage } from '~/jet/models/static-message-page'; + +const { routes, makeCanonicalUrl } = generateRoutes( + (opts) => ({ + ...opts, + $kind: 'WinBackPageIntent', + }), + '/win-back/{offerId}', + [], + { + extraRules: [ + { + regex: [/(?:\/[a-z]{2})?\/win-back/], + }, + ], + }, +); + +export const WinBackPageIntentController: IntentController<any> & + RouteProvider = { + $intentKind: 'WinBackPageIntent', + + routes, + + async perform(intent, objectGraphWithoutActiveIntent: AppStoreObjectGraph) { + return await withActiveIntent( + objectGraphWithoutActiveIntent, + intent, + async (objectGraph) => { + const page = new StaticMessagePage({ + titleLocKey: 'ASE.Web.AppStore.WinBack.Title', + contentType: 'win-back', + }); + + page.canonicalURL = makeCanonicalUrl(objectGraph, intent); + + injectWebNavigation(objectGraph, page, intent.platform); + return page; + }, + ); + }, +}; diff --git a/src/jet/jet.ts b/src/jet/jet.ts new file mode 100644 index 0000000..75b0afc --- /dev/null +++ b/src/jet/jet.ts @@ -0,0 +1,320 @@ +import type I18N from '@amp/web-apps-localization'; +import type { Logger, LoggerFactory } from '@amp/web-apps-logger'; + +import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; +import type { AppStoreRuntime } from '@jet-app/app-store/foundation/runtime/runtime'; +import type { + NormalizedStorefront, + NormalizedLanguage, +} from '@jet-app/app-store/api/locale'; + +import type { + LintedMetricsEvent, + MetricsFields, + PageMetrics, +} from '@jet/environment/types/metrics'; +import { type Opt } from '@jet/environment/types/optional'; +import type { Intent, IntentReturnType } from '@jet/environment/dispatching'; +import { + type ActionImplementation, + ActionDispatcher, + type ActionOutcome, + type MetricsBehavior, +} from '@jet/engine'; + +import { Metrics } from '@amp/web-apps-metrics-8'; +import { makeMetricsSettings } from '~/jet/metrics/settings'; +import { makeMetricsProviders } from '~/jet/metrics/providers'; +import { config as metricsConfig } from '~/config/metrics'; + +import { bootstrap } from '~/jet/bootstrap'; +import { makeDependencies } from '~/jet/dependencies'; +import type { Locale } from '~/jet/dependencies/locale'; +import type { WebLocalization } from '~/jet/dependencies/localization'; +import { + type RouterResponse, + type RouteUrlIntent, + makeRouteUrlIntent, + makeLintMetricsEventIntent, +} from '~/jet/intents'; +import type { Page, ActionModel } from '~/jet/models'; +import { PrefetchedIntents } from '@amp/web-apps-common/src/jet/prefetched-intents'; +import { CONTEXT_NAME } from '~/jet/svelte'; +import type { FeaturesCallbacks } from './dependencies/net'; + +/** + * The entry point for interacting with the Jet shared business logic. + */ +export class Jet { + private readonly log: Logger; + private readonly runtime: AppStoreRuntime; + private readonly actionDispatcher: ActionDispatcher; + private readonly metrics: Metrics; + private readonly locale: Locale; + + /** + * Intents (and their resolved data) that have yet to be dispatched that + * were recently dispatched. These are consulted before dispatching + * intents. If a prefetched intent exists for an ongoing dispatch, it will + * be used as the return value instead of actually dispatching. + * + * This can be used, for example, for intents that are dispatched during + * SSR. The server can serialize the intents it dispatches and then the + * client can populate this, to avoid re-dispatching the intents. + */ + private readonly prefetchedIntents: PrefetchedIntents; + + /** + * A set of the action types that already have registered implementations to catch + * double registers. + */ + private readonly wiredActions: Set<string>; + + readonly objectGraph: AppStoreObjectGraph; + readonly localization: WebLocalization; + + static load({ + loggerFactory, + context, + fetch, + prefetchedIntents = PrefetchedIntents.empty(), + featuresCallbacks, + }: { + loggerFactory: LoggerFactory; + context: Map<string, unknown>; + fetch: typeof window.fetch; + prefetchedIntents?: PrefetchedIntents; + featuresCallbacks?: FeaturesCallbacks; + }): Jet { + const dependencies = makeDependencies( + loggerFactory, + fetch, + featuresCallbacks, + ); + const { runtime, objectGraph } = bootstrap(dependencies); + let jet: Jet; + + const processEvent = async ( + fields: MetricsFields, + ): Promise<LintedMetricsEvent> => { + const intent = makeLintMetricsEventIntent({ fields }); + return jet.dispatch(intent); + }; + const metrics = Metrics.load( + loggerFactory, + context, + processEvent, + metricsConfig, + makeMetricsProviders(objectGraph), + makeMetricsSettings(context), + ); + const actionDispatcher = new ActionDispatcher( + // `@amp/web-apps-metrics` depends on a different version of `@jet/engine` with a different + // type definition for `MetricsPipeline` + // @ts-expect-error + metrics.metricsPipeline, + ); + + jet = new Jet( + loggerFactory.loggerFor('Jet'), + runtime, + objectGraph, + actionDispatcher, + metrics, + dependencies.locale, + prefetchedIntents, + dependencies.localization, + ); + + context.set(CONTEXT_NAME, jet); + + return jet; + } + + private constructor( + log: Logger, + runtime: AppStoreRuntime, + objectGraph: AppStoreObjectGraph, + actionDispatcher: ActionDispatcher, + metrics: Metrics, + locale: Locale, + prefetchedIntents: PrefetchedIntents, + localization: WebLocalization, + ) { + this.log = log; + this.runtime = runtime; + this.objectGraph = objectGraph; + this.actionDispatcher = actionDispatcher; + + this.metrics = metrics; + this.locale = locale; + this.localization = localization; + + this.prefetchedIntents = prefetchedIntents; + + this.wiredActions = new Set(); + } + + async didEnterPage(page: Page | null): Promise<void> { + // This is a very temporary hacky fix to move the `platformContext` value from + // `pageRenderFields` to `pageFields`, which will eventually happen in the Jet + // business logic. + const pageWithMetrics = { ...page }; + if (pageWithMetrics.pageMetrics?.pageFields) { + pageWithMetrics.pageMetrics.pageFields.platformContext = + pageWithMetrics.pageMetrics.pageRenderFields?.platformContext; + } + + // @ts-expect-error - pageMetrics property not required at runtime + await this.metrics.didEnterPage(page); + } + + get pageMetrics(): Opt<PageMetrics> { + return this.metrics.currentPageMetrics?.pageMetrics; + } + + /** + * Dispatch a Jet intent, returning its output. + * + * @param intent The intent to dispatch + * @return output The value returned by the intent's controller + */ + async dispatch<I extends Intent<unknown>>( + intent: I, + ): Promise<IntentReturnType<I>> { + const data = this.prefetchedIntents.get(intent); + if (data) { + this.log.info( + 're-using prefetched intent response for:', + intent, + 'data:', + data, + ); + return data; + } + + // TODO: rdar://73165545 (Error Handling Across App) + return this.runtime.dispatch(intent); + } + + /** + * Perform a Jet action, returning the outcome. + * + * @param action The action to perform + * @param metricsBehavior Indicates how to handle metrics for this action + * @return outcome Either 'performed' or 'unsupported' + */ + async perform( + action: ActionModel, + metricsBehavior?: MetricsBehavior, + ): Promise<ActionOutcome> { + if (!metricsBehavior) { + if (this.pageMetrics) { + metricsBehavior = { + behavior: 'fromAction', + context: this.pageMetrics || {}, + }; + } else { + this.log.warn( + 'No pageMetrics found for jet.perform action:', + action, + ); + metricsBehavior = { behavior: 'notProcessed' }; + } + } + // TODO: rdar://73165545 (Error Handling Across App): handle throw + const outcome = await this.actionDispatcher.perform( + action, + metricsBehavior, + ); + + if (outcome === 'unsupported') { + this.log.error( + 'unable to perform action:', + action, + metricsBehavior, + ); + } + + return outcome; + } + + /** + * Register an implementation to handle a Jet action. + * + * @param kind The type of the action + * @param implementation The code to run when that action is performed + */ + onAction<A extends ActionModel>( + kind: string, + implementation: ActionImplementation<A>, + ): void { + if (this.wiredActions.has(kind)) { + throw new Error( + `onAction called twice with the same action type: ${kind}`, + ); + } + + this.actionDispatcher.register(kind, implementation); + this.wiredActions.add(kind); + } + + /** + * Route a URL using Jet, returning the routing if the URL could be routed. + * + * @param url The URL to route + * @return routing The routing of the URL or null if unrouteable + */ + async routeUrl(url: string): Promise<RouterResponse | null> { + // TODO: rdar://73165545 (Error Handling Across App): what about 404s? + const routerResponse = await this.dispatch<RouteUrlIntent>( + makeRouteUrlIntent({ url }), + ); + + if (routerResponse && routerResponse.action) { + return routerResponse; + } + + this.log.warn( + 'url did not resolve to a flow action with a discernable intent:', + url, + routerResponse, + ); + + return null; + } + + /** + * Propagates the routing-derrived localization information through the Jet app + * + * The {@link Locale} instance that is configured here is referenced by + * the rest of our Jet dependencies in order to lazily retreive the locale + * information. + * + * @param localizer + * @param storefront + * @param language + */ + setLocale( + localizer: I18N, + storefront: NormalizedStorefront, + language: NormalizedLanguage, + ): void { + this.locale.i18n = localizer; + this.locale.setActiveLocale({ storefront, language }); + } + + recordCustomMetricsEvent(fields?: Opt<MetricsFields>) { + this.metrics.recordCustomEvent(fields); + } + + enableFunnelKit(): void { + this.metrics.enableFunnelKit(); + } + + disableFunnelKit(): void { + this.metrics.disableFunnelKit(); + } + + // TODO: rdar://75011660 (Bridge Jet to MetricsKit and PerfKit for reporting) +} diff --git a/src/jet/metrics/providers/StorefrontFieldsProvider.ts b/src/jet/metrics/providers/StorefrontFieldsProvider.ts new file mode 100644 index 0000000..f4c5448 --- /dev/null +++ b/src/jet/metrics/providers/StorefrontFieldsProvider.ts @@ -0,0 +1,19 @@ +import type { + MetricsFieldsBuilder, + MetricsFieldsContext, + MetricsFieldsProvider, +} from '@jet/engine'; +import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; +import { getLocale } from '@jet-app/app-store/common/locale'; + +export class StorefrontFieldsProvider implements MetricsFieldsProvider { + constructor(private readonly objectGraph: AppStoreObjectGraph) {} + + addMetricsFields( + builder: MetricsFieldsBuilder, + _context: MetricsFieldsContext, + ) { + const { storefront } = getLocale(this.objectGraph); + builder.addValue(storefront, 'storeFrontCountryCode'); + } +} diff --git a/src/jet/metrics/providers/index.ts b/src/jet/metrics/providers/index.ts new file mode 100644 index 0000000..98f3780 --- /dev/null +++ b/src/jet/metrics/providers/index.ts @@ -0,0 +1,15 @@ +import type { MetricsProvider } from '@amp/web-apps-metrics-8'; +import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; + +import { StorefrontFieldsProvider } from './StorefrontFieldsProvider'; + +export function makeMetricsProviders( + objectGraph: AppStoreObjectGraph, +): MetricsProvider[] { + return [ + { + provider: new StorefrontFieldsProvider(objectGraph), + request: 'storeFrontCountryCode', + }, + ]; +} diff --git a/src/jet/metrics/settings.ts b/src/jet/metrics/settings.ts new file mode 100644 index 0000000..c0c5075 --- /dev/null +++ b/src/jet/metrics/settings.ts @@ -0,0 +1,20 @@ +import type { MetricSettings } from '@amp/web-apps-metrics-8'; + +/** + * Generates a metric settings for Metrics class. + * + * @param context - app context map + * @returns MetricSettings + */ +export function makeMetricsSettings( + context: Map<string, unknown>, +): MetricSettings { + return { + shouldEnableFunnelKit: function (): boolean { + return false; + }, + getConsumerId: async function (): Promise<string> { + return null; + }, + }; +} diff --git a/src/jet/models/error-page.ts b/src/jet/models/error-page.ts new file mode 100644 index 0000000..80bcdf5 --- /dev/null +++ b/src/jet/models/error-page.ts @@ -0,0 +1,15 @@ +import { GenericPage } from '@jet-app/app-store/api/models'; +import type { Opt } from '@jet/environment'; + +export class ErrorPage extends GenericPage { + constructor({ error }: { error: Opt<Error> }) { + super([]); + this.error = error; + } + + // Used in our type guards to narrow a `Page` down to a `ErrorPage` + pageType: string = 'errorPage'; + + // The browser `Error`, used to determine which message to display to the user + error: Opt<Error>; +} diff --git a/src/jet/models/external-action.ts b/src/jet/models/external-action.ts new file mode 100644 index 0000000..25dbd14 --- /dev/null +++ b/src/jet/models/external-action.ts @@ -0,0 +1,7 @@ +import type { Action, ExternalUrlAction } from '@jet-app/app-store/api/models'; + +export function isExternalUrlAction( + action: Action, +): action is ExternalUrlAction { + return action.$kind === 'ExternalUrlAction'; +} diff --git a/src/jet/models/flow-action.ts b/src/jet/models/flow-action.ts new file mode 100644 index 0000000..d5edb40 --- /dev/null +++ b/src/jet/models/flow-action.ts @@ -0,0 +1,28 @@ +import type { Intent } from '@jet/environment/dispatching'; +import { FlowAction } from '@jet-app/app-store/api/models'; + +export const FLOW_ACTION_KIND: FlowAction['$kind'] = 'flowAction'; + +/** + * Creates a FlowAction For a given destination. + * + * Note: this is only here temporarily as a convenience for the "web" client, to be used + * while the upstream `FlowAction` is represented as a class that needs to be constructed, + * so those details are abstracted away from our codebase. Once `FlowAction` has been + * migrated to a POJO, there should be a factory-function provided that we should leverage + * instead + * + * @param destination Destination of the `FlowAction` + */ +export function makeFlowAction(destination: Intent<unknown>): FlowAction { + const action = new FlowAction( + // This data is only used by the Jet app's `PageRouter` architecture, which is not + // relevant for us. We should safely be able to pass an arbitrary value here. + 'page', + ); + + // The important part for the "web" client router: setting the `destination` + action.destination = destination; + + return action; +} diff --git a/src/jet/models/page.ts b/src/jet/models/page.ts new file mode 100644 index 0000000..a05e59f --- /dev/null +++ b/src/jet/models/page.ts @@ -0,0 +1,177 @@ +import type { + ArticlePage, + ChartsHubPage, + GenericPage, + SearchLandingPage, + SearchResultsPage, + ShelfBasedProductPage, + TopChartsPage, + TodayPage, + SeeAllPage, +} from '@jet-app/app-store/api/models'; +import { StaticMessagePage } from '~/jet/models/static-message-page'; +import { isObject } from '~/utils/types'; +import { ErrorPage } from './error-page'; +import type { WebRenderablePage } from 'node_modules/@jet-app/app-store/src/api/models/web-renderable-page'; + +/** + * The union of every type of page that the App Store Onyx app can render + */ +export type Page = ( + | ArticlePage + | ChartsHubPage + | GenericPage + | SearchLandingPage + | SearchResultsPage + | ShelfBasedProductPage + | StaticMessagePage + | TopChartsPage + | TodayPage + | ErrorPage +) & + // TS needs to be told this explicitly, even though all the above implement this + WebRenderablePage; + +/** + * Detects if {@linkcode page} is actually an {@linkcode AppEventDetailPage} + */ +export function isAppEventDetailPage(page: Page): page is GenericPage { + return ( + 'shelves' in page && + page.shelves.some(({ contentType }) => contentType === 'appEventDetail') + ); +} + +/** + * Detects if {@linkcode page} is actually an {@linkcode ArticlePage} + */ +export function isArticlePage(page: Page): page is ArticlePage { + return 'card' in page && 'shelves' in page; +} + +/** + * Detects if {@linkcode page} is actually a {@linkcode ChartsHubPage} + */ +export function isChartsHubPage(page: Page): page is ChartsHubPage { + return 'charts' in page; +} + +/** + * Detects if {@linkcode page} is actually a {@linkcode GenericPage} + */ +export function isGenericPage(page: Page): page is GenericPage { + return 'shelves' in page; +} + +/** + * Detects if {@linkcode page} is actually a {@linkcode ShelfBasedProductPage} + */ +export function isShelfBasedProductPage( + page: Page, +): page is ShelfBasedProductPage { + return 'shelfMapping' in page && !('seeAllType' in page); +} + +/** + * Detects if {@linkcode page} is actually a {@linkcode SeeAllPage} + */ +export function isSeeAllPage(page: Page): page is SeeAllPage { + return 'seeAllType' in page; +} + +/** + * Detects if {@linkcode page} is actually a {@linkcode SearchLandingPage} + */ +export function isSearchLandingPage(page: Page): page is SearchLandingPage { + return 'adIncidents' in page; +} + +/** + * Detects if {@linkcode page} is actually a {@linkcode SearchResultsPage} + */ +export function isSearchResultsPage(page: Page): page is SearchResultsPage { + return 'searchClearAction' in page || 'searchCancelAction' in page; +} + +/** + * Detects if {@linkcode page} is actually a {@linkcode TopChartsPage} + */ +export function isTopChartsPage(page: Page): page is TopChartsPage { + return 'segments' in page && 'categories' in page; +} + +/** + * Detects if {@linkcode page} is actually a {@linkcode TodayPage} + */ +export function isTodayPage(page: Page): page is TodayPage { + return 'titleDetail' in page; +} + +/** + * Detects if {@linkcode page} is actually a {@linkcode StaticMessagePage} + */ +export function isStaticMessagePage( + page: GenericPage, +): page is StaticMessagePage { + return 'pageType' in page && page.pageType === 'staticMessagePage'; +} + +export function isErrorPage(page: GenericPage) { + return 'pageType' in page && page.pageType === 'errorPage'; +} + +/** + * Type-guard that determines if the provided {@linkcode page} matches a renderable {@linkcode Page} definition + */ +export function isPage(page: unknown): page is Page { + if (!isObject(page)) { + return false; + } + + return [ + isAppEventDetailPage, + isArticlePage, + isChartsHubPage, + isGenericPage, + isShelfBasedProductPage, + isSearchLandingPage, + isSearchResultsPage, + isTopChartsPage, + isTodayPage, + isErrorPage, + isSeeAllPage, + ].some((specificPageTypePredicate) => + specificPageTypePredicate( + // This type-cast reflects the fact that we don't really know if `page` is really a `Page`, + // but that we're going to use the type-guards of our `Page` members to see if `page` looks + // like one of them + page as Page, + ), + ); +} + +/** + * Type-assertion that determines if the provided {@linkcode page} matches a renderable {@linkcode Page} definition + */ +export function assertIsPage(page: unknown): asserts page is Page { + if (!isPage(page)) { + throw new Error( + 'The view-model for the dispatched `Intent` does not match a known renderable shape', + ); + } +} + +/** + * Detects if {@linkcode page} has the Vision Pro pathname in it's URL. + */ +export function hasVisionProUrl(page: GenericPage) { + if (!page.canonicalURL) { + return false; + } + + const url = new URL(page.canonicalURL); + return ( + url.pathname.includes('/vision/apps-and-games') || + url.pathname.includes('/vision/arcade') + ); +} diff --git a/src/jet/models/static-message-page.ts b/src/jet/models/static-message-page.ts new file mode 100644 index 0000000..91dafb0 --- /dev/null +++ b/src/jet/models/static-message-page.ts @@ -0,0 +1,33 @@ +import { GenericPage } from '@jet-app/app-store/api/models'; + +const contentTypes = [ + 'win-back', + 'carrier', + 'invoice', + 'contingent-price', +] as const; + +export type ContentType = (typeof contentTypes)[number]; + +export class StaticMessagePage extends GenericPage { + constructor({ + titleLocKey, + contentType, + }: { + titleLocKey: string; + contentType: ContentType; + }) { + super([]); + this.titleLocKey = titleLocKey; + this.contentType = contentType; + } + + titleLocKey?: string; + + // Used to indicate which type of content the page needs to show, used to pull in the proper + // LOC keys when rendering + contentType: ContentType; + + // Used in our type guards to narrow a `Page` down to a `StaticMessagePage` + pageType: string = 'staticMessagePage'; +} diff --git a/src/jet/svelte.ts b/src/jet/svelte.ts new file mode 100644 index 0000000..f1870ca --- /dev/null +++ b/src/jet/svelte.ts @@ -0,0 +1,45 @@ +import { getContext } from 'svelte'; +import type { Opt } from '@jet/environment'; +import type { ActionOutcome } from '@jet/engine'; + +import type { ActionModel } from '~/jet/models'; +import type { Jet } from '~/jet/jet'; + +export const CONTEXT_NAME = 'jet'; + +/** + * Gets the current Jet instance from the Svelte context. + * + * @return jet The current instance of Jet + */ +export function getJet(): Jet { + const jet = getContext<Opt<Jet>>(CONTEXT_NAME); + + if (!jet) { + throw new Error('getJet called before Jet.load'); + } + + return jet; +} + +/** + * Jet helper to expose jet.perform in single location + * + * @return Promise<ActionOutcome> + */ +type ActionUndefined = 'noActionProvided'; + +export function getJetPerform(): ( + action: ActionModel, +) => Promise<ActionOutcome | ActionUndefined> { + const jet = getJet(); + + return (action: ActionModel) => { + if (!action) { + //TODO: rdar://73165545 (Error Handling Across App) + return Promise.resolve('noActionProvided'); + } + + return jet.perform(action); + }; +} diff --git a/src/jet/utils/app-event-formatted-date.ts b/src/jet/utils/app-event-formatted-date.ts new file mode 100644 index 0000000..c885687 --- /dev/null +++ b/src/jet/utils/app-event-formatted-date.ts @@ -0,0 +1,194 @@ +import { + type Optional, + isSome, + isNothing, +} from '@jet/environment/types/optional'; +import type { LocalizationWrapper } from '@jet-app/app-store/foundation/wrappers/localization'; +import type { + AppEventFormattedDate, + AppEventBadgeKind, +} from '@jet-app/app-store/api/models'; +import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; +import { formattedDatesWithKind } from '@jet-app/app-store/common/app-promotions/app-event'; + +/** + * Partial type of {@linkcode AppEventFormattedDate} with just the properties + * that are actually used + */ +export type RequiredAppEventFormattedDate = Pick< + AppEventFormattedDate, + 'displayText' | 'displayFromDate' | 'countdownToDate' | 'countdownStringKey' +>; + +/** + * Represents a client-side serialization of an {@linkcode RequiredAppEventFormattedDate} + * + * This is needed because our client-side code will receive the event object with `Date` properties + * serialized as ISO 8601-formatted strings, while the server-side code will receive the original + * `Date` values. We need to normalize this to make sure we have consistent logic in both environments + */ +type SerializedAppEventFormattedDate = Pick< + RequiredAppEventFormattedDate, + 'displayText' | 'countdownStringKey' +> & { + readonly displayFromDate?: string; + readonly countdownToDate?: string; +}; + +function deserializeDate(value: Optional<Date | string>): Date | undefined { + if (isNothing(value)) { + return undefined; + } + + return typeof value === 'string' ? new Date(value) : value; +} + +/** + * Turn {@linkcode date} in either the client- or server-side format into the + * server-side format by parsing the ISO 8601 string values into `Date` instances + */ +function deserializeDateProperties( + date: SerializedAppEventFormattedDate | RequiredAppEventFormattedDate, +): RequiredAppEventFormattedDate { + const { countdownToDate, displayFromDate, ...rest } = date; + + return { + // Normalize properties that might have been serialized as `string` to `Date` + countdownToDate: deserializeDate(countdownToDate), + displayFromDate: deserializeDate(displayFromDate), + + // Use all of the other properties with their existing values + ...rest, + }; +} + +/** + * A {@linkcode RequiredAppEventFormattedDate} with a definitely-defined `.displayFromDate` property + */ +type AppEventFormattedDateWithDisplayFromDate = + RequiredAppEventFormattedDate & { + readonly displayFromDate: Date; + }; + +function hasDisplayRequirement( + date: RequiredAppEventFormattedDate, +): date is AppEventFormattedDateWithDisplayFromDate { + return isSome(date.displayFromDate); +} + +export function chooseAppEventDate( + dates: (SerializedAppEventFormattedDate | RequiredAppEventFormattedDate)[], +): Optional<RequiredAppEventFormattedDate> { + const nowTime = Date.now(); + + // We might be passed `dates` in the expected format (server-side) or with their `Date` + // properties serialized as strings (client-side); we need to normalize them all to the + // same format + const normalizedDates = dates.map((date) => + deserializeDateProperties(date), + ); + + // A `dates` member might not have a `.displayFromDate`; if that's the case, we will + // use that as a fallback if all other options are in the future + const fallback = normalizedDates.find( + (date) => !hasDisplayRequirement(date), + ); + + // Find all of the `dates` members with a `.displayFromDate` in the past + const optionsWithPastDisplayFromDates = normalizedDates + // Ensure all `date` objects have a display requirement + .filter((date) => hasDisplayRequirement(date)) + // Filter out any `date` objects with a display requirement in the future + .filter((date) => { + const dateTime = date.displayFromDate.getTime(); + const timeDifference = nowTime - dateTime; + + return timeDifference > 0; + }); + + // If there are none, use the fallback + if (optionsWithPastDisplayFromDates.length === 0) { + return fallback; + } + + // Otherwise, find the `date` object with the most recent `.displayFromDate` + return optionsWithPastDisplayFromDates.reduce((acc, next) => { + const accTime = acc.displayFromDate.getTime(); + const nextTime = next.displayFromDate.getTime(); + + // Which time is closer to "now"? + const accTimeDiff = nowTime - accTime; + const nextTimeDiff = nowTime - nextTime; + + return accTimeDiff > nextTimeDiff ? next : acc; + }); +} + +/** + * Partial type of {@linkcode LocalizationWrapper} with just the methods that + * are actually called + * + * This partial type simplifies testing by reducing the surface area of the function's + * dependencies + */ +type RequiredLocalization = Pick<LocalizationWrapper, 'string'>; + +function msToMinutes(ms: number): number { + return ms / (1_000 * 60); +} + +export function renderDate( + localization: RequiredLocalization, + date: RequiredAppEventFormattedDate, +): Optional<string> { + if (typeof date.countdownStringKey === 'string' && date.countdownToDate) { + const nowTime = Date.now(); + const translationString = localization.string(date.countdownStringKey); + + const countdownToDateTime = date.countdownToDate.getTime(); + const diffTime = countdownToDateTime - nowTime; + + const count = Math.floor(msToMinutes(diffTime)); + + return translationString.replace('@@count@@', count.toString()); + } + + if (typeof date.displayText === 'string') { + return date.displayText; + } + + return undefined; +} + +/** + * Helper function to compute formatted dates for app events. + * Handles date conversion and error handling. + * + * @param objectGraph - objectGraph from Jet + * @param badgeKind - The badge kind from the app event + * @param startDate - The start date (string or Date) + * @param endDate - The optional end date (string or Date) + * @returns Array of formatted dates or undefined if an error occurs + */ +export function computeAppEventFormattedDates( + objectGraph: AppStoreObjectGraph, + badgeKind: AppEventBadgeKind, + startDate: string | Date, + endDate?: string | Date | null, +): RequiredAppEventFormattedDate[] | undefined { + // Use deserializeDate function to convert dates + const startDateObj = deserializeDate(startDate); + const endDateObj = deserializeDate(endDate); + + // Validate that we have a valid start date + if (!startDateObj || isNaN(startDateObj.getTime())) { + return undefined; + } + + return formattedDatesWithKind( + objectGraph, + badgeKind, + startDateObj, + endDateObj, + ); +} diff --git a/src/jet/utils/error-metadata.ts b/src/jet/utils/error-metadata.ts new file mode 100644 index 0000000..1322dfd --- /dev/null +++ b/src/jet/utils/error-metadata.ts @@ -0,0 +1,16 @@ +import type { Opt } from '@jet/environment'; +import type { Intent } from '@jet/environment/dispatching'; + +export function addRejectedIntent(error: Error, intent: Intent<unknown>) { + (error as any).rejectedIntent = intent; +} + +export function getRejectedIntent(error: Error): Opt<Intent<unknown>> { + return hasRejectedIntent(error) ? error.rejectedIntent : null; +} + +function hasRejectedIntent( + error: Error, +): error is Error & { rejectedIntent: Intent<unknown> } { + return 'rejectedIntent' in error; +} diff --git a/src/jet/utils/handle-modal-presentation.ts b/src/jet/utils/handle-modal-presentation.ts new file mode 100644 index 0000000..9040d4f --- /dev/null +++ b/src/jet/utils/handle-modal-presentation.ts @@ -0,0 +1,29 @@ +import { getModalPageStore } from '~/stores/modalPage'; +import { isGenericPage, type Page } from '../models'; +import type { Logger } from '@amp/web-apps-logger/src'; + +/** + * This function handles rendering flow action pages into a modal container. + * NOTE: Rendering a page in a modal will not update URL or history + * + * @param page page promise + * @param log app logger + */ +export const handleModalPresentation = ( + page: { promise: Promise<Page> }, + log: Logger<unknown[]>, + pageDetail?: string, +) => { + page.promise + .then((page) => { + if (isGenericPage(page)) { + const modalStore = getModalPageStore(); + modalStore.setPage({ page, pageDetail }); + } else { + throw new Error('only generic page is rendered in modal'); + } + }) + .catch((e) => { + log.error('modal presentation failed', e); + }); +}; diff --git a/src/jet/utils/with-platform.ts b/src/jet/utils/with-platform.ts new file mode 100644 index 0000000..6e11ab8 --- /dev/null +++ b/src/jet/utils/with-platform.ts @@ -0,0 +1,5 @@ +import type { WithPlatform } from 'node_modules/@jet-app/app-store/src/api/models/preview-platform'; + +export function isWithPlatform(x: unknown): x is WithPlatform { + return typeof x === 'object' && x !== null && 'platform' in x; +} |
