@applicaster/zapp-react-native-ui-components 16.0.0-rc.70 → 16.0.0-rc.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,10 +2,22 @@ import React, { useCallback, useEffect } from "react";
2
2
  import { TouchableOpacity, ViewStyle } from "react-native";
3
3
 
4
4
  import { useActions } from "@applicaster/zapp-react-native-utils/reactHooks/actions";
5
+ import {
6
+ observeEntryState,
7
+ RegisteredActionValue,
8
+ } from "@applicaster/zapp-react-native-utils/uiActionsRegistrator";
9
+
10
+ import { masterCellLogger } from "../logger";
5
11
 
6
12
  import Image from "./Image";
7
13
  type Props = {
8
14
  item: ZappEntry | ZappFeed;
15
+ /**
16
+ * Already resolved action value. Pass it when the caller took the action out
17
+ * of `uiActionsRegistry` (entry actions have no plugin context to look up),
18
+ * and this skips the `useActions` lookup entirely.
19
+ */
20
+ actionContext?: RegisteredActionValue;
9
21
  flavour?: "flavour_1" | "flavour_2";
10
22
  asset?: {
11
23
  props: {};
@@ -32,6 +44,19 @@ function isStringAsset(asset) {
32
44
  return typeof asset === "string" || Array.isArray(asset);
33
45
  }
34
46
 
47
+ /**
48
+ * `CellActionEntryState["asset"]` is either something `Image` can take (a URI,
49
+ * or a per-flavour array) or a component to render. A plain object is neither -
50
+ * a locale/state map reaches us that way - and handing one to JSX throws
51
+ * "Element type is invalid", so it has to be recognised rather than assumed.
52
+ */
53
+ function isComponentAsset(asset): boolean {
54
+ return (
55
+ typeof asset === "function" ||
56
+ (typeof asset === "object" && asset !== null && "$$typeof" in asset)
57
+ );
58
+ }
59
+
35
60
  function getAssetValue(asset, flavour, fallbackAsset = null) {
36
61
  if (!asset) {
37
62
  return null;
@@ -55,26 +80,52 @@ function getAssetValue(asset, flavour, fallbackAsset = null) {
55
80
  export const ActionButton = React.memo(function ActionButtonComponent(
56
81
  props: Props
57
82
  ) {
58
- const { item, action, asset, flavour = "flavour_1", cellUUID } = props;
59
- const actionContext = useActions(action?.identifier);
83
+ const {
84
+ item,
85
+ action,
86
+ asset,
87
+ flavour = "flavour_1",
88
+ cellUUID,
89
+ actionContext: providedActionContext,
90
+ } = props;
91
+
92
+ // Passing `undefined` makes `useActions` a no-op lookup instead of warning
93
+ // about a plugin that was never meant to be there. Callers are expected to
94
+ // either always or never provide the context for a given mounted button -
95
+ // `useActions` already branches on plugin presence internally, so a value
96
+ // that appears mid-life would change the hook count either way.
97
+ const lookedUpActionContext = useActions(
98
+ providedActionContext ? undefined : action?.identifier
99
+ );
100
+
101
+ const actionContext = providedActionContext ?? lookedUpActionContext;
60
102
 
61
103
  // TODO: add subscription API for action availability
62
104
  const actionDisabled =
63
105
  typeof actionContext?.isActionAvailable === "function" &&
64
106
  !actionContext.isActionAvailable(item);
65
107
 
66
- // Note: in theory initialization is not needed anymore, we are using useEffect
67
- const [actionState, setActionState] = React.useState(
68
- actionDisabled || !actionContext
69
- ? null
70
- : actionContext.initialEntryState(item)
108
+ const [actionState, setActionState] = React.useState(() =>
109
+ actionDisabled ? null : actionContext?.initialEntryState?.(item)
71
110
  );
72
111
 
112
+ // `observeEntryState` is the one way to follow an action's state: it uses the
113
+ // action's own stream when it has one, and otherwise builds an equivalent one
114
+ // from `initialEntryState` + `addListener`. Reading only the latter pair
115
+ // leaves stream-backed actions - playback speed, sleep timer - frozen at the
116
+ // value they had when the button mounted.
73
117
  useEffect(() => {
74
- if (!((actionDisabled || !actionContext) && actionState !== null)) {
75
- setActionState(actionContext.initialEntryState(item));
118
+ if (actionDisabled || !actionContext) {
119
+ return undefined;
76
120
  }
77
- }, [actionDisabled, item?.id, actionContext, action, setActionState]);
121
+
122
+ const subscription = observeEntryState(actionContext, item).subscribe(
123
+ setActionState
124
+ );
125
+
126
+ return () => subscription.unsubscribe();
127
+ // eslint-disable-next-line @wogns3623/better-exhaustive-deps/exhaustive-deps
128
+ }, [actionDisabled, item?.id, actionContext]);
78
129
 
79
130
  const onPress = useCallback(() => {
80
131
  actionContext.invokeAction(item, {
@@ -82,15 +133,30 @@ export const ActionButton = React.memo(function ActionButtonComponent(
82
133
  setActionState(state);
83
134
  },
84
135
  });
85
- }, [actionState, actionContext?.state, item?.id]);
136
+ }, [actionContext, item]);
137
+
138
+ // An asset that is present but of a shape nothing can render (a locale or
139
+ // state map, say) drops the button for good - unlike a missing asset, which
140
+ // is usually just state that has not loaded yet. Reported from an effect so
141
+ // it is said once per action/entry rather than on every render.
142
+ const unrenderableAsset =
143
+ Boolean(actionState?.asset) &&
144
+ !isStringAsset(actionState?.asset) &&
145
+ !isComponentAsset(actionState?.asset);
86
146
 
87
147
  useEffect(() => {
88
- if (typeof actionContext?.addListener === "function") {
89
- return actionContext?.addListener(String(item.id), (state) => {
90
- setActionState(state);
148
+ if (unrenderableAsset) {
149
+ masterCellLogger.warning({
150
+ message: `ActionButton: the asset of action "${action?.identifier}" is neither an image source nor a component - the button is not rendered`,
151
+ data: {
152
+ identifier: action?.identifier,
153
+ entryId: item?.id,
154
+ assetType: typeof actionState?.asset,
155
+ },
91
156
  });
92
157
  }
93
- }, [item?.id, setActionState, actionContext]);
158
+ // eslint-disable-next-line @wogns3623/better-exhaustive-deps/exhaustive-deps
159
+ }, [unrenderableAsset, action?.identifier, item?.id]);
94
160
 
95
161
  if (actionDisabled || !actionContext) return null;
96
162
  const AssetComponent = actionState?.asset;
@@ -98,12 +164,22 @@ export const ActionButton = React.memo(function ActionButtonComponent(
98
164
  // Default state is not yet ready
99
165
  if (!AssetComponent) return null;
100
166
 
167
+ // Neither an image source nor a component - nothing renderable.
168
+ if (!isStringAsset(AssetComponent) && !isComponentAsset(AssetComponent)) {
169
+ return null;
170
+ }
171
+
101
172
  return (
102
173
  <TouchableOpacity
103
174
  activeOpacity={1}
104
175
  onPress={onPress}
105
176
  testID={props?.testID || `${item?.id}`}
106
- accessibilityLabel={props?.accessibilityLabel || `${item?.id}`}
177
+ accessibilityRole="button"
178
+ accessibilityLabel={
179
+ props?.accessibilityLabel ||
180
+ (typeof actionState?.label === "string" ? actionState.label : null) ||
181
+ `${item?.id}`
182
+ }
107
183
  accessibilityHint={props?.accessibilityHint}
108
184
  accessible={!!(props?.testID || props?.accessibilityLabel)}
109
185
  style={props?.style}
@@ -0,0 +1,54 @@
1
+ import React from "react";
2
+ import { useEntryActionState } from "@applicaster/zapp-react-native-utils/reactHooks/actions";
3
+ import {
4
+ isActionAvailableFor,
5
+ RegisteredAction,
6
+ } from "@applicaster/zapp-react-native-utils/uiActionsRegistrator";
7
+
8
+ import { Button } from "../Button";
9
+
10
+ export type ActionButtonProps = {
11
+ entry: ZappEntry;
12
+ item: RegisteredAction;
13
+ configuration: any;
14
+ width: number;
15
+ };
16
+
17
+ type Props = ActionButtonProps & {
18
+ onPress?: (item: any) => void;
19
+ };
20
+
21
+ /**
22
+ * Unified action button for the modal bottom sheet.
23
+ *
24
+ * Every item is a `RegisteredAction` resolved from the registry (entry actions,
25
+ * registry actions and legacy context-provider actions alike), so the button
26
+ * simply reads `item.action` and drives it through `useEntryActionState`
27
+ * (backed by the action's observable / `addListener`).
28
+ */
29
+ export function ActionButton(props: Props) {
30
+ const { item, entry, onPress: onItemPress } = props;
31
+
32
+ const action = item?.action;
33
+
34
+ const { state, invokeAction } = useEntryActionState(action, entry);
35
+
36
+ const onPress = React.useCallback(
37
+ (_item) => {
38
+ invokeAction({
39
+ context: "toast",
40
+ dismiss: () => onItemPress?.(item),
41
+ });
42
+ },
43
+ [invokeAction, item, onItemPress]
44
+ );
45
+
46
+ if (!isActionAvailableFor(action, entry as ZappEntry)) return null;
47
+
48
+ return (
49
+ <Button {...props} item={state} onPress={onPress} label={state?.label} />
50
+ );
51
+ }
52
+
53
+ // Explicitly set display name for the component
54
+ ActionButton.displayName = "ActionButton";
@@ -0,0 +1,21 @@
1
+ import React from "react";
2
+
3
+ import { ActionButton, ActionButtonProps } from "./ActionButton";
4
+
5
+ /**
6
+ * Binds an entry to the sheet's row button.
7
+ *
8
+ * `BottomSheetModalContent` renders `buttonComponent` per item and does not
9
+ * know about entries, so the entry is closed over here instead.
10
+ */
11
+ export const boundActionButton = (entry: ZappEntry | ZappFeed) => {
12
+ const ActionButtonWithEntry = (props: ActionButtonProps) => (
13
+ <ActionButton {...props} entry={entry as ZappEntry} />
14
+ );
15
+
16
+ ActionButtonWithEntry.displayName = "ActionButtonWithEntry";
17
+
18
+ return ActionButtonWithEntry;
19
+ };
20
+
21
+ boundActionButton.displayName = "boundActionButton";
@@ -0,0 +1,9 @@
1
+ export { ActionButton } from "./ActionButton";
2
+
3
+ export type { ActionButtonProps } from "./ActionButton";
4
+
5
+ export { boundActionButton } from "./boundActionButton";
6
+
7
+ export { openActionsBottomSheet } from "./openActionsBottomSheet";
8
+
9
+ export type { OpenActionsBottomSheetArgs } from "./openActionsBottomSheet";
@@ -0,0 +1,58 @@
1
+ import { createLogger } from "@applicaster/zapp-react-native-utils/logger";
2
+ import { openBottomSheetModal } from "@applicaster/zapp-react-native-utils/modalState";
3
+ import { noop } from "@applicaster/zapp-react-native-utils/functionUtils";
4
+ import { RegisteredAction } from "@applicaster/zapp-react-native-utils/uiActionsRegistrator";
5
+
6
+ import { boundActionButton } from "./boundActionButton";
7
+
8
+ const { log_warning } = createLogger({
9
+ subsystem: "ActionsBottomSheet",
10
+ category: "Presentation",
11
+ });
12
+
13
+ export type OpenActionsBottomSheetArgs = {
14
+ /** Entry the actions act on; bound into every row button. */
15
+ entry: ZappEntry | ZappFeed;
16
+ /** Already resolved and merged actions, in the order they should appear. */
17
+ actions: RegisteredAction[];
18
+ title?: string;
19
+ summary?: string;
20
+ };
21
+
22
+ /**
23
+ * Presents a list of resolved actions in the shared bottom sheet.
24
+ *
25
+ * This is the core entry point for "show these actions in a sheet": the
26
+ * open-modal-bottom-sheet plugin uses it for its configured list, and the
27
+ * player overflow uses it for the actions that did not fit on the overlay.
28
+ * Neither has to know about the other, and neither needs the plugin installed.
29
+ */
30
+ export function openActionsBottomSheet({
31
+ entry,
32
+ actions,
33
+ title,
34
+ summary,
35
+ }: OpenActionsBottomSheetArgs): void {
36
+ if (!actions?.length) {
37
+ // Every action resolved away - unavailable for this entry, or not
38
+ // invokable. An empty sheet looks broken, so say why and stay put.
39
+ log_warning(
40
+ "openActionsBottomSheet: nothing to present - not opening the sheet",
41
+ { entryId: (entry as ZappEntry)?.id }
42
+ );
43
+
44
+ return;
45
+ }
46
+
47
+ openBottomSheetModal({
48
+ modalBottomSheetContentProps: {
49
+ // TODO: should be optional - the button handles its own tap, but
50
+ // BottomSheetModalContent still requires an onPress.
51
+ onPress: noop,
52
+ items: actions,
53
+ buttonComponent: boundActionButton(entry),
54
+ title,
55
+ summary,
56
+ },
57
+ });
58
+ }
@@ -0,0 +1,65 @@
1
+ import * as React from "react";
2
+
3
+ import { ActionExecutorContext } from "@applicaster/zapp-react-native-utils/actionsExecutor/ActionExecutorContext";
4
+ import {
5
+ buildEntryActions,
6
+ EntryActionDeps,
7
+ } from "@applicaster/zapp-react-native-utils/entryActions";
8
+ import { useRoute } from "@applicaster/zapp-react-native-utils/reactHooks/navigation";
9
+ import { useScreenStateStore } from "@applicaster/zapp-react-native-utils/reactHooks/navigation/useScreenStateStore";
10
+ import {
11
+ useCurrentScreenData,
12
+ useScreenContext,
13
+ } from "@applicaster/zapp-react-native-utils/reactHooks/screen";
14
+ import { RegisteredAction } from "@applicaster/zapp-react-native-utils/uiActionsRegistrator";
15
+
16
+ import { ZappPipesEntryContext } from "../Contexts";
17
+
18
+ /**
19
+ * Expands `entry.extensions.entry_action` into invokable actions, against the
20
+ * screen this hook is called from.
21
+ *
22
+ * Entry actions are data on the entry, not an installed capability, so there is
23
+ * nothing to look up by identifier: the surface showing the entry expands them.
24
+ * That also decides which screen they execute against — the one whose tree this
25
+ * hook runs in, rather than whichever screen happened to be current when a
26
+ * global provider was registered.
27
+ *
28
+ * Pass the result to `resolveActionIdentifiers` / `resolvePlayerActions` as
29
+ * `expandEntryActions`.
30
+ */
31
+ export function useEntryActionsExpander(): (
32
+ entry: ZappEntry | ZappFeed
33
+ ) => RegisteredAction[] {
34
+ const { pathname } = useRoute();
35
+
36
+ const { context: screenEntryContext } = React.useContext(
37
+ ZappPipesEntryContext.Context
38
+ );
39
+
40
+ const screenData = useCurrentScreenData();
41
+ const screenState = useScreenContext()?.options;
42
+ const screenStateStore = useScreenStateStore();
43
+ const actionExecutor = React.useContext(ActionExecutorContext);
44
+
45
+ // Read through a ref so the returned expander keeps a stable identity: it
46
+ // feeds `useMemo` dependency lists on surfaces that re-resolve their actions
47
+ // on every registry change.
48
+ const depsRef = React.useRef<EntryActionDeps>();
49
+
50
+ depsRef.current = {
51
+ actionExecutor,
52
+ actionContext: {
53
+ screenData,
54
+ screenState,
55
+ screenRoute: pathname,
56
+ screenStateStore,
57
+ screenEntry: screenEntryContext?.data,
58
+ },
59
+ };
60
+
61
+ return React.useCallback(
62
+ (entry: ZappEntry | ZappFeed) => buildEntryActions(entry, depsRef.current),
63
+ []
64
+ );
65
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applicaster/zapp-react-native-ui-components",
3
- "version": "16.0.0-rc.70",
3
+ "version": "16.0.0-rc.72",
4
4
  "description": "Applicaster Zapp React Native ui components for the Quick Brick App",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -28,10 +28,10 @@
28
28
  },
29
29
  "homepage": "https://github.com/applicaster/quickbrick#readme",
30
30
  "dependencies": {
31
- "@applicaster/applicaster-types": "16.0.0-rc.70",
32
- "@applicaster/zapp-react-native-bridge": "16.0.0-rc.70",
33
- "@applicaster/zapp-react-native-redux": "16.0.0-rc.70",
34
- "@applicaster/zapp-react-native-utils": "16.0.0-rc.70",
31
+ "@applicaster/applicaster-types": "16.0.0-rc.72",
32
+ "@applicaster/zapp-react-native-bridge": "16.0.0-rc.72",
33
+ "@applicaster/zapp-react-native-redux": "16.0.0-rc.72",
34
+ "@applicaster/zapp-react-native-utils": "16.0.0-rc.72",
35
35
  "fast-json-stable-stringify": "^2.1.0",
36
36
  "promise": "^8.3.0",
37
37
  "react-native-sortables": "1.7.1",