@applicaster/zapp-react-native-utils 16.0.0-alpha.2434153375 → 16.0.0-alpha.2500443242

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.
Files changed (97) hide show
  1. package/actionUtils/index.ts +3 -1
  2. package/actionsExecutor/ActionExecutor.ts +10 -1
  3. package/actionsExecutor/ActionExecutorContext.tsx +29 -1
  4. package/actionsExecutor/__tests__/ActionExecutor.test.ts +71 -0
  5. package/actionsExecutor/actions/__tests__/dismissBottomSheet.test.ts +3 -0
  6. package/actionsExecutor/actions/__tests__/goBack.test.ts +62 -0
  7. package/actionsExecutor/actions/__tests__/goHome.test.ts +19 -0
  8. package/actionsExecutor/actions/__tests__/navigateToScreen.test.ts +139 -5
  9. package/actionsExecutor/actions/__tests__/openBottomSheet.customContent.test.ts +24 -0
  10. package/actionsExecutor/actions/__tests__/openBottomSheet.inlineItems.test.ts +201 -1
  11. package/actionsExecutor/actions/__tests__/presentScreen.test.ts +127 -0
  12. package/actionsExecutor/actions/__tests__/showAlert.test.ts +49 -0
  13. package/actionsExecutor/actions/__tests__/showToast.test.ts +2 -2
  14. package/actionsExecutor/actions/dismissBottomSheet.ts +2 -1
  15. package/actionsExecutor/actions/goBack.ts +60 -0
  16. package/actionsExecutor/actions/goHome.ts +31 -0
  17. package/actionsExecutor/actions/index.ts +8 -0
  18. package/actionsExecutor/actions/navigateToScreen.ts +29 -45
  19. package/actionsExecutor/actions/openBottomSheet.ts +35 -16
  20. package/actionsExecutor/actions/presentScreen.ts +78 -0
  21. package/actionsExecutor/actions/showAlert.ts +51 -0
  22. package/actionsExecutor/actions/showToast.ts +2 -2
  23. package/actionsExecutor/consts.ts +4 -0
  24. package/appUtils/playerManager/OverlayObserver/OverlaysObserver.ts +4 -11
  25. package/appUtils/playerManager/OverlayObserver/__tests__/getUpcomingQueue.test.ts +67 -0
  26. package/appUtils/playerManager/OverlayObserver/__tests__/utils.test.ts +9 -2
  27. package/appUtils/playerManager/OverlayObserver/getUpcomingQueue.ts +15 -2
  28. package/appUtils/playerManager/OverlayObserver/utils.ts +2 -5
  29. package/appUtils/playerManager/__tests__/playerNative.test.ts +76 -0
  30. package/appUtils/playerManager/__tests__/playerNativeCommand.test.ts +62 -0
  31. package/appUtils/playerManager/__tests__/playerToolbox.test.ts +97 -0
  32. package/appUtils/playerManager/__tests__/usePlayerControllerSetup.test.tsx +62 -0
  33. package/appUtils/playerManager/const.ts +13 -0
  34. package/appUtils/playerManager/index.ts +7 -0
  35. package/appUtils/playerManager/player.ts +10 -0
  36. package/appUtils/playerManager/playerNative.ts +22 -0
  37. package/appUtils/playerManager/playerNativeCommand.ts +3 -6
  38. package/appUtils/playerManager/playerToolbox.ts +157 -0
  39. package/appUtils/playerManager/usePlayerControllerSetup.tsx +6 -0
  40. package/arrayUtils/__tests__/reorderByIds.test.ts +13 -0
  41. package/arrayUtils/index.ts +26 -43
  42. package/cellUtils/__tests__/getButtonsEnabled.test.ts +28 -0
  43. package/cellUtils/__tests__/getCellNavigationEnabled.test.ts +36 -0
  44. package/cellUtils/__tests__/getIsCellNavigationEnabled.test.ts +35 -0
  45. package/cellUtils/__tests__/isCellNavigationEnabled.test.ts +21 -0
  46. package/cellUtils/index.ts +74 -19
  47. package/configurationUtils/__tests__/manifestKeyParser.test.ts +21 -0
  48. package/configurationUtils/__tests__/modalBlocksParser.test.ts +286 -1
  49. package/configurationUtils/index.ts +77 -73
  50. package/configurationUtils/manifestKeyParser.ts +12 -1
  51. package/configurationUtils/modalBlocksParser.ts +78 -4
  52. package/entryActions/__tests__/buildEntryActions.test.ts +97 -18
  53. package/entryActions/aliasPresentation.ts +26 -42
  54. package/entryActions/buildEntryActions.ts +1 -0
  55. package/entryActions/index.ts +0 -2
  56. package/focusManager/FocusManager.ts +26 -0
  57. package/focusManager/__tests__/FocusManager.test.ts +9 -0
  58. package/localizationUtils/index.ts +12 -6
  59. package/logger/__tests__/xrayToolbox.test.ts +69 -0
  60. package/logger/xrayToolbox.ts +67 -0
  61. package/manifestUtils/_internals/index.js +8 -3
  62. package/manifestUtils/defaultManifestConfigurations/generalContent.js +6 -0
  63. package/manifestUtils/defaultManifestConfigurations/player.js +8 -0
  64. package/manifestUtils/keys.js +6 -1
  65. package/manifestUtils/mobileAction/container/__tests__/mobileActionButtonsContainer.test.js +21 -0
  66. package/manifestUtils/mobileAction/groups/__tests__/buildMobileActionButtonGroups.test.js +1 -1
  67. package/manifestUtils/mobileAction/groups/defaults.js +2 -1
  68. package/modalState/ModalOrchestrator.ts +22 -9
  69. package/modalState/RemoteEditableCollection.ts +9 -1
  70. package/modalState/__tests__/ModalOrchestrator.phase3.test.ts +16 -0
  71. package/modalState/__tests__/RemoteEditableCollection.test.ts +90 -1
  72. package/modalState/__tests__/index.test.ts +30 -1
  73. package/modalState/__tests__/useModalStoreState.test.ts +142 -0
  74. package/modalState/index.ts +6 -1
  75. package/modalState/store.ts +9 -2
  76. package/modalState/types.ts +31 -2
  77. package/modalState/useBottomSheetContent.ts +24 -17
  78. package/navigationUtils/xrayNavigator.ts +12 -0
  79. package/package.json +2 -2
  80. package/perfTracker.ts +206 -0
  81. package/pluginUtils/__tests__/pluginUtils.test.js +32 -0
  82. package/pluginUtils/index.ts +29 -0
  83. package/reactHooks/__tests__/getPluginConfiguration.test.ts +82 -0
  84. package/reactHooks/cell-click/__tests__/index.test.js +64 -0
  85. package/reactHooks/cell-click/index.ts +21 -14
  86. package/reactHooks/connection/index.tsx +8 -5
  87. package/reactHooks/feed/__tests__/useMarkPipesDataStale.test.tsx +243 -0
  88. package/reactHooks/feed/useMarkPipesDataStale.ts +68 -23
  89. package/reactHooks/navigation/__tests__/index.test.tsx +176 -1
  90. package/reactHooks/navigation/__tests__/usePresentScreen.test.ts +154 -0
  91. package/reactHooks/navigation/index.ts +2 -0
  92. package/reactHooks/navigation/usePresentScreen.ts +157 -0
  93. package/reactHooks/navigation/useRoute.ts +22 -5
  94. package/reactHooks/usePluginConfiguration.ts +84 -46
  95. package/riverComponetsMeasurementProvider/index.tsx +8 -1
  96. package/uiActionsRegistrator/__tests__/subscribe.test.ts +26 -0
  97. package/uiActionsRegistrator/registry.ts +16 -0
