@applicaster/zapp-react-native-ui-components
Version:
Applicaster Zapp React Native ui components for the Quick Brick App
370 lines (293 loc) • 9.49 kB
text/typescript
import React, { useEffect, useMemo } from "react";
import * as R from "ramda";
import validateColor from "validate-color";
import { platformSelect } from "@applicaster/zapp-react-native-utils/reactUtils";
import { useActions } from "@applicaster/zapp-react-native-utils/reactHooks/actions";
import { masterCellLogger } from "../logger";
import { playerManager } from "@applicaster/zapp-react-native-utils/appUtils";
import { usePlayer } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayer";
import { PushTopicManager } from "@applicaster/zapp-react-native-bridge/PushNotifications/PushTopicManager";
import { StorageMultiSelectProvider } from "@applicaster/zapp-react-native-bridge/ZappStorage/StorageMultiSelectProvider";
import { getCellState } from "../../Cell/utils";
import { getColorFromData } from "@applicaster/zapp-react-native-utils/cellUtils";
const hasElementSpecificViewType = (viewType) => (element) => {
if (R.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);
};
const logWarning = R.curry(
(colorValueFromCellStyle, style, entry, colorValueFromEntry) => {
if (R.isNil(colorValueFromEntry)) {
masterCellLogger.warn({
message: `Cannot resolve property ${colorValueFromCellStyle} from the entry.`,
data: {
configurationValue: colorValueFromCellStyle,
entry,
},
});
}
return style;
}
);
export const hasElementsSpecificViewType = (viewType) => (elements) => {
if (R.isNil(elements) || R.isEmpty(elements)) {
return false;
}
return R.any(hasElementSpecificViewType(viewType))(elements);
};
function resolveColorForProp(entry, style, colorProp) {
const colorFromProp = style[colorProp];
const nestedEntryValue = R.path(colorFromProp.split("."), entry);
const color = colorFromProp.replace(".00", "").replace(".0", ""); // https://github.com/dreamyguy/validate-color/issues/44
if (nestedEntryValue === undefined && !validateColor(color)) {
logWarning(colorFromProp, style, entry, nestedEntryValue);
}
const colorValue = getColorFromData({
data: entry,
valueFromLayout: colorFromProp,
});
if (!colorValue) {
logWarning(colorProp, style, entry, colorValue);
return style;
}
return { ...style, [colorProp]: colorValue };
}
export function resolveColor(entry, style) {
if (style === null || style === undefined) {
return style;
}
// TODO can be optimized to remove 3 O(n) loops
const styleKeys = Object.keys(style);
const colorKeys = styleKeys.filter((key) => /color/i.test(key));
return colorKeys.reduce((acc, value) => {
return { ...style, ...resolveColorForProp(entry, acc, value) };
}, 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 (R.isNil(children) || R.isEmpty(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, children) => {
if (R.isNil(children) || R.isEmpty(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 isSelected = (id: string | number, behavior?: Behavior) => {
if (!behavior) {
return false;
}
if (behavior?.selection_source === "now_playing") {
const player = playerManager.getActivePlayer();
if (player?.entry?.id === id) {
return true;
}
}
if (behavior?.select_mode === "single") {
return behavior.current_selection === id;
}
if (behavior?.select_mode === "multi") {
// TODO: Use generic resolver source
if (behavior.selection_source === "@{push/topics}") {
const tags = PushTopicManager.getInstance().getRegisteredTags();
return tags.includes(String(id));
}
if (Array.isArray(behavior.current_selection)) {
return behavior.current_selection.includes(id);
}
const currentSelection = String(behavior.current_selection);
if (currentSelection?.startsWith("@{ctx/")) {
const keyWithoutCtx = currentSelection.substring(
"@{ctx/".length,
currentSelection.length - 1
);
const selectedItems =
StorageMultiSelectProvider.getProvider(
keyWithoutCtx
)?.getSelectedItems();
return selectedItems?.includes(String(id));
}
}
return false;
};
export const useBehaviorUpdate = (behavior: Behavior) => {
const [lastUpdate, setLastUpdate] = React.useState(null);
const player = usePlayer();
const triggerUpdate = () => {
setLastUpdate(Date.now());
};
// TODO: Create generic RX to state update
useEffect(() => {
// TODO: Use generic resolver source
if (!behavior) {
return;
}
const currentSelection = String(behavior.current_selection);
if (currentSelection?.startsWith("@{ctx/")) {
const keyWithoutCtx = currentSelection.substring(
"@{ctx/".length,
currentSelection.length - 1
);
if (keyWithoutCtx) {
const subscription = StorageMultiSelectProvider.getProvider(
keyWithoutCtx
)
.getObservable()
.subscribe(() => {
triggerUpdate();
});
return () => {
subscription.unsubscribe();
};
}
}
}, [behavior]);
useEffect(() => {
if (!behavior) {
return;
}
if (behavior?.selection_source === "@{push/topics}") {
const subscription = PushTopicManager.getInstance()
.getEntryObservable()
.subscribe(() => {
triggerUpdate();
});
return () => {
subscription.unsubscribe();
};
}
}, [behavior]);
useEffect(() => {
if (!behavior) {
return;
}
if (behavior?.selection_source === "now_playing" && player) {
const subscription = player.getEntryObservable().subscribe(() => {
triggerUpdate();
});
return () => {
subscription.unsubscribe();
};
}
}, [behavior, player]);
return lastUpdate;
};
export const useCellState = ({
id,
behavior,
focused,
}: {
id: string | number;
behavior: Behavior;
focused: boolean;
}): CellState => {
const lastUpdate = useBehaviorUpdate(behavior);
const _isSelected = useMemo(
() => isSelected(id, behavior),
[behavior, id, 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";
}