UNPKG

@applicaster/zapp-react-native-utils

Version:

Applicaster Zapp React Native utilities package

643 lines (550 loc) • 17.3 kB
/// <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; };