@@ -60,7 +60,7 @@ describe("buildEntryActions - alias & label resolution", () => {
60
60
  expect(state.label).toBe("Label Property Action");
61
61
  });
62
62
 
63
- it("resolves entry action with known backend button alias (e.g. add_all_to_queue)", () => {
63
+ it("humanizes an un-overridden alias and falls back to the placeholder asset", () => {
64
64
  const entry = {
65
65
  id: "entry-3",
66
66
  extensions: {
@@ -71,11 +71,7 @@ describe("buildEntryActions - alias & label resolution", () => {
71
71
  dismiss_on_action: true,
72
72
  },
73
73
  {
74
- button: { alias: "add_to_playlist" },
75
- actions: [{ type: "test" }],
76
- },
77
- {
78
- button: { alias: "delete_collection" },
74
+ button: { alias: "custom_unknown_action" },
79
75
  actions: [{ type: "test" }],
80
76
  },
81
77
  ],
@@ -84,42 +80,86 @@ describe("buildEntryActions - alias & label resolution", () => {
84
80
 
85
81
  const actions = resolveEntryActions(entry as any);
86
82
 
87
- expect(actions).toHaveLength(3);
83
+ expect(actions).toHaveLength(2);
88
84
 
85
+ // No built-in alias vocabulary any more: underscores become spaces and
86
+ // only the first letter is capitalized, so "queue" stays lowercase.
89
87
  expect(actions[0].action.initialEntryState(entry as any)).toMatchObject({
90
88
  state: 0,
91
- label: "Add all to Queue",
89
+ label: "Add all to queue",
92
90
  });
93
91
 
94
92
  expect(actions[1].action.initialEntryState(entry as any)).toMatchObject({
95
93
  state: 0,
96
- label: "Add to Playlist",
94
+ label: "Custom unknown action",
97
95
  });
98
96
 
99
- expect(actions[2].action.initialEntryState(entry as any)).toMatchObject({
100
- state: 0,
101
- label: "Delete collection",
102
- });
97
+ // The asset has no such fallback: without an override it is the red-box
98
+ // placeholder, so a missing Studio icon is visible rather than absent.
99
+ for (const action of actions) {
100
+ expect(action.action.initialEntryState(entry as any)).toMatchObject({
101
+ asset: expect.stringMatching(/^data:image\/png;base64,/),
102
+ });
103
+ }
103
104
  });
104
105
 
105
- it("formats unknown alias nicely as fallback", () => {
106
+ it("prefers aliasPresentation overrides over the humanized fallbacks", () => {
107
+ const deps = {
108
+ actionExecutor: { handleActions: jest.fn() } as any,
109
+ actionContext: {},
110
+ aliasPresentation: {
111
+ labels: { add_all_to_queue: "Add all to Queue" },
112
+ assets: { add_all_to_queue: "http://example.com/queue.png" },
113
+ },
114
+ };
115
+
106
116
  const entry = {
107
117
  id: "entry-4",
108
118
  extensions: {
109
119
  entry_action: [
110
120
  {
111
- button: { alias: "custom_unknown_action" },
121
+ button: { alias: "add_all_to_queue" },
122
+ actions: [{ type: "test" }],
123
+ },
124
+ {
125
+ button: { alias: "not_overridden" },
112
126
  actions: [{ type: "test" }],
113
127
  },
114
128
  ],
115
129
  },
116
130
  };
117
131
 
118
- const actions = resolveEntryActions(entry as any);
132
+ const actions = buildEntryActions(entry as any, deps);
119
133
 
120
- expect(actions).toHaveLength(1);
134
+ expect(actions[0].action.initialEntryState(entry as any)).toMatchObject({
135
+ label: "Add all to Queue",
136
+ asset: "http://example.com/queue.png",
137
+ });
138
+
139
+ // Overrides are per-alias, so an alias absent from the map still humanizes.
140
+ expect(actions[1].action.initialEntryState(entry as any)).toMatchObject({
141
+ label: "Not overridden",
142
+ });
143
+ });
144
+
145
+ it("returns no asset when the button has neither iconURL nor alias", () => {
146
+ const entry = {
147
+ id: "entry-5",
148
+ extensions: {
149
+ entry_action: [
150
+ {
151
+ button: { label: "Plain Label" },
152
+ actions: [{ type: "test" }],
153
+ },
154
+ ],
155
+ },
156
+ };
157
+
158
+ const actions = resolveEntryActions(entry as any);
121
159
  const state = actions[0].action.initialEntryState(entry as any);
122
- expect(state.label).toBe("Custom unknown action");
160
+
161
+ expect(state.label).toBe("Plain Label");
162
+ expect(state.asset).toBeUndefined();
123
163
  });
124
164
 
125
165
  it("uses actionExecutor.getActionState for plugin-provided label and asset", () => {
@@ -154,3 +194,42 @@ describe("buildEntryActions - alias & label resolution", () => {
154
194
  });
155
195
  });
156
196
  });
197
+
198
+ describe("buildEntryActions - invokeAction execution context", () => {
199
+ it("forwards the List component from invoke options into handleActions", async () => {
200
+ const handleActions = jest.fn();
201
+
202
+ const component = {
203
+ id: "list-1",
204
+ data: { source: "https://example.com/user/collections/abc" },
205
+ };
206
+
207
+ const deps = {
208
+ actionExecutor: { handleActions } as any,
209
+ actionContext: { screenData: { id: "playlist-screen" } },
210
+ };
211
+
212
+ const entry = {
213
+ id: "entry-1",
214
+ extensions: {
215
+ entry_action: [
216
+ {
217
+ button: { title: "Edit Name" },
218
+ actions: [{ type: "refreshComponent" }],
219
+ },
220
+ ],
221
+ },
222
+ };
223
+
224
+ const [action] = buildEntryActions(entry as any, deps);
225
+
226
+ await action.action.invokeAction(entry as any, { component });
227
+
228
+ expect(handleActions).toHaveBeenCalledWith([{ type: "refreshComponent" }], {
229
+ entry,
230
+ entryContext: entry,
231
+ screenData: { id: "playlist-screen" },
232
+ component,
233
+ });
234
+ });
235
+ });
@@ -1,52 +1,29 @@
1
1
  /**
2
- * Presentation defaults for backend action aliases.
2
+ * Presentation fallbacks for backend action aliases.
3
3
  *
4
4
  * Entries may declare an action by `alias` alone, without a label or an icon.
5
- * These maps are the last-resort labels/icons for the aliases we know about; an
6
- * unknown alias falls back to a humanized version of the alias itself.
5
+ * There is no built-in alias vocabulary: a label falls back to a humanized
6
+ * version of the alias itself, and an icon falls back to an inline placeholder
7
+ * that is deliberately conspicuous, so a missing asset reads as unconfigured
8
+ * rather than as a deliberate blank.
7
9
  *
8
- * They are defaults, not a framework guarantee: an app that speaks a different
9
- * alias vocabulary, needs other languages, or does not want to depend on these
10
- * icon hosts passes its own through `EntryActionDeps.aliasPresentation`.
10
+ * Both are last resorts, not a framework guarantee: an app that speaks its own
11
+ * alias vocabulary or needs other languages passes labels and assets through
12
+ * `EntryActionDeps.aliasPresentation`, which is consulted per alias.
11
13
  */
12
14
 
13
15
  import { EntryAction } from "./types";
14
16
 
15
- const ADD_TO_PLAYLIST_ICON =
16
- "https://assets-secure.applicaster.com/zapp/assets/app_family/1710/906728572617/playlist_add_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.png";
17
-
18
- const REMOVE_ICON =
19
- "https://assets-secure.applicaster.com/zapp/assets/app_family/1710/372097240191/remove_24dp_EA3323_FILL0_wght400_GRAD0_opsz24.png";
20
-
21
- const EDIT_ICON =
22
- "https://upload.wikimedia.org/wikipedia/commons/b/b6/Edit-icon.png";
23
-
24
- /** Known backend action alias labels. */
25
- export const ALIAS_LABELS: Record<string, string> = {
26
- add_to_playlist: "Add to Playlist",
27
- add_to_queue: "Add to Queue",
28
- add_all_to_queue: "Add all to Queue",
29
- play_all: "Play All",
30
- remove_item: "Remove item",
31
- delete_collection: "Delete collection",
32
- create_collection: "Create collection",
33
- edit: "Edit",
34
- edit_name: "Edit Name & Details",
35
- };
17
+ import { createLogger } from "@applicaster/zapp-react-native-utils/logger";
36
18
 
37
- /** Known backend action alias image assets. */
38
- export const ALIAS_ASSETS: Record<string, string> = {
39
- add_to_playlist: ADD_TO_PLAYLIST_ICON,
40
- add_to_queue: ADD_TO_PLAYLIST_ICON,
41
- add_all_to_queue: ADD_TO_PLAYLIST_ICON,
42
- play_all: ADD_TO_PLAYLIST_ICON,
43
- remove_item: REMOVE_ICON,
44
- delete_collection: REMOVE_ICON,
45
- edit: EDIT_ICON,
46
- edit_name: EDIT_ICON,
47
- };
19
+ const logger = createLogger({
20
+ category: "zapp-react-native-utils:aliasPresentation",
21
+ });
22
+
23
+ const PLACEHOLDER_RED_BOX =
24
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAJ0lEQVR4nO3NMQEAAAjDsIF/z2ACvlRAU5NMHuvPOQAAAAAAAMBdC/07Ai5sNw+CAAAAAElFTkSuQmCC";
48
25
 
49
- /** Per-app overrides for the maps above. */
26
+ /** Per-app labels and icons, keyed by alias, that outrank the fallbacks. */
50
27
  export type AliasPresentation = {
51
28
  labels?: Record<string, string>;
52
29
  assets?: Record<string, string>;
@@ -56,7 +33,7 @@ function formatAliasLabel(
56
33
  alias: string,
57
34
  overrides?: AliasPresentation
58
35
  ): string {
59
- const label = overrides?.labels?.[alias] ?? ALIAS_LABELS[alias];
36
+ const label = overrides?.labels?.[alias];
60
37
 
61
38
  if (label) {
62
39
  return label;
@@ -107,7 +84,7 @@ export function resolveButtonLabel(
107
84
  return "";
108
85
  }
109
86
 
110
- /** Icon to show for an entry action: explicit iconURL, else the alias default. */
87
+ /** Icon to show for an entry action: explicit iconURL, matching alias asset or fallback to red box. */
111
88
  export function resolveButtonAsset(
112
89
  button: EntryAction["button"],
113
90
  overrides?: AliasPresentation
@@ -120,5 +97,12 @@ export function resolveButtonAsset(
120
97
  return undefined;
121
98
  }
122
99
 
123
- return overrides?.assets?.[button.alias] ?? ALIAS_ASSETS[button.alias];
100
+ if (!overrides?.assets?.[button.alias]) {
101
+ logger.log_warning(
102
+ `Entry action alias '${button.alias}' was not resolved by any plugin feedDecorator or app override. Falling back to placeholder red box.`,
103
+ { data: { button } }
104
+ );
105
+ }
106
+
107
+ return overrides?.assets?.[button.alias] ?? PLACEHOLDER_RED_BOX;
124
108
  }
@@ -105,6 +105,7 @@ function createEntryAction(
105
105
  entry: invokedEntry,
106
106
  entryContext: invokedEntry,
107
107
  ...deps.actionContext,
108
+ ...(options.component ? { component: options.component } : {}),
108
109
  } as ActionExecutionContext);
109
110
  } catch (error) {
110
111
  log_error(
@@ -1,8 +1,6 @@
1
1
  export { buildEntryActions, isEntryAction } from "./buildEntryActions";
2
2
 
3
3
  export {
4
- ALIAS_ASSETS,
5
- ALIAS_LABELS,
6
4
  resolveButtonAsset,
7
5
  resolveButtonLabel,
8
6
  resolveExplicitLabel,
@@ -224,12 +224,38 @@ class FocusManager {
224
224
 
225
225
  unregisterFocusable(focusableId: string) {
226
226
  const node = this.tree.find(focusableId);
227
+ const parentNode = this.tree.findParent(focusableId);
227
228
 
228
229
  this.focusableComponents = this.focusableComponents.filter(
229
230
  (c) => c !== node?.component
230
231
  );
231
232
 
232
233
  this.tree.remove(focusableId);
234
+
235
+ if (this._focusedId === focusableId) {
236
+ this._focusedId = null;
237
+ this.retainFocus(parentNode);
238
+ }
239
+ }
240
+
241
+ // Empty groups drop their focusable list after the feed fails, which is
242
+ // after Layout's one-shot focus pass. Land on the nearest ancestor that is
243
+ // still mounted so key handlers keep a target.
244
+ private retainFocus(node: TreeNode | null) {
245
+ let current = node;
246
+
247
+ while (current) {
248
+ const ref = current.component;
249
+
250
+ if (ref?.current?.props?.id) {
251
+ this.setFocusLocal(ref);
252
+ ref.current.onFocus?.(ref.current, {}, undefined);
253
+
254
+ return;
255
+ }
256
+
257
+ current = current.parentId ? this.tree.find(current.parentId) : null;
258
+ }
233
259
  }
234
260
 
235
261
  private setNextFocus(
@@ -231,6 +231,15 @@ describe("FocusManager", () => {
231
231
  ).toBeFalsy();
232
232
  });
233
233
 
234
+ it("moves focus to the mounted parent when the focused element unregisters", () => {
235
+ focusManager.setFocus("child1");
236
+
237
+ focusManager.unregisterFocusable("child1");
238
+
239
+ expect(focusManager.focusedId).toBe("group");
240
+ expect(focusManager.focused).toBe(group.current);
241
+ });
242
+
234
243
  it("focusManager registerFocusable should be defined", () => {
235
244
  expect(focusManager.registerFocusable).toBeDefined();
236
245
  });
@@ -28,17 +28,23 @@ const RTL_LOCALES = [
28
28
  "yi", // Yiddish
29
29
  ];
30
30
 
31
+ // The strings for the UI language, else the first language present
32
+ export const getLocalizedStrings = (
33
+ localizations = {},
34
+ languageCode = getUILanguage()
35
+ ) =>
36
+ localizations?.[languageCode] ||
37
+ Object.values(localizations).find(
38
+ (value) => typeof value === "object" && value !== null
39
+ ) ||
40
+ {};
41
+
31
42
  export function useLocalizedStrings({ localizations = {} }) {
32
43
  const languageCode = getUILanguage();
33
44
 
34
45
  // TODO: probably we can call getLocalizations here from localizationsHelper.js
35
46
  const result = useMemo(
36
- () =>
37
- localizations?.[languageCode] ||
38
- Object.values(localizations).find(
39
- (value) => typeof value === "object" && value !== null
40
- ) ||
41
- {},
47
+ () => getLocalizedStrings(localizations, languageCode),
42
48
  [languageCode, localizations]
43
49
  );
44
50
 
@@ -0,0 +1,69 @@
1
+ const xray = require("@applicaster/quick-brick-xray");
2
+
3
+ const toolbox = { name: "test", description: "", tools: [] };
4
+
5
+ const load = (): typeof import("../xrayToolbox") => {
6
+ let module;
7
+
8
+ jest.isolateModules(() => {
9
+ module = require("../xrayToolbox");
10
+ });
11
+
12
+ return module;
13
+ };
14
+
15
+ describe("registerXrayToolbox", () => {
16
+ afterEach(() => {
17
+ delete xray.registerReactToolbox;
18
+ });
19
+
20
+ it("registers once per name", async () => {
21
+ xray.registerReactToolbox = jest.fn().mockResolvedValue(undefined);
22
+ const { registerXrayToolbox } = load();
23
+
24
+ await expect(registerXrayToolbox(toolbox)).resolves.toBe(true);
25
+ await expect(registerXrayToolbox(toolbox)).resolves.toBe(true);
26
+
27
+ expect(xray.registerReactToolbox).toHaveBeenCalledTimes(1);
28
+ });
29
+
30
+ it("resolves false without a toolbox API", async () => {
31
+ await expect(load().registerXrayToolbox(toolbox)).resolves.toBe(false);
32
+ });
33
+
34
+ it("keeps a failed registration instead of retrying it", async () => {
35
+ xray.registerReactToolbox = jest
36
+ .fn()
37
+ .mockRejectedValue(new Error("XRayToolboxBridge is not available"));
38
+
39
+ const { registerXrayToolbox } = load();
40
+
41
+ await expect(registerXrayToolbox(toolbox)).resolves.toBe(false);
42
+ await expect(registerXrayToolbox(toolbox)).resolves.toBe(false);
43
+
44
+ expect(xray.registerReactToolbox).toHaveBeenCalledTimes(1);
45
+ });
46
+
47
+ it("registers new tools under the same name again (Fast Refresh)", async () => {
48
+ xray.registerReactToolbox = jest.fn().mockResolvedValue(undefined);
49
+ const { registerXrayToolbox } = load();
50
+
51
+ await registerXrayToolbox(toolbox);
52
+ await registerXrayToolbox({ ...toolbox, tools: [] });
53
+
54
+ expect(xray.registerReactToolbox).toHaveBeenCalledTimes(2);
55
+ });
56
+
57
+ it("stops trying every toolbox once the bridge is missing", async () => {
58
+ xray.registerReactToolbox = jest.fn().mockRejectedValue(new Error("no"));
59
+ const { registerXrayToolbox } = load();
60
+
61
+ await registerXrayToolbox(toolbox);
62
+
63
+ await expect(
64
+ registerXrayToolbox({ ...toolbox, name: "other" })
65
+ ).resolves.toBe(false);
66
+
67
+ expect(xray.registerReactToolbox).toHaveBeenCalledTimes(1);
68
+ });
69
+ });
@@ -0,0 +1,67 @@
1
+ import { getXray } from ".";
2
+
3
+ /** One tool of an X-Ray React toolbox: an agent calls it by name with JSON arguments. */
4
+ export type XrayToolSpec = {
5
+ name: string;
6
+ description: string;
7
+ parameters?: Array<{
8
+ name: string;
9
+ type: "number" | "string";
10
+ description: string;
11
+ }>;
12
+ handler: (args: Record<string, unknown>) => unknown;
13
+ };
14
+
15
+ export type XrayToolbox = {
16
+ name: string;
17
+ description: string;
18
+ tools: XrayToolSpec[];
19
+ };
20
+
21
+ type RegisterReactToolbox = (toolbox: XrayToolbox) => Promise<void>;
22
+
23
+ const registered = new Map<
24
+ string,
25
+ { tools: XrayToolSpec[]; registration: Promise<boolean> }
26
+ >();
27
+
28
+ /** Set once a registration fails: the build has no X-Ray bridge, which cannot appear later. */
29
+ let bridgeMissing = false;
30
+
31
+ /**
32
+ * Registers an X-Ray React toolbox and resolves whether it is registered. The same tools are
33
+ * registered once; new tools under the same name (Fast Refresh re-ran the module) replace them.
34
+ * A build without the X-Ray native bridge (release builds) rejects the first attempt and every
35
+ * later call resolves false at once, so callers on hot paths pay for one attempt only.
36
+ */
37
+ export const registerXrayToolbox = (toolbox: XrayToolbox): Promise<boolean> => {
38
+ const cached = registered.get(toolbox.name);
39
+
40
+ if (cached?.tools === toolbox.tools) return cached.registration;
41
+
42
+ if (bridgeMissing) return Promise.resolve(false);
43
+
44
+ const register = (
45
+ getXray() as
46
+ | { registerReactToolbox?: RegisterReactToolbox }
47
+ | null
48
+ | undefined
49
+ )?.registerReactToolbox;
50
+
51
+ const registration = register
52
+ ? register(toolbox).then(
53
+ () => true,
54
+ () => {
55
+ bridgeMissing = true;
56
+
57
+ return false;
58
+ }
59
+ )
60
+ : Promise.resolve(false);
61
+
62
+ if (!register) bridgeMissing = true;
63
+
64
+ registered.set(toolbox.name, { tools: toolbox.tools, registration });
65
+
66
+ return registration;
67
+ };
@@ -135,7 +135,8 @@ function generateFieldsFromDefaults(label, defaults, fields) {
135
135
  function generateFieldsFromDefaultsWithoutPrefixedLabel(
136
136
  label,
137
137
  defaults,
138
- fields
138
+ fields,
139
+ { withStyle = false } = {}
139
140
  ) {
140
141
  const keyPrefix = toSnakeCase(label);
141
142
 
@@ -151,7 +152,9 @@ function generateFieldsFromDefaultsWithoutPrefixedLabel(
151
152
  const generatedField = {
152
153
  ...fieldProps,
153
154
  label: capitalizeFirstLetter(field.suffix),
154
- key: `${keyPrefix}_${toSnakeCase(field.suffix)}`,
155
+ key: withStyle
156
+ ? `${keyPrefix}_style_${toSnakeCase(field.suffix)}`
157
+ : `${keyPrefix}_${toSnakeCase(field.suffix)}`,
155
158
  initial_value: R.when(R.is(Array), R.head)(initialValue),
156
159
  };
157
160
 
@@ -164,7 +167,9 @@ function generateFieldsFromDefaultsWithoutPrefixedLabel(
164
167
  const section = condition.section ? `${condition.section}/` : "";
165
168
 
166
169
  return {
167
- key: `${section}${keyPrefix}_${toSnakeCase(condition.key)}`,
170
+ key: withStyle
171
+ ? `${section}${keyPrefix}_style_${toSnakeCase(condition.key)}`
172
+ : `${section}${keyPrefix}_${toSnakeCase(condition.key)}`,
168
173
  condition_value: condition.value,
169
174
  };
170
175
  });
@@ -12,6 +12,12 @@ const generalContent = () => ({
12
12
  label: "Navigation Bar",
13
13
  placeholder: "Choose Nav Bar",
14
14
  },
15
+ {
16
+ key: "hide_menu",
17
+ type: "switch",
18
+ label: "Hide Menu",
19
+ initial_value: false,
20
+ },
15
21
  ],
16
22
  },
17
23
  styles: {
@@ -830,6 +830,14 @@ function getPlayerConfiguration({ platform, version }) {
830
830
  type: "switch",
831
831
  label_tooltip:
832
832
  "Enable this option to have a floating video player when the app is in the background.",
833
+ },
834
+ {
835
+ key: "vertical_video_player",
836
+ label: "Vertical video mode",
837
+ initial_value: false,
838
+ type: "switch",
839
+ label_tooltip:
840
+ "Enable this option to use the vertical video player layout.",
833
841
  }
834
842
  );
835
843
 
@@ -412,6 +412,12 @@ const mobileActionButtonContainerFields = (positionOptions) => [
412
412
  type: ZAPPIFEST_FIELDS.switch,
413
413
  suffix: "buttons enabled",
414
414
  },
415
+ {
416
+ type: ZAPPIFEST_FIELDS.switch,
417
+ suffix: "cell navigation enabled",
418
+ label_tooltip:
419
+ "Controls whether tapping on the cell (outside the buttons) triggers its navigation behavior. When disabled, only the configured buttons are interactive. Useful for Home screens (tap anywhere to open) vs. Details screens (only buttons should work).",
420
+ },
415
421
  {
416
422
  type: ZAPPIFEST_FIELDS.select,
417
423
  suffix: "position",
@@ -472,7 +478,6 @@ const MOBILE_ACTION_BUTTON_FIELDS = [
472
478
  suffix: "assign action",
473
479
  options: [
474
480
  { text: "primary navigation", value: "navigation_action" },
475
- { text: "secondary navigation", value: "secondary_navigation" },
476
481
  { text: "favorite", value: "local_storage_favourites_action" },
477
482
  { text: "more", value: "open-modal-bottom-sheet-cell-action" },
478
483
  { text: "add to calendar", value: "add_to_calendar_action" },
@@ -7,6 +7,7 @@ describe("mobileActionButtonsContainer", () => {
7
7
  description: "container",
8
8
  defaults: {
9
9
  buttonsEnabled: false,
10
+ cellNavigationEnabled: false,
10
11
  position: ["over_image", "text_label_1", "text_label_2"],
11
12
  align: "left",
12
13
  overImagePosition: "bottom_left",
@@ -31,6 +32,26 @@ describe("mobileActionButtonsContainer", () => {
31
32
  expect(enabledField).toBeTruthy();
32
33
  expect(enabledField.type).toBe("switch");
33
34
 
35
+ const cellNavigationField = result.fields.find(
36
+ (field) =>
37
+ field.key === "mobile_buttons_container_cell_navigation_enabled"
38
+ );
39
+
40
+ expect(cellNavigationField).toMatchObject({
41
+ type: "switch",
42
+ label: "Cell navigation enabled",
43
+ initial_value: false,
44
+ label_tooltip: expect.stringContaining(
45
+ "Controls whether tapping on the cell"
46
+ ),
47
+ conditional_fields: [
48
+ {
49
+ key: "styles/mobile_buttons_container_buttons_enabled",
50
+ condition_value: true,
51
+ },
52
+ ],
53
+ });
54
+
34
55
  const positionField = result.fields.find(
35
56
  (field) => field.key === "mobile_buttons_container_position"
36
57
  );
@@ -67,7 +67,7 @@ describe("buildMobileActionButtonGroups", () => {
67
67
  );
68
68
 
69
69
  expect(button2EnabledField.initial_value).toBe(false);
70
- expect(button2AssignActionField.initial_value).toBe("secondary_navigation");
70
+ expect(button2AssignActionField.initial_value).toBe("trailer_action");
71
71
 
72
72
  const button3Fields = groups[3].fields;
73
73
 
@@ -1,5 +1,6 @@
1
1
  const DEFAULT_MOBILE_ACTION_BUTTONS_CONTAINER_DEFAULTS = {
2
2
  buttonsEnabled: false,
3
+ cellNavigationEnabled: false,
3
4
 
4
5
  align: "left",
5
6
  overImagePosition: "bottom_left",
@@ -63,7 +64,7 @@ const DEFAULT_MOBILE_ACTION_BUTTON_PRESETS = {
63
64
  },
64
65
  2: {
65
66
  buttonEnabled: false,
66
- assignAction: "secondary_navigation",
67
+ assignAction: "trailer_action",
67
68
  },
68
69
  3: {
69
70
  buttonEnabled: false,