@applicaster/zapp-react-native-utils
Version:
Applicaster Zapp React Native utilities package
643 lines (550 loc) • 17.3 kB
text/typescript
/// <reference types="@applicaster/applicaster-types" />
import * as R from "ramda";
import { getIdResolver, itemIsOfType, SCREEN_TYPES } from "./itemTypes";
import { layoutV2TypeMatcher } from "./layoutV2TypeMatcher";
import { HOOKS_EVENTS } from "../appUtils/HooksManager/constants";
import { HooksManager } from "../appUtils/HooksManager";
import { appStore } from "@applicaster/zapp-react-native-redux/AppStore";
import { HookModalContextT } from "@applicaster/zapp-react-native-ui-components/Contexts/ZappHookModalContext";
import { logger } from "@applicaster/zapp-react-native-utils/logger";
import {
isGeneralPlugin,
isLink,
isPlayable,
isV2River,
} from "./itemTypeMatchers";
type PathAttribute = {
screenType: string;
screenId: string;
};
const navigationUtilsLogger = logger?.addSubsystem("NavigationUtils");
/**
* returns the type of navigation based on a navigation category
* @param {String} category to look for. can be either "nav_bar" or "menu"
* @param {Array} navigations array to look for the type
* @returns {String} navigation type for the selected category
*/
export function getNavigationType(
category: "nav_bar" | "menu",
navigations: ZappRiver["navigations"]
): string {
if (R.isEmpty(navigations)) {
navigationUtilsLogger.warning(
"getNavigationType: navigations data is empty"
);
}
return R.compose(
R.unless(R.isNil, R.prop("navigation_type")),
R.defaultTo(undefined),
R.find(R.propEq("category", category))
)(navigations);
}
/**
* returns the navigation plugin for the given category, and the provided plugins
* Will return the navigation plugin flagged as default
* @param {Object} options
* @param {String} options.category to look for. can be either "nav_bar" or menu
* @param {Array<Object>} navigations array to look for the type
* @param {Array<Object>} plugins list to search for the plugin to return
* @returns {Object} plugin to use
*/
export function resolveNavigationPlugin({
category,
navigations,
plugins,
}: {
category: "nav_bar" | "menu";
navigations: ZappRiver["navigations"];
plugins: QuickBrickPlugin[];
}): Nullable<QuickBrickPlugin> {
const navigationType = getNavigationType(category, navigations);
if (!navigationType) {
navigationUtilsLogger.warning(
`resolveNavigationPlugin: failed to resolve plugin with category ${category}`
);
return null;
}
const elligiblePlugins = R.filter(
R.propEq("identifier", navigationType),
plugins
);
if (R.length(elligiblePlugins) === 0) {
return R.find(R.propEq("type", category), plugins);
}
if (R.length(elligiblePlugins) > 1) {
return R.find(R.compose(R.not, R.prop("default")), elligiblePlugins);
}
return R.head(elligiblePlugins);
}
/**
* returns the navigation props for the provided category, for the given screen
* @param {Object} navigator to look for navigation props
* @param {String} category to look for in screen navigations
* @returns {Object} props
* @deprecated use getNavigationPropsV2 instead
*/
export function getNavigationProps({
navigator,
title = "",
category,
}: {
navigator: QuickBrickAppNavigator;
title: string;
category: string;
}): {
selected: string;
title: string;
home: boolean;
styles: unknown;
assets: ZappNavBarAssets;
rules: ZappNavBarRules;
nav_items: NavBarItem[];
} | null {
const { activeRiver } = navigator;
const { navigations, id: selected, home } = activeRiver; // use useRiver hook instead
const navigation: ZappNavigation = R.find(
R.propEq("category", category),
navigations
);
if (!navigation) {
return null;
}
const { styles, assets, nav_items, rules } = navigation;
return {
selected,
title,
home,
styles,
assets: assets as ZappNavBarAssets,
nav_items: nav_items as any as NavBarItem[],
rules: rules as any as ZappNavBarRules,
};
}
export function getNavigationPropsV2({
currentRiver,
title = "",
category,
}: {
currentRiver: ZappRiver;
title: string;
category: string;
}): {
selected: string;
title: string;
home: boolean;
styles: unknown;
assets: ZappNavBarAssets;
rules: ZappNavBarRules;
nav_items: NavBarItem[];
} | null {
const { navigations, id: selected, home } = currentRiver; // use useRiver hook instead
const navigation = R.find(R.propEq("category", category), navigations);
if (!navigation) {
return null;
}
const { styles, assets, nav_items, rules } = navigation;
return {
selected,
title,
home,
styles,
assets,
nav_items,
rules,
};
}
/**
* retrieves the type of item
* @param {object} item
* @param {'v1' | 'v2'} layoutVersion
* @param {object} contentTypes content types map
* @return {String} type of the item
*/
export function getItemType(
item,
layoutVersion?: Maybe<ZappLayoutVersions>,
contentTypes?: any
) {
if (layoutVersion === "v1") {
return R.compose(R.find(itemIsOfType(item)), R.values)(SCREEN_TYPES);
} else if (layoutVersion === "v2") {
return layoutV2TypeMatcher(item, contentTypes);
} else {
throw new Error(`Layout version: ${layoutVersion} is not supported`);
}
}
/**
* Finds correct screen_id for item type
* @param {object} payload ZappEntry or ZappFeed
* @param {object} contentTypes content types map
* @return {string} id of the item target
*/
export function getScreenIdForContentType(payload, contentTypes) {
const itemType = payload?.type?.value;
const typeTargetScreenId = contentTypes?.[itemType]?.screen_id;
return typeTargetScreenId;
}
/**
* retrieves a the Id of the item target based on an item and a type
* @param {object} item
* @param {string} screenType type of the screen
* @param {object} contentTypes content types map
* @param {object} rivers
* @return {string} id of the item target
*/
export function getItemTargetId(
item: ZappEntry | ZappRiver,
screenType?: string, // todo: creat screen types enum
contentTypes?: ZappContentTypes | null,
rivers?: Record<string, ZappRiver>
) {
// Pipes v2 resolution
if (contentTypes) {
const contentTypeScreenId = getScreenIdForContentType(item, contentTypes);
if (contentTypeScreenId) {
return contentTypeScreenId;
}
// TODO: remove link check when able to open external URL before this func
if (isV2River(item) || isGeneralPlugin(item) || isLink(item)) {
return item?.id;
}
// Fallback for playable items
if (isPlayable(item)) {
const playerScreen = R.compose(
R.find(R.propEq("plugin_type", "player")),
R.values
)(rivers);
return playerScreen?.id;
}
}
// old logic for pipes v1
return getIdResolver(screenType)(item);
}
/**
* returns the target route given the provided payload and pathname
* @param {object} payload data of the item which triggered the navigation
* @param {string} pathname current location if it exist
* @param {object} options current location if it exist
* @param {object} options.contentTypes content types mapping, required for v2 version
* @returns {string} targetRoute to navigate to
*/
export function getTargetRoute(
payload: ZappEntry | ZappRiver,
pathname = "",
{
contentTypes = undefined,
layoutVersion = "v2",
rivers = {},
}: {
contentTypes?: ZappContentTypes | null;
layoutVersion?: Maybe<ZappLayoutVersions>;
rivers?: Record<string, ZappRiver>;
} = {}
) {
const screenType = getItemType(payload, layoutVersion, contentTypes);
const screenId = getItemTargetId(payload, screenType, contentTypes, rivers);
// Can't create route without screenID or screenType
if (!screenId || !screenType) {
return null;
}
const routeForScreenType = screenType === "menu_item" ? "river" : screenType;
return R.replace("//", "/", `${pathname}/${routeForScreenType}/${screenId}`);
}
/**
* Returns the path attributes for a provided location
* will transform /screenType1/screenId/foo/bar
* into
[
{ screenType: "screenType1", screenId: "screenId"},
{ screenType: "foo", screenId: "bar"}
]
* @param {Object} location object
* @param {String} location.pathname path to get attributes from
* @returns {Array} PathAttributes array, of the form [{ screenType: String, screenId: String }]
*/
export function getPathAttributes({
pathname,
}: {
pathname: string;
}): PathAttribute[] {
if (pathname === "/" || pathname === "") {
return [];
}
return R.compose(
// mapping(["foo", "bar"] => { screenType: "foo", screenId: "bar" })
R.map(R.zipObj(["screenType", "screenId"])),
// ["foo", "bar", "baz", "qux"] => [["foo", "bar"], ["baz", "qux"]]
R.splitEvery(2),
// omitting the first empty element in the array since pathname starts with /
R.tail,
// spliting pathname string into array
R.split("/")
)(pathname);
}
/**
* retrieves the river from the given route, or null if route is not a river route
* @param {string} route
* @param {object} rivers
* @returns {object}
*/
export function getRiverFromRoute({ route, rivers }) {
if (!route) {
return null;
}
const { screenType, screenId } =
R.compose(
R.last,
getPathAttributes,
R.assoc("pathname", R.__, {})
)(route) || {};
if (rivers?.[screenId]) {
return rivers[screenId];
}
const findScreenTypeByKey = (key, type) =>
R.compose(R.find(R.propEq(key, type)), R.values)(rivers);
const pluginScreen =
findScreenTypeByKey("type", screenType) ||
(screenType === SCREEN_TYPES.PLAYABLE &&
findScreenTypeByKey("plugin_type", "player"));
if (pluginScreen) {
return pluginScreen;
}
return screenType && screenId ? { screenType, screenId } : null;
}
/**
* Function returns true if the previous route (in the react-router history) is a hook plugin
* @param {*} entries - React-Router History Object
* @return boolean
*/
export function isPreviousRouteHook(entries: [{ pathname: string }]): boolean {
const previousRoute = entries[entries.length - 2];
if (!previousRoute) return false;
return R.test(/\/hooks\/.*/g, previousRoute.pathname);
}
/**
* Function returns number of consecutive hooks that are behind the current route
* @param {*} history - React-Router History Object
* @return true
*/
export function getPreviousHooksCount(history: {
entries: [{ pathname: string }];
}): number {
let hooksCount = 0;
let entries = R.clone(history.entries);
while (isPreviousRouteHook(entries)) {
hooksCount++;
entries = R.init(entries);
}
return hooksCount;
}
export const usesVideoModal = (
item: ZappEntry,
rivers: Record<string, ZappRiver>,
contentTypes: ZappContentTypes
) => {
const targetScreenId = contentTypes?.[item?.type?.value]?.screen_id;
const targetScreenConfiguration = rivers?.[targetScreenId] as {
styles?: { use_video_modal: boolean };
};
return targetScreenConfiguration?.styles?.use_video_modal;
};
export const mapContentTypesToRivers = ({
rivers,
contentTypes,
}): ZappContentTypesMapped | null => {
if (!contentTypes) {
return null;
}
return R.compose(
R.reduce((types, key) => {
const screen = rivers?.[contentTypes?.[key]?.screen_id];
const screenType = screen?.plugin_type || screen?.type;
if (screenType) {
types[key] = R.assoc("screenType", screenType, contentTypes[key]);
}
return types;
}, {}),
R.keys
)(contentTypes);
};
export const runZappHooksForEntry = async (
entry: HookPluginProps["payload"],
{
setState,
resetState,
setIsHooksExecutionInProgress,
setIsPresentingFullScreen,
setIsRunningInBackground,
}: Partial<HookModalContextT>
): Promise<{ success: boolean; payload: ZappEntry }> => {
resetState?.();
let success;
let payload;
const rivers = appStore.get("rivers");
const plugins = appStore.get("plugins");
const appData = appStore.get("appData");
const contentTypes = mapContentTypesToRivers({
rivers,
contentTypes: appStore.get("contentTypes"),
});
const { layoutVersion } = appData;
setIsHooksExecutionInProgress?.(true);
const targetRoute = getTargetRoute(entry as ZappEntry, "", {
layoutVersion,
contentTypes,
});
const targetScreen = getRiverFromRoute({ route: targetRoute, rivers });
const hooksOptions = {
rivers,
plugins,
targetScreen,
};
const handleHookPresent = (hookProps) => {
const { route, payload } = hookProps;
setIsPresentingFullScreen?.();
setState?.({
path: route,
screenData: payload,
});
};
const handleBackgroundHook = (hookProps) => {
const { route, payload } = hookProps;
setState?.({
path: route,
screenData: payload,
});
setIsRunningInBackground?.();
};
const onFinished =
(_success) =>
({ payload: _payload }) => {
setIsHooksExecutionInProgress?.(false);
success = _success;
payload = _payload;
};
HooksManager(hooksOptions)
.on(HOOKS_EVENTS.PRESENT_SCREEN_HOOK, handleHookPresent)
.on(HOOKS_EVENTS.START_BACKGROUND_HOOK, handleBackgroundHook)
.on(HOOKS_EVENTS.ERROR, onFinished(false))
.on(HOOKS_EVENTS.CANCEL, onFinished(false))
.on(HOOKS_EVENTS.COMPLETE, onFinished(true))
.handleHooks(entry);
// eslint-disable-next-line no-unmodified-loop-condition
while (success === undefined) {
await new Promise((resolve) =>
setTimeout(() => {
resolve(undefined);
}, 125)
);
}
return { success, payload };
};
/**
* Function which tells if the provided nav items have focusable buttons
* @param {NavBarItem[]} navItems
* @return boolean
*/
export const hasButtonItems = (navItems: NavBarItem[]): boolean =>
navItems?.filter(R.propSatisfies(R.contains, "button"))?.length > 0;
/**
* Function which tells if a given nav item is a menu item or a nav bar item
* (button or image)
* @param {NavBarItem} navItem
* @return boolean
*/
export const isNavBarItem: (NavBarItem) => boolean = R.anyPass([
R.propEq("type", "tv_right_button"),
R.propEq("type", "tv_left_button"),
R.propEq("type", "tv_left_image"),
R.propEq("type", "tv_right_image"),
]);
/**
* This function sorts nav items in a Zapp navigation configuration
* and returns left items, menu items and right items. returned arrays can be empty
* @param {NavBarItem[]} - nav items to sort
* @return {[leftItems: NavBarItem[], menuItems: NavBarItem[], rightItems: NavBarItem[]]}
*/
export const getNavItems = (
navItems: NavBarItem[]
): [NavBarItem[], NavBarItem[], NavBarItem[]] => {
const sortedNavItems = R.sortBy(R.prop("position"), navItems);
const [navBarItems, menuItems] = R.partition(isNavBarItem, sortedNavItems);
const firstMenuItemPosition = R.compose(
R.prop("position"),
R.find(R.propEq("type", "label"))
)(sortedNavItems);
const [leftItems, rightItems] = R.compose(
R.partition((item) => Number(item.position) < firstMenuItemPosition),
R.sortBy(R.prop("position")),
R.reject(R.propEq("type", "label"))
)(navBarItems);
return [leftItems, menuItems, rightItems];
};
/**
* This function tells if the menu should be a valid focusable target or not
* Currently only used on Android TV. It will check if there are enabled left or right
* items, and if they have focusable elements
* @param {NavigationProps} Props sent to the Menu plugin
* @param {QuickBrickAppNavigator}
* @returns boolean;
*/
export function isMenuDisabled(
{ navigationProps },
navigator: QuickBrickAppNavigator
): boolean {
const secondaryLevel = navigator.canGoBack();
if (!secondaryLevel) return false;
const { styles, nav_items } = navigationProps;
const showButtons = styles.show_buttons;
const showImage = styles.show_left_image;
const [leftItems, _, rightItems] = getNavItems(nav_items);
const hasLeftButtons = hasButtonItems(leftItems) && showImage;
const hasRightButtons = hasButtonItems(rightItems) && showButtons;
const menuDisabled = !hasLeftButtons && !hasRightButtons;
return menuDisabled;
}
export function routeIsPlayerScreen(currentRoute) {
return currentRoute?.includes("/playable");
}
export const getNavBarProps =
(currentRiver: ZappRiver, pathname: string, title: string) => () => {
const props = getNavigationPropsV2({
currentRiver,
title,
category: "nav_bar",
});
if (props) {
return {
...props,
id: pathname,
pathname: pathname,
};
}
return null;
};
export const findMenuPlugin = (
navigations: ZappNavigation[],
plugins
): QuickBrickPlugin => {
return resolveNavigationPlugin({
category: "menu",
navigations,
plugins,
}) as QuickBrickPlugin;
};
export const shouldNavBarDisplayMenu = (currentRiver: ZappRiver, plugins) =>
findMenuPlugin(currentRiver.navigations, plugins)?.identifier ===
"quick_brick_side_menu" ||
currentRiver?.navigations.findIndex(
(nav) => nav.category === "nav_bar" && nav.general?.override_menu_target
) > -1;
export const getScreenId = (
screenData: LegacyNavigationScreenData,
fallbackRiver: ZappRiver
) => {
return screenData?.targetScreen
? (screenData?.targetScreen?.id ?? fallbackRiver.id)
: "screen_id" in screenData
? screenData.screen_id
: fallbackRiver.id;
};