@applicaster/zapp-react-native-utils
Version:
Applicaster Zapp React Native utilities package
498 lines (434 loc) • 14 kB
JavaScript
import * as R from "ramda";
import { subscriber } from "@applicaster/zapp-react-native-utils/functionUtils";
import {
QUICK_BRICK_EVENTS,
sendQuickBrickEvent,
} from "@applicaster/zapp-react-native-bridge/QuickBrick";
import { Hook } from "./Hook";
import { HOOKS_EVENTS, HOOKS_TYPE } from "./constants";
import { hooksManagerLogger } from "./logger";
/**
* creates a hooksManager object to handle the hooks process.
* it is based on the `subscriber` object
* @param {object} options
* @param {object} rivers map of rivers in the app
* @param {object} targetScreen rivers which we are navigating to
* @param {Array<object>} plugins array of plugins in the app
* @returns {object} hooksManager object - see Navigator for usage
*/
export function HooksManager({
rivers,
targetScreen,
plugins,
startUpHooksData = {},
}) {
hooksManagerLogger.addContext({ targetScreen });
function logHookEvent(func, message, data) {
func({
message,
data: __DEV__ ? data : null,
});
}
const hooksManager = subscriber();
/**
* attaches the screen data to the hook object if the hook
* is a screen
* @param {object} hook object
* @returns {object} hook object with river data
*/
function addScreenDataIfNeeded(hook) {
const { screen_id } = hook;
const screen = R.pathOr({}, [screen_id], rivers);
return R.mergeDeepLeft(hook, screen);
}
/**
* logs to Xray when a hook is missing
* @param {object} hook object
* @return {function} curried function to be invoked
* in ramda's map function
*/
function logMissingHook(hook) {
const hookMessage = (identifier) =>
`Could not find hook plugin ${identifier} - skipping execution`;
return function () {
logHookEvent(hooksManagerLogger.warn, hookMessage(hook.identifier), {
hook,
});
return null;
};
}
/**
* retrieves the hook module in the plugins and adds it to
* the hook object if it exists,
* @param {object} hook object
* @returns {object} hook with plugin module
*/
function getHookModule(hook) {
return R.compose(
R.ifElse(
R.isNil,
logMissingHook(hook),
R.compose(R.merge(hook), R.pick(["module", "configuration"]))
),
R.find(R.propEq("identifier", hook.identifier))
)(plugins);
}
/**
* returns a Hook object
* curried function of the form (x, y) => z => Hook
* @param {Array<object>} hooks list of sibling hooks to run in the same process
* @param {object} manager hook manager to attach the hook to
* @param {object} hook data from the rivers configuration
* @returns {Hook} object
*/
function constructHook(hooks, manager) {
return function (hook) {
return hook ? new Hook({ hook, hooks, manager }) : null;
};
}
/**
* orders the hooks according to their weight
* @param {Array<object>} hooks to sort out
* @returns {Array<object>} sorted hooks
*/
function orderHooksByWeight(hooks) {
return R.sortBy(R.compose(Number, R.prop("weight")), hooks);
}
/**
* extracts the hooks form the target river. returns null if there are none
* if there are hooks, gathers all required data and constructs a Hook object
* @param {string} hookType see constants/HOOK_TYPES
* @param {river} river to look for hooks to run
* @returns {Array<Hooks>} list of hooks to go through
*/
function getHooks(hookType, river) {
const playerhooks = [];
if (riverIsPlayer(river)) {
playerhooks.push(...injectPlayerHooks(hookType));
}
let hooks = R.concat(playerhooks, R.pathOr([], ["hooks", hookType], river));
hooks = R.sortBy(R.prop("weight"))(hooks);
const { startUpHooks, setStartUpHooks } = startUpHooksData;
if (startUpHooks && startUpHooks !== "in_process") {
hooks.unshift(...startUpHooks);
setStartUpHooks("in_process");
}
if (!R.length(hooks)) {
return null;
}
return R.compose(
R.tap((_hooks) => {
const hooksLog = _hooks.map((o) => o?.identifier).join(", ");
logHookEvent(hooksManagerLogger.info, `running hooks: ${hooksLog}`, {
hooks: _hooks,
});
}),
R.reject(R.isNil),
R.map(
R.compose(
constructHook(hooks, hooksManager),
getHookModule,
addScreenDataIfNeeded
)
),
orderHooksByWeight
)(hooks);
}
/**
* tells wether the current river is related to a player plugin, or the default player
* @param {Object} river
* @returns {boolean}
*/
function riverIsPlayer(river) {
const { screenType, plugin_type } = river;
if (screenType || plugin_type) {
return screenType === "playable" || plugin_type === "player";
}
const { type } = river;
return R.compose(
R.propEq("type", "player"),
R.defaultTo({}),
R.find(R.propEq("identifier", type))
)(plugins);
}
/**
* Injects the hooks for the player
* @param {String} hookType type of hook
* @returns {Array<Object>}
*/
function injectPlayerHooks(_hookType) {
const playerhooks = R.compose(
R.sortBy(R.prop("weight")),
R.map((plugin) => ({
identifier: plugin.identifier,
weight: R.path(["module", "weight"], plugin) || 1,
})),
R.filter(R.pathEq(["module", "hasPlayerHook"], true))
)(plugins);
return playerhooks;
}
/**
* tells wether the given hook is the last one or not
* @param {Hook} hookPlugin
* @param {Array<Hook>} restOfHooks
* @returns {Boolean}
*/
function isLastHook(hookPlugin, restOfHooks) {
if (restOfHooks?.length === 0) return true;
return hookPlugin.lastHook;
}
/**
* creates the callback to invoke in order to proceed to the next hook
* covers the scenarios of failure & flow blocking
* @param {<Hook>} hookPlugin object for which the callback is built
* @param {Array<Hook>} restOfHooks to run
* @returns {function} callback function
*/
function hookCallback(hookPlugin, restOfHooks, initialPayload) {
/**
* callback invoked after a hook is executed
* @param {object} options
* @param {boolean} success result of the hook execution
* @param {?Error} error returned by the hook if relevant
* @param {object} payload returned by the hook, to be passed along to the next hook
*/
return function ({
success,
error,
payload = initialPayload,
callback,
cancelled,
}) {
if (error) {
logHookEvent(
hooksManagerLogger.error,
`hookCallback: hook returned with an error: ${error.message}`,
{
payload,
hook: hookPlugin,
error,
}
);
return hookPlugin.setStateAndNotify(HOOKS_EVENTS.ERROR, {
error,
hookPlugin,
payload,
callback,
});
}
if (!success) {
logHookEvent(
hooksManagerLogger.info,
`hookCallback: hook was cancelled: ${hookPlugin["identifier"]}`,
{
payload,
hook: hookPlugin,
}
);
// eslint-disable-next-line
// TODO: Temporary hack to pass getLoginProtocol to other plugins to refresh in case token expired, need be deleted later
delete payload.getLoginProtocol;
hookPlugin.setStateAndNotify(HOOKS_EVENTS.CANCEL, {
hookPlugin,
payload,
});
const isHookInHomescreen = payload?.home;
const isHookFlowBlocker =
hookPlugin?.module?.isFlowBlocker?.() ?? false;
// TODO: We technically need to also support the case where there is more than one hook in the stack,
// if needed we could add differentiation explicit user cancellation (when we should go to background) and other types of cancellation
if (isHookInHomescreen && isHookFlowBlocker && cancelled) {
logHookEvent(
hooksManagerLogger.info,
`hookCallback: send app to background, cancelled flow blocker hook ${hookPlugin["identifier"]} on home screen`,
{
payload,
hook: hookPlugin,
}
);
// TODO: Add this logic to platformBack and rename platformBack to platformToBackground for all platforms
sendQuickBrickEvent(QUICK_BRICK_EVENTS.MOVE_APP_TO_BACKGROUND, {
MOVE_APP_TO_BACKGROUND: true,
});
}
} else {
logHookEvent(
hooksManagerLogger.info,
`hookCallback: hook successfully finished: ${hookPlugin["identifier"]}`,
{
payload,
hook: hookPlugin,
}
);
hookPlugin.setStateAndNotify(HOOKS_EVENTS.SUCCESS, {
hookPlugin,
payload,
callback,
});
isLastHook(hookPlugin, restOfHooks)
? completeHook(hookPlugin, payload, callback)
: handleNextHook(restOfHooks, payload);
}
};
}
/**
* flags a hook as succeeded, and pass hook data along
* @param {Hook} hookPlugin
* @param {object} payload returned by the hook plugin
*/
function completeHook(hookPlugin, payload, callback) {
logHookEvent(
hooksManagerLogger.info,
`completeHook: hook sequence completed successfully: ${hookPlugin["identifier"]}`,
{
payload,
hook: hookPlugin,
}
);
// eslint-disable-next-line
// TODO: Temporary hack to pass getLoginProtocol to other plugins to refresh in case token expired, need be deleted later
delete payload.getLoginProtocol;
hookPlugin.setStateAndNotify(HOOKS_EVENTS.COMPLETE, {
hookPlugin,
payload,
callback,
});
}
/**
* presents a screen hook by triggering an event invoking the handler with
* the appropriate route & payload
* @param {Hook} hookPlugin to present
* @param {object} payload to pass to the hook screen
* @param {function} callback to invoke when the hook is successful
*/
hooksManager.presentScreenHook = function (hookPlugin, payload, callback) {
const targetScreenRoute = `/hooks/${
hookPlugin.screen_id || hookPlugin.identifier
}`;
const routeData = {
hookPlugin,
payload,
callback,
};
logHookEvent(
hooksManagerLogger.info,
`presentScreenHook: Presenting screen hook: ${hookPlugin["identifier"]}`,
{
hook: hookPlugin,
payload,
}
);
hookPlugin.setStateAndNotify(HOOKS_EVENTS.PRESENT_SCREEN_HOOK, {
hookPlugin,
route: targetScreenRoute,
payload: routeData,
});
};
/**
* executes a headless hook
* @param {Hook} hookPlugin to present
* @param {object} payload to pass to the hook module
* @param {function} callback to invoke when the hook is successful
*/
hooksManager.executeHook = function (hookPlugin, payload, callback) {
logHookEvent(
hooksManagerLogger.info,
`executeHook: ${hookPlugin["identifier"]}`,
{
hook: hookPlugin,
payload,
}
);
try {
hookPlugin.module.run(payload, callback, hookPlugin.configuration);
} catch (error) {
logHookEvent(
hooksManagerLogger.error,
`executeHook: error executing hook: ${hookPlugin["identifier"]} error: ${error.message}`,
{
hook: hookPlugin,
payload,
error,
}
);
hookPlugin.setStateAndNotify(HOOKS_EVENTS.ERROR, {
error: error,
hookPlugin,
payload,
});
}
};
/**
* executes a headless prehook in the background
* @param {Hook} hookPlugin to present
* @param {object} payload to pass to the hook module
* @param {function} callback to invoke when the hook is successful
* @param {function} presentUI to call when/if show the ui
*/
hooksManager.runInBackground = (hookPlugin, payload, callback, presentUI) => {
try {
logHookEvent(
hooksManagerLogger.info,
`runInBackground: Executing hook: ${hookPlugin["identifier"]}`,
{
hook: hookPlugin,
payload,
}
);
hookPlugin.module.runInBackground(
payload,
callback,
hookPlugin,
presentUI
);
} catch (e) {
hookPlugin.setStateAndNotify(HOOKS_EVENTS.ERROR, {
error: e,
hookPlugin,
payload,
});
}
};
/**
* handles the next hook in the list of provided hooks, passing the payload along
* @param {Array<Hook>} hooks to process
* @param {object} payload to pass to the hook
*/
function handleNextHook(hooks, payload) {
const [hookPlugin, ...restOfHooks] = orderHooksByWeight(hooks);
/**
* Commenting out part of the code which is trying to run the next hook
* in parallel - it's not in use, and it's creating unexpected side
* effects
* */
hookPlugin.execute(payload, hookCallback(hookPlugin, restOfHooks, payload));
}
/**
* function exposes on the hooks manager to trigger the handling of the hooks for
* the given targetScreen
* @param {object} payload to send to the hooks
* @returns {object} returns the hooks manager so methods can be chained
*/
hooksManager.handleHooks = function (payload) {
const preloadHooks = getHooks(HOOKS_TYPE.PRELOAD, targetScreen);
if (preloadHooks?.length) {
const hooks = preloadHooks.map((o) => o?.identifier).join(", ");
logHookEvent(
hooksManagerLogger.info,
`handleHooks: Starting hooks execution, hooks: ${hooks}`,
{
hooks: preloadHooks,
payload,
}
);
handleNextHook(preloadHooks, payload);
} else {
hooksManager.invokeHandler(HOOKS_EVENTS.COMPLETE, {
hookPlugin: { lastHook: true },
payload,
});
}
return hooksManager;
};
return hooksManager;
}