@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
text/typescript
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";
}