UNPKG

@applicaster/zapp-react-native-ui-components

Version:

Applicaster Zapp React Native ui components for the Quick Brick App

323 lines (260 loc) 8.45 kB
import React, { useMemo } from "react"; import * as R from "ramda"; import memoizee from "memoizee"; import { platformSelect } from "@applicaster/zapp-react-native-utils/reactUtils"; import { useActions } from "@applicaster/zapp-react-native-utils/reactHooks/actions"; import { isValidColor } from "@applicaster/zapp-react-native-utils/colorUtils"; import { getColorFromData } from "@applicaster/zapp-react-native-utils/cellUtils"; import { pathOr, get, isNil } from "@applicaster/zapp-react-native-utils/utils"; import { isNilOrEmpty } from "@applicaster/zapp-react-native-utils/reactUtils/helpers"; import { useRoute } from "@applicaster/zapp-react-native-utils/reactHooks"; import { useScreenStateStore } from "@applicaster/zapp-react-native-utils/reactHooks/navigation/useScreenStateStore"; import { isNotEmptyString } from "@applicaster/zapp-react-native-utils/stringUtils"; import { masterCellLogger } from "../logger"; import { getCellState } from "../../Cell/utils"; import { isCellSelected, useBehaviorUpdate } from "./behaviorProvider"; const hasElementSpecificViewType = (viewType) => (element) => { if (isNil(element)) { return false; } if (element.type === viewType) { return true; } // eslint-disable-next-line @typescript-eslint/no-use-before-define return hasElementsSpecificViewType(viewType)(element.elements); }; export const hasElementsSpecificViewType = (viewType) => (elements) => { if (isNilOrEmpty(elements)) { return false; } return R.any(hasElementSpecificViewType(viewType))(elements); }; const logWarning = ( colorValueFromCellStyle, colorFromProp, colorValueFromEntry ) => { if (isNil(colorValueFromEntry)) { masterCellLogger.warn({ message: `Cannot resolve property ${colorValueFromCellStyle} from the entry.`, data: { configurationValue: colorValueFromCellStyle, colorFromProp, }, }); } }; export function resolveColorForProp( entry: any, colorFromProp: string | undefined ) { if (!colorFromProp) { return undefined; } const nestedEntryValue: string | undefined = get( entry, colorFromProp.split(".") ); if (nestedEntryValue === undefined && !isValidColor(colorFromProp)) { logWarning(colorFromProp, colorFromProp, nestedEntryValue); return undefined; } const colorValue = getColorFromData({ data: entry, valueFromLayout: colorFromProp, }); if (!colorValue) { logWarning(colorFromProp, colorFromProp, nestedEntryValue); return undefined; } return colorValue; } const getColorKeys = memoizee((style) => { const styleKeys = Object.keys(style); return styleKeys.filter((key) => /color/i.test(key)); }); /** * A color style value is either a literal color (e.g. "#FFFFFF") or a * data-mapping path into the entry. Data mappings always point at the entry * `extensions` object (e.g. "extensions.brandColor"), so we only need to do a * path lookup when the value starts with "extensions" — everything else is a * literal color and can be returned as-is. * * This avoids the previous, very expensive approach that ran `validateColor` * on every value and memoized on a full `fast-json-stable-stringify` of the * entire entry. */ const EXTENSIONS_PREFIX = "extensions"; const isDataMappingPath = (value: string): boolean => typeof value === "string" && value.startsWith(EXTENSIONS_PREFIX); export const resolveColor = ( entry, style, allowDynamicColorsOutsideExtensions ) => { if (style === null || style === undefined) { return style; } const colorKeys = getColorKeys(style); if (colorKeys.length === 0) { return style; } return colorKeys.reduce( (acc, key) => { const value = acc[key]; // 1. The Expensive Edge-Case (Only for the app with root mappings) if (allowDynamicColorsOutsideExtensions && isNotEmptyString(value)) { const possibleColor = resolveColorForProp(entry, value); acc[key] = isValidColor(possibleColor) ? possibleColor : null; return acc; } // 2. The Fast Path (For 99% of apps) if (isDataMappingPath(value)) { // resolve the mapped color from the entry; fall back to null // (RN ignores null style values) when the path is missing const possibleColor = pathOr(null, value.split("."), entry); acc[key] = possibleColor; return acc; } // 3. Default Case: Treat as a raw color string acc[key] = value; return acc; }, { ...style } ); }; export function isVideoPreviewEnabled({ enable_video_preview = false, player_screen_id = null, }: { enable_video_preview: boolean; player_screen_id: string | null; }) { return enable_video_preview && !R.isEmpty(player_screen_id); } export const useFilterChildren = < T extends { props: { item: any; pluginIdentifier: string; }; }, >( children: T[] ): T[] => { const actions = useActions(""); const filteredChildren = children.filter((child) => { const item = child.props.item; const actionIdentifier = child.props.pluginIdentifier; const action = actions.actions[actionIdentifier]; // context value of specific plugin if (action?.module && action.module.context) { const currentValue = action.module.context._currentValue; return currentValue?.isActionAvailable ? currentValue.isActionAvailable(item) : true; } masterCellLogger.error({ message: `Action plugin for ${actionIdentifier} could not be found, check the configuration of your action button`, data: { item, action: child.props.pluginIdentifier }, }); return false; }); return filteredChildren; }; export const insertBetween = (separator, items) => { return items.reduce((acc, item, index) => { if (index + 1 >= items.length) { // last element acc.push(item); return acc; } acc.push(item); acc.push(separator(index)); return acc; }, []); }; const recursiveCloneElement = (focused: boolean) => (element) => { const { children, ...otherProps } = element.props; const state = focused ? "focused" : "default"; return React.cloneElement(element, { ...otherProps, state, // eslint-disable-next-line @typescript-eslint/no-use-before-define children: recursiveCloneElementsWithState(focused, children), }); }; export const recursiveCloneElementsWithState = (focused: boolean, children) => { if (isNilOrEmpty(children)) { return undefined; } return React.Children.map(children, recursiveCloneElement(focused)); }; const next = (currentIndex, items) => items[currentIndex + 1]; const previous = (currentIndex, items) => items[currentIndex - 1]; export const cloneElementsWithIds = ( ids: string[], children: React.ReactElement[] ) => { if (isNilOrEmpty(children)) { return undefined; } return React.Children.map(children, (element, index) => React.cloneElement(element, { nextFocusLeft: previous(index, ids), nextFocusRight: next(index, ids), }) ); }; export const getFocusedButtonId = (focusable) => { return platformSelect({ tvos: R.path(["itemID"], focusable), default: R.path(["props", "id"], focusable), }); }; export const useCellState = ({ item, behavior, focused, }: { item: ZappEntry; behavior: Behavior; focused: boolean; }): CellState => { const lastUpdate = useBehaviorUpdate(behavior); const router = useRoute(); const screenStateStore = useScreenStateStore(); const _isSelected = useMemo( () => isCellSelected({ item, screenRoute: router?.pathname, screenStateStore, behavior, }), [behavior, item, lastUpdate] ); return getCellState({ focused, selected: _isSelected }); }; export const hasFocusableInsideBuilder = (elementsBuilder) => (item) => { const elements = elementsBuilder({ entry: item }); return R.anyPass([ hasElementsSpecificViewType("ButtonContainerView"), hasElementsSpecificViewType("FocusableView"), ])(elements); }; export function getEntryState(state, selected) { if (state === "focused_selected") { return state; } if (state === "focused" && selected) { return "focused_selected"; } if (state === "focused" && !selected) { return "focused"; } if (state === "selected") { return "selected"; } return selected ? "selected" : "default"; }