init commit
This commit is contained in:
16
src/jet/action-handlers/browser.ts
Normal file
16
src/jet/action-handlers/browser.ts
Normal file
@@ -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);
|
||||
}
|
||||
33
src/jet/action-handlers/compound-action.ts
Normal file
33
src/jet/action-handlers/compound-action.ts
Normal file
@@ -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';
|
||||
});
|
||||
}
|
||||
19
src/jet/action-handlers/external-url-action.ts
Normal file
19
src/jet/action-handlers/external-url-action.ts
Normal file
@@ -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';
|
||||
});
|
||||
}
|
||||
369
src/jet/action-handlers/flow-action.ts
Normal file
369
src/jet/action-handlers/flow-action.ts
Normal file
@@ -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);
|
||||
}
|
||||
125
src/jet/bootstrap.ts
Normal file
125
src/jet/bootstrap.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
290
src/jet/dependencies/bag.ts
Normal file
290
src/jet/dependencies/bag.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
96
src/jet/dependencies/client.ts
Normal file
96
src/jet/dependencies/client.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
26
src/jet/dependencies/console.ts
Normal file
26
src/jet/dependencies/console.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
20
src/jet/dependencies/feature-flags.ts
Normal file
20
src/jet/dependencies/feature-flags.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
99
src/jet/dependencies/locale.ts
Normal file
99
src/jet/dependencies/locale.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
523
src/jet/dependencies/localization.ts
Normal file
523
src/jet/dependencies/localization.ts
Normal file
@@ -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');
|
||||
}
|
||||
}
|
||||
45
src/jet/dependencies/make-dependencies.ts
Normal file
45
src/jet/dependencies/make-dependencies.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
11
src/jet/dependencies/media-token-service.ts
Normal file
11
src/jet/dependencies/media-token-service.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
13
src/jet/dependencies/metrics-identifiers.ts
Normal file
13
src/jet/dependencies/metrics-identifiers.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
117
src/jet/dependencies/net.ts
Normal file
117
src/jet/dependencies/net.ts
Normal file
@@ -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();
|
||||
}
|
||||
59
src/jet/dependencies/object-graph.ts
Normal file
59
src/jet/dependencies/object-graph.ts
Normal file
@@ -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);
|
||||
}
|
||||
5
src/jet/dependencies/properties.ts
Normal file
5
src/jet/dependencies/properties.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export function makeProperties(): PackageProperties {
|
||||
return {
|
||||
clientFeatures: {},
|
||||
};
|
||||
}
|
||||
254
src/jet/dependencies/seo.ts
Normal file
254
src/jet/dependencies/seo.ts
Normal file
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
44
src/jet/dependencies/storage.ts
Normal file
44
src/jet/dependencies/storage.ts
Normal file
@@ -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>';
|
||||
}
|
||||
}
|
||||
30
src/jet/dependencies/user.ts
Normal file
30
src/jet/dependencies/user.ts
Normal file
@@ -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: {},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
68
src/jet/intents/charts-page-redirect-intent-controller.ts
Normal file
68
src/jet/intents/charts-page-redirect-intent-controller.ts
Normal file
@@ -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;
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
52
src/jet/intents/error-page-intent-controller.ts
Normal file
52
src/jet/intents/error-page-intent-controller.ts
Normal file
@@ -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;
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
28
src/jet/intents/route-url/route-url-controller.ts
Normal file
28
src/jet/intents/route-url/route-url-controller.ts
Normal file
@@ -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;
|
||||
},
|
||||
};
|
||||
48
src/jet/intents/route-url/route-url-intent.ts
Normal file
48
src/jet/intents/route-url/route-url-intent.ts
Normal file
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
320
src/jet/jet.ts
Normal file
320
src/jet/jet.ts
Normal file
@@ -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)
|
||||
}
|
||||
19
src/jet/metrics/providers/StorefrontFieldsProvider.ts
Normal file
19
src/jet/metrics/providers/StorefrontFieldsProvider.ts
Normal file
@@ -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');
|
||||
}
|
||||
}
|
||||
15
src/jet/metrics/providers/index.ts
Normal file
15
src/jet/metrics/providers/index.ts
Normal file
@@ -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',
|
||||
},
|
||||
];
|
||||
}
|
||||
20
src/jet/metrics/settings.ts
Normal file
20
src/jet/metrics/settings.ts
Normal file
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
15
src/jet/models/error-page.ts
Normal file
15
src/jet/models/error-page.ts
Normal file
@@ -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>;
|
||||
}
|
||||
7
src/jet/models/external-action.ts
Normal file
7
src/jet/models/external-action.ts
Normal file
@@ -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';
|
||||
}
|
||||
28
src/jet/models/flow-action.ts
Normal file
28
src/jet/models/flow-action.ts
Normal file
@@ -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;
|
||||
}
|
||||
177
src/jet/models/page.ts
Normal file
177
src/jet/models/page.ts
Normal file
@@ -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')
|
||||
);
|
||||
}
|
||||
33
src/jet/models/static-message-page.ts
Normal file
33
src/jet/models/static-message-page.ts
Normal file
@@ -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';
|
||||
}
|
||||
45
src/jet/svelte.ts
Normal file
45
src/jet/svelte.ts
Normal file
@@ -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);
|
||||
};
|
||||
}
|
||||
194
src/jet/utils/app-event-formatted-date.ts
Normal file
194
src/jet/utils/app-event-formatted-date.ts
Normal file
@@ -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,
|
||||
);
|
||||
}
|
||||
16
src/jet/utils/error-metadata.ts
Normal file
16
src/jet/utils/error-metadata.ts
Normal file
@@ -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;
|
||||
}
|
||||
29
src/jet/utils/handle-modal-presentation.ts
Normal file
29
src/jet/utils/handle-modal-presentation.ts
Normal file
@@ -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);
|
||||
});
|
||||
};
|
||||
5
src/jet/utils/with-platform.ts
Normal file
5
src/jet/utils/with-platform.ts
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user