@applicaster/zapp-react-native-ui-components 16.0.0-rc.113 → 16.0.0-rc.114

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.
@@ -7,7 +7,7 @@ import { Artwork } from "./Artwork";
7
7
  import { directionStyles } from "./helpers";
8
8
 
9
9
  type Props = {
10
- artwork: string;
10
+ artwork: Option<string>;
11
11
  config: {
12
12
  titleColor: string;
13
13
  summaryColor: string;
@@ -152,7 +152,7 @@ export function AudioPlayerTVLayout({
152
152
  resizeMode="cover"
153
153
  >
154
154
  <View style={[mainContainerStyles, backgroundOverlayStyles]}>
155
- <Artwork srcImage={artwork} config={config} />
155
+ {artwork ? <Artwork srcImage={artwork} config={config} /> : null}
156
156
  <View style={textContainerStyles}>{children}</View>
157
157
  </View>
158
158
  </ImageBackground>
@@ -161,7 +161,7 @@ export function AudioPlayerTVLayout({
161
161
 
162
162
  return (
163
163
  <View style={mainContainerStyles}>
164
- <Artwork srcImage={artwork} config={config} />
164
+ {artwork ? <Artwork srcImage={artwork} config={config} /> : null}
165
165
  <View style={textContainerStyles}>{children}</View>
166
166
  </View>
167
167
  );
@@ -287,6 +287,46 @@ describe("mergeButtonConfiguration background", () => {
287
287
  expect(merged.title.androidLetterSpacing).toBe(-0.5);
288
288
  expect(merged.title.textTransform).toBe("uppercase");
289
289
  });
290
+
291
+ it("uses the family string inside a font-selector object and ignores {}", () => {
292
+ const fromObject = mergeButtonConfiguration(buttonBase as any, {
293
+ title: {
294
+ default: { fontFamily: { fontFamily: "GreycliffCF-Bold" } },
295
+ },
296
+ });
297
+
298
+ expect(fromObject.title.iosFontFamily).toBe("GreycliffCF-Bold");
299
+ expect(fromObject.title.androidFontFamily).toBe("GreycliffCF-Bold");
300
+
301
+ const fromEmpty = mergeButtonConfiguration(buttonBase as any, {
302
+ title: {
303
+ default: { fontFamily: {} },
304
+ },
305
+ });
306
+
307
+ expect(fromEmpty.title.iosFontFamily).toBe(buttonBase.title.iosFontFamily);
308
+
309
+ expect(fromEmpty.title.androidFontFamily).toBe(
310
+ buttonBase.title.androidFontFamily
311
+ );
312
+ });
313
+
314
+ it("maps inactive color, gutter, and focused and inactive font colors", () => {
315
+ const merged = mergeButtonConfiguration(buttonBase as any, {
316
+ background: {
317
+ default: { inactiveColor: "#999999", horizontalGutter: 6 },
318
+ },
319
+ title: {
320
+ default: { inactiveFontColor: "#111111" },
321
+ focused: { fontColor: "#222222" },
322
+ },
323
+ });
324
+
325
+ expect(merged.background.inactiveColor).toBe("#999999");
326
+ expect(merged.background.horizontalGutter).toBe(6);
327
+ expect(merged.title.inactiveFontColor).toBe("#111111");
328
+ expect(merged.title.focusedFontColor).toBe("#222222");
329
+ });
290
330
  });
291
331
 
292
332
  describe("mergeHeaderConfiguration section header (Variant 3)", () => {
@@ -381,6 +381,22 @@ export function mergeHeaderConfiguration(
381
381
  // Button Merging Helpers
382
382
  // ---------------------------------------------------------------------------
383
383
 
384
+ function fontFamilyName(value: unknown): string | undefined {
385
+ if (typeof value === "string" && value.trim()) {
386
+ return value;
387
+ }
388
+
389
+ if (value && typeof value === "object") {
390
+ const family = (value as { fontFamily?: unknown }).fontFamily;
391
+
392
+ if (typeof family === "string" && family.trim()) {
393
+ return family;
394
+ }
395
+ }
396
+
397
+ return undefined;
398
+ }
399
+
384
400
  function mergeButtonBackground(
385
401
  base: ButtonBackgroundConfiguration,
386
402
  defaultOverride?: Record<string, any>,
@@ -399,6 +415,9 @@ function mergeButtonBackground(
399
415
  ...(defaultOverride?.backgroundColor
400
416
  ? { defaultColor: defaultOverride.backgroundColor }
401
417
  : {}),
418
+ ...(defaultOverride?.inactiveColor
419
+ ? { inactiveColor: defaultOverride.inactiveColor }
420
+ : {}),
402
421
  ...(focusedColor != null && focusedColor !== "" ? { focusedColor } : {}),
403
422
  ...(defaultOverride?.borderRadius !== undefined
404
423
  ? { cornerRadius: defaultOverride.borderRadius }
@@ -415,47 +434,62 @@ function mergeButtonBackground(
415
434
  ...(defaultOverride?.paddingLeft !== undefined
416
435
  ? { paddingLeft: defaultOverride.paddingLeft }
417
436
  : {}),
437
+ ...(defaultOverride?.horizontalGutter !== undefined
438
+ ? { horizontalGutter: defaultOverride.horizontalGutter }
439
+ : {}),
418
440
  };
419
441
  }
420
442
 
421
443
  function mergeButtonTitle(
422
444
  base: ButtonTitleConfiguration,
423
- override?: Record<string, any>
445
+ override?: Record<string, any>,
446
+ focusedOverride?: Record<string, any>
424
447
  ): ButtonTitleConfiguration {
425
- if (!override) return base;
448
+ if (!override && !focusedOverride) return base;
449
+
450
+ const fontFamily = fontFamilyName(override?.fontFamily);
451
+
452
+ const focusedFontColor =
453
+ focusedOverride?.fontColor || override?.focusedFontColor;
454
+
455
+ const inactiveFontColor = override?.inactiveFontColor;
426
456
 
427
457
  return {
428
458
  ...base,
429
- ...(override.fontColor ? { fontColor: override.fontColor } : {}),
430
- ...(override.fontSize !== undefined ? { fontSize: override.fontSize } : {}),
431
- ...(override.lineHeight !== undefined
459
+ ...(override?.fontColor ? { fontColor: override.fontColor } : {}),
460
+ ...(focusedFontColor ? { focusedFontColor } : {}),
461
+ ...(inactiveFontColor ? { inactiveFontColor } : {}),
462
+ ...(override?.fontSize !== undefined
463
+ ? { fontSize: override.fontSize }
464
+ : {}),
465
+ ...(override?.lineHeight !== undefined
432
466
  ? { lineHeight: override.lineHeight }
433
467
  : {}),
434
- ...(override.fontFamily
468
+ ...(fontFamily
435
469
  ? {
436
- iosFontFamily: override.fontFamily,
437
- androidFontFamily: override.fontFamily,
470
+ iosFontFamily: fontFamily,
471
+ androidFontFamily: fontFamily,
438
472
  }
439
473
  : {}),
440
- ...(override.iosFontFamily
474
+ ...(override?.iosFontFamily
441
475
  ? { iosFontFamily: override.iosFontFamily }
442
476
  : {}),
443
- ...(override.androidFontFamily
477
+ ...(override?.androidFontFamily
444
478
  ? { androidFontFamily: override.androidFontFamily }
445
479
  : {}),
446
- ...(override.letterSpacing !== undefined
480
+ ...(override?.letterSpacing !== undefined
447
481
  ? {
448
482
  iosLetterSpacing: override.letterSpacing,
449
483
  androidLetterSpacing: override.letterSpacing,
450
484
  }
451
485
  : {}),
452
- ...(override.iosLetterSpacing !== undefined
486
+ ...(override?.iosLetterSpacing !== undefined
453
487
  ? { iosLetterSpacing: override.iosLetterSpacing }
454
488
  : {}),
455
- ...(override.androidLetterSpacing !== undefined
489
+ ...(override?.androidLetterSpacing !== undefined
456
490
  ? { androidLetterSpacing: override.androidLetterSpacing }
457
491
  : {}),
458
- ...(override.textTransform
492
+ ...(override?.textTransform
459
493
  ? { textTransform: override.textTransform }
460
494
  : {}),
461
495
  };
@@ -511,13 +545,19 @@ export function mergeButtonConfiguration(
511
545
  const titleOverride =
512
546
  buttonBlock?.title?.default || actionBlock?.title?.default;
513
547
 
548
+ const titleFocused =
549
+ buttonBlock?.title?.focused ||
550
+ buttonBlock?.title?.focus ||
551
+ actionBlock?.title?.focused ||
552
+ actionBlock?.title?.focus;
553
+
514
554
  const iconsOverride = buttonBlock?.icons || actionBlock?.icons;
515
555
  const leadingOverride = buttonBlock?.leadingIcon || actionBlock?.leadingIcon;
516
556
 
517
557
  return {
518
558
  ...base,
519
559
  background: mergeButtonBackground(base.background, bgDefault, bgFocused),
520
- title: mergeButtonTitle(base.title, titleOverride),
560
+ title: mergeButtonTitle(base.title, titleOverride, titleFocused),
521
561
  icons: mergeButtonIcons(base.icons, iconsOverride, leadingOverride),
522
562
  };
523
563
  }
@@ -90,6 +90,7 @@ type ModalComponentProps = {
90
90
  /** Optional body that replaces the items list (header stays DefaultModalHeader). */
91
91
  contentComponent?: React.ComponentType<any>;
92
92
  contentComponentProps?: Record<string, any>;
93
+ themePluginId?: string;
93
94
  styleOverrides?: ModalBlocksConfig | Record<string, any>;
94
95
  measurementPhase?: boolean;
95
96
  /** Copy under Next Up when the queue has no upcoming items. */
@@ -115,6 +116,7 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
115
116
  iconPlacement,
116
117
  contentComponent: ContentComponent,
117
118
  contentComponentProps = {},
119
+ themePluginId: themePluginIdProp,
118
120
  measurementPhase = false,
119
121
  emptyMessage,
120
122
  } = props;
@@ -139,12 +141,14 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
139
141
  role,
140
142
  behavior,
141
143
  editableCollection,
142
- themePluginId,
144
+ themePluginId: themePluginIdFromSheet,
143
145
  } = useBottomSheetContent({
144
146
  onPress,
145
147
  viewModel,
146
148
  });
147
149
 
150
+ const themePluginId = themePluginIdFromSheet || themePluginIdProp;
151
+
148
152
  const plugins = usePlugins();
149
153
 
150
154
  const plugin = useMemo(
@@ -92,4 +92,29 @@ describe("showTextInputAction", () => {
92
92
  expect(result).toBe(ActionResult.Error);
93
93
  expect(actionExecutor.handleAction).not.toHaveBeenCalled();
94
94
  });
95
+
96
+ it("forwards themePluginId so the sheet can read plugin button styles", async () => {
97
+ const action = {
98
+ type: "showTextInput",
99
+ options: {
100
+ headerTitle: "Create New Playlist",
101
+ inputLabel: "Name your playlist",
102
+ buttonLabel: "Create",
103
+ themePluginId: "playlist-experience",
104
+ actions: [{ type: "sendCloudEvent", options: {} }],
105
+ },
106
+ };
107
+
108
+ await showTextInputAction(action as any);
109
+
110
+ expect(actionExecutor.handleAction).toHaveBeenCalledWith(
111
+ expect.objectContaining({
112
+ type: "openBottomSheet",
113
+ options: expect.objectContaining({
114
+ themePluginId: "playlist-experience",
115
+ }),
116
+ }),
117
+ undefined
118
+ );
119
+ });
95
120
  });
@@ -21,6 +21,7 @@ export async function showTextInputAction(
21
21
  validationLabel,
22
22
  actions,
23
23
  maxLength,
24
+ themePluginId,
24
25
  } = action.options || {};
25
26
 
26
27
  if (!headerTitle || !buttonLabel || !actions || !actions.length) {
@@ -34,6 +35,7 @@ export async function showTextInputAction(
34
35
  header: {
35
36
  title: headerTitle,
36
37
  },
38
+ ...(themePluginId ? { themePluginId } : {}),
37
39
  content: {
38
40
  component: TextInputContent,
39
41
  props: {
@@ -118,7 +118,10 @@ function ComponentsMapComponent(props: Props) {
118
118
  };
119
119
 
120
120
  return (
121
- <ScreenLoadingMeasurementsListItemWrapper index={index}>
121
+ <ScreenLoadingMeasurementsListItemWrapper
122
+ index={index}
123
+ testID={`component-${item.id}`}
124
+ >
122
125
  <RiverItem {...riverItemProps} />
123
126
  </ScreenLoadingMeasurementsListItemWrapper>
124
127
  );
@@ -213,6 +213,7 @@ exports[`componentsMap renders renders components map correctly 1`] = `
213
213
  <View
214
214
  onLayout={[Function]}
215
215
  style={{}}
216
+ testID="component-A1"
216
217
  >
217
218
  <View />
218
219
  </View>
@@ -225,6 +226,7 @@ exports[`componentsMap renders renders components map correctly 1`] = `
225
226
  <View
226
227
  onLayout={[Function]}
227
228
  style={{}}
229
+ testID="component-A2"
228
230
  />
229
231
  </View>
230
232
  <View
@@ -0,0 +1,347 @@
1
+ import { renderHook } from "@testing-library/react-native";
2
+
3
+ const mockRegister = jest.fn().mockResolvedValue(undefined);
4
+
5
+ // The X-Ray mock (__mocks__/@applicaster/quick-brick-xray) has no toolbox API: give it one.
6
+ require("@applicaster/quick-brick-xray").registerReactToolbox = mockRegister;
7
+
8
+ // Required after the toolbox API is in place: loading them can register the player toolbox.
9
+ const {
10
+ modalStore,
11
+ } = require("@applicaster/zapp-react-native-utils/modalState");
12
+
13
+ const {
14
+ setXrayNavigator,
15
+ } = require("@applicaster/zapp-react-native-utils/navigationUtils/xrayNavigator");
16
+
17
+ const {
18
+ zappHookModalStore,
19
+ } = require("@applicaster/zapp-react-native-ui-components/Contexts/ZappHookModalContext");
20
+
21
+ const mockStore = {
22
+ rivers: { home: { name: "Home", type: "general_content" } },
23
+ plugins: [
24
+ { identifier: "quick-brick-epg-screen", name: "EPG" },
25
+ { identifier: "login-plugin", name: "Login" },
26
+ ],
27
+ };
28
+
29
+ jest.mock("@applicaster/zapp-react-native-redux/AppStore", () => ({
30
+ appStore: { get: (key) => mockStore[key] },
31
+ }));
32
+
33
+ const { useScreenToolboxRecord, getTopPathname } = require("../screenToolbox");
34
+
35
+ const home = { id: "home", name: "Home", type: "general_content" };
36
+
37
+ const stack = {
38
+ "/river/home": { screen: home },
39
+ "/river/epg": {
40
+ screen: { id: "epg", name: "Guide", type: "quick-brick-epg-screen" },
41
+ },
42
+ "/river/tabs": {
43
+ screen: { id: "tabs", name: "Tabs", type: "quick-brick-tabs" },
44
+ nested: { screen: { id: "t1", name: "Tab 1", type: "general_content" } },
45
+ },
46
+ "/river/home/hooks/login": {
47
+ screen: { identifier: "login-plugin", name: "Login" },
48
+ entry: { payload: { id: "e1", title: "One" } },
49
+ },
50
+ };
51
+
52
+ const navigate = (pathname: string, videoModalState = { visible: false }) =>
53
+ setXrayNavigator({
54
+ getPathname: () => pathname,
55
+ getStackForPathname: (route) =>
56
+ stack[route] ? { route, state: stack[route] } : undefined,
57
+ videoModalState,
58
+ } as unknown as QuickBrickAppNavigator);
59
+
60
+ const mount = (
61
+ component,
62
+ index,
63
+ feed?,
64
+ parentId?,
65
+ pathname = "/river/home"
66
+ ) => {
67
+ const hook = renderHook(() =>
68
+ useScreenToolboxRecord(component, "home", index, parentId, pathname)
69
+ );
70
+
71
+ hook.result.current.feed = feed;
72
+
73
+ return hook;
74
+ };
75
+
76
+ const current = (args = {}) => {
77
+ // Other toolboxes (player) may register first.
78
+ const { tools } = mockRegister.mock.calls.find(
79
+ ([toolbox]) => toolbox.name === "screen"
80
+ )[0];
81
+
82
+ return tools.find((tool) => tool.name === "current").handler(args);
83
+ };
84
+
85
+ const presentHook = (overrides = {}) =>
86
+ zappHookModalStore.setState({
87
+ state: {
88
+ path: "/hooks/login",
89
+ screenData: {
90
+ hookPlugin: { identifier: "login-plugin", name: "Login" },
91
+ payload: { id: "e1", title: "One" },
92
+ },
93
+ } as any,
94
+ isHooksExecutionInProgress: true,
95
+ isRunningInBackground: false,
96
+ ...overrides,
97
+ });
98
+
99
+ describe("screenToolbox", () => {
100
+ // Registers the toolbox; the registration is kept for the whole module, as in the app.
101
+ beforeAll(() => mount({ id: "init" }, 0).unmount());
102
+
103
+ beforeEach(() => navigate("/river/home"));
104
+
105
+ afterEach(() => {
106
+ modalStore.getState().setModalState({ visible: false, screen: null });
107
+ zappHookModalStore.getState().resetState();
108
+ zappHookModalStore.getState().setIsHooksExecutionInProgress(false);
109
+ });
110
+
111
+ it("returns the top screen's mounted components in order, with feed and entries", () => {
112
+ const second = mount({ id: "c2", component_type: "grid" }, 1, {
113
+ url: "https://feed/2",
114
+ loading: true,
115
+ });
116
+
117
+ const first = mount({ id: "c1", component_type: "horizontal_list" }, 0, {
118
+ url: "https://feed/1",
119
+ loading: false,
120
+ data: {
121
+ entry: [
122
+ { id: "e1", title: "One" },
123
+ { id: "e2", title: "Two" },
124
+ ],
125
+ },
126
+ });
127
+
128
+ const third = mount({ id: "c3", component_type: "grid" }, 2, {
129
+ url: "https://feed/3",
130
+ error: new Error("404"),
131
+ });
132
+
133
+ expect(current({ limit: 1 })).toEqual({
134
+ pathname: "/river/home",
135
+ screen: {
136
+ kind: "river",
137
+ id: "home",
138
+ name: "Home",
139
+ type: "general_content",
140
+ },
141
+ entry: null,
142
+ rivers: [
143
+ {
144
+ id: "home",
145
+ name: "Home",
146
+ type: "general_content",
147
+ components: [
148
+ {
149
+ index: 0,
150
+ id: "c1",
151
+ type: "horizontal_list",
152
+ feed: { url: "https://feed/1", state: "loaded", count: 2 },
153
+ entries: [{ index: 0, id: "e1", title: "One" }],
154
+ },
155
+ {
156
+ index: 1,
157
+ id: "c2",
158
+ type: "grid",
159
+ feed: { url: "https://feed/2", state: "loading", count: 0 },
160
+ entries: [],
161
+ },
162
+ {
163
+ index: 2,
164
+ id: "c3",
165
+ type: "grid",
166
+ feed: {
167
+ url: "https://feed/3",
168
+ state: "error",
169
+ error: "404",
170
+ count: 0,
171
+ },
172
+ entries: [],
173
+ },
174
+ ],
175
+ },
176
+ ],
177
+ mounted: ["/river/home"],
178
+ });
179
+
180
+ [first, second, third].forEach((hook) => hook.unmount());
181
+
182
+ expect(current().rivers).toEqual([]);
183
+ });
184
+
185
+ it("nests a group's children under it", () => {
186
+ const group = mount({ id: "g", component_type: "group" }, 1);
187
+
188
+ const child = mount(
189
+ { id: "g0", component_type: "hero" },
190
+ 0,
191
+ undefined,
192
+ "g"
193
+ );
194
+
195
+ const row = mount({ id: "r", component_type: "grid" }, 0);
196
+
197
+ expect(
198
+ current().rivers[0].components.map(({ id, children }) => ({
199
+ id,
200
+ children: children?.map((item) => item.id),
201
+ }))
202
+ ).toEqual([
203
+ { id: "r", children: undefined },
204
+ { id: "g", children: ["g0"] },
205
+ ]);
206
+
207
+ [group, child, row].forEach((hook) => hook.unmount());
208
+ });
209
+
210
+ it("reads another mounted screen by pathname", () => {
211
+ const other = mount({ id: "c" }, 0, undefined, undefined, "/river/epg");
212
+
213
+ expect(current({ pathname: "/river/epg" }).rivers[0].components).toEqual([
214
+ expect.objectContaining({ id: "c" }),
215
+ ]);
216
+
217
+ expect(current().mounted).toEqual(["/river/epg"]);
218
+
219
+ other.unmount();
220
+ });
221
+
222
+ it("names the plugin that renders a screen plugin", () => {
223
+ navigate("/river/epg");
224
+
225
+ expect(current().screen).toEqual({
226
+ kind: "plugin",
227
+ id: "epg",
228
+ name: "Guide",
229
+ type: "quick-brick-epg-screen",
230
+ plugin: { identifier: "quick-brick-epg-screen", name: "EPG" },
231
+ });
232
+ });
233
+
234
+ it("reports the nested tab screen", () => {
235
+ navigate("/river/tabs");
236
+
237
+ expect(current().nested).toEqual({
238
+ id: "t1",
239
+ name: "Tab 1",
240
+ type: "general_content",
241
+ });
242
+ });
243
+
244
+ it("unwraps the entry of a hook screen on the stack", () => {
245
+ navigate("/river/home/hooks/login");
246
+
247
+ expect(current()).toMatchObject({
248
+ screen: { kind: "hook", plugin: { identifier: "login-plugin" } },
249
+ entry: { id: "e1", title: "One" },
250
+ });
251
+ });
252
+
253
+ it("says when there is no screen yet", () => {
254
+ navigate("/");
255
+
256
+ expect(current().screen).toEqual({ kind: "none" });
257
+ });
258
+
259
+ it("reads a modal's screen from the modal store, its pathname as ModalProvider builds it", () => {
260
+ modalStore.getState().openModal({ item: home, props: { screenId: "m1" } });
261
+
262
+ expect(current()).toMatchObject({
263
+ pathname: "modal/m1",
264
+ screen: { kind: "river", id: "home" },
265
+ });
266
+ });
267
+
268
+ it("describes a modal hook by its plugin", () => {
269
+ modalStore.getState().openModal({
270
+ item: {
271
+ hookPlugin: { identifier: "login-plugin", name: "Login" },
272
+ payload: { id: "e1", title: "One" },
273
+ },
274
+ props: { screenType: "hooks" },
275
+ } as any);
276
+
277
+ expect(current()).toMatchObject({
278
+ screen: {
279
+ kind: "hook",
280
+ plugin: { identifier: "login-plugin", name: "Login" },
281
+ },
282
+ entry: { id: "e1", title: "One" },
283
+ });
284
+ });
285
+
286
+ it("describes a bottom sheet", () => {
287
+ modalStore.getState().openModal({
288
+ item: { modalBottomSheetContentProps: {} },
289
+ } as any);
290
+
291
+ expect(current().screen).toEqual({ kind: "bottomSheet" });
292
+ });
293
+
294
+ it("puts a modal over the video modal, and the video modal over the stack unless docked", () => {
295
+ const item = { id: "v1", title: "Video" };
296
+
297
+ navigate("/river/home", {
298
+ visible: true,
299
+ mode: "MAXIMIZED",
300
+ item,
301
+ } as any);
302
+
303
+ expect(current()).toMatchObject({
304
+ pathname: "video-modal/v1",
305
+ screen: { kind: "player" },
306
+ entry: { id: "v1", title: "Video" },
307
+ });
308
+
309
+ modalStore.getState().openModal({ item: home });
310
+
311
+ expect(getTopPathname()).toBe("modal/home");
312
+
313
+ modalStore.getState().setModalState({ visible: false, screen: null });
314
+
315
+ navigate("/river/home", {
316
+ visible: true,
317
+ mode: "MINIMIZED",
318
+ item,
319
+ } as any);
320
+
321
+ expect(getTopPathname()).toBe("/river/home");
322
+ });
323
+
324
+ it("puts a presented hook screen on top, not one running in the background", () => {
325
+ presentHook();
326
+
327
+ expect(current()).toMatchObject({
328
+ pathname: "hooks-modal/Login",
329
+ screen: {
330
+ kind: "hook",
331
+ name: "Login",
332
+ plugin: { identifier: "login-plugin" },
333
+ },
334
+ entry: { id: "e1", title: "One" },
335
+ });
336
+
337
+ presentHook({ isRunningInBackground: true });
338
+
339
+ expect(getTopPathname()).toBe("/river/home");
340
+ });
341
+
342
+ it("describes nothing for a modal pathname that is no longer presented", () => {
343
+ expect(current({ pathname: "modal/gone" }).screen).toEqual({
344
+ kind: "none",
345
+ });
346
+ });
347
+ });
@@ -0,0 +1,53 @@
1
+ import { act, renderHook } from "@testing-library/react-native";
2
+
3
+ // Fails the screen toolbox as a build without the X-Ray bridge does. Others (player, loaded with
4
+ // the toolbox's imports) register, or the helper would never let the screen toolbox try.
5
+ const mockRegister = jest.fn((toolbox) =>
6
+ toolbox.name === "screen"
7
+ ? Promise.reject(new Error("no bridge"))
8
+ : Promise.resolve()
9
+ );
10
+
11
+ // The X-Ray mock (__mocks__/@applicaster/quick-brick-xray) has no toolbox API: give it one.
12
+ require("@applicaster/quick-brick-xray").registerReactToolbox = mockRegister;
13
+
14
+ jest.mock("@applicaster/zapp-react-native-redux/AppStore", () => ({
15
+ appStore: { get: () => undefined },
16
+ }));
17
+
18
+ const { useScreenToolboxRecord } = require("../screenToolbox");
19
+
20
+ const mount = (id: string) =>
21
+ renderHook(() =>
22
+ useScreenToolboxRecord({ id }, "home", 0, undefined, "/river/home")
23
+ );
24
+
25
+ const mounted = () => {
26
+ // Other toolboxes (player) may register first.
27
+ const { tools } = mockRegister.mock.calls.find(
28
+ ([toolbox]) => toolbox.name === "screen"
29
+ )[0];
30
+
31
+ return tools
32
+ .find((tool) => tool.name === "current")
33
+ .handler({ pathname: "/river/home" }).mounted;
34
+ };
35
+
36
+ describe("screenToolbox without the X-Ray bridge", () => {
37
+ it("drops its records and stops recording", async () => {
38
+ mount("c1");
39
+
40
+ // Let the failed registration settle.
41
+ await act(async () => {});
42
+
43
+ expect(mounted()).toEqual([]);
44
+
45
+ mount("c2");
46
+
47
+ expect(mounted()).toEqual([]);
48
+
49
+ expect(
50
+ mockRegister.mock.calls.filter(([toolbox]) => toolbox.name === "screen")
51
+ ).toHaveLength(1);
52
+ });
53
+ });
@@ -5,7 +5,9 @@ import { useRoute } from "@applicaster/zapp-react-native-utils/reactHooks/naviga
5
5
  import { usePlugins } from "@applicaster/zapp-react-native-redux/hooks";
6
6
  import { ZappPipesSearchContext } from "@applicaster/zapp-react-native-ui-components/Contexts";
7
7
  import { useScreenContext } from "@applicaster/zapp-react-native-utils/reactHooks/screen/useScreenContext";
8
+ import { useUIComponentContext } from "@applicaster/zapp-react-native-ui-components/Contexts/UIComponentContext";
8
9
  import { ResolverSelector } from "./ResolverSelector";
10
+ import { useScreenToolboxRecord } from "./screenToolbox";
9
11
  import { ComponentDataSourceContext } from "./types";
10
12
 
11
13
  type Props = {
@@ -22,7 +24,7 @@ export function zappPipesDataConnector(
22
24
  Component: React.FC<any> | React.ComponentClass<any>
23
25
  ) {
24
26
  return function WrappedWithZappPipesData(props: Props) {
25
- const { screenData } = useRoute();
27
+ const { screenData, pathname } = useRoute();
26
28
  const plugins = usePlugins();
27
29
  const screenContextData = useScreenContext();
28
30
 
@@ -53,6 +55,16 @@ export function zappPipesDataConnector(
53
55
 
54
56
  const [searchContext] = ZappPipesSearchContext.useZappPipesContext();
55
57
 
58
+ const parent = useUIComponentContext() as { id?: string } | null;
59
+
60
+ const toolboxRecord = useScreenToolboxRecord(
61
+ component,
62
+ riverId,
63
+ componentIndex,
64
+ parent?.id,
65
+ pathname
66
+ );
67
+
56
68
  // Prepare context for resolvers
57
69
  const resolverProps: ComponentDataSourceContext = {
58
70
  component,
@@ -71,9 +83,12 @@ export function zappPipesDataConnector(
71
83
 
72
84
  return (
73
85
  <ResolverSelector {...resolverProps}>
74
- {(zappPipesDataProps) => (
75
- <Component {...props} {...zappPipesDataProps} />
76
- )}
86
+ {(zappPipesDataProps) => {
87
+ // A plain field on the toolbox record, read only when the agent asks: no render.
88
+ toolboxRecord.feed = zappPipesDataProps.zappPipesData;
89
+
90
+ return <Component {...props} {...zappPipesDataProps} />;
91
+ }}
77
92
  </ResolverSelector>
78
93
  );
79
94
  };
@@ -0,0 +1,384 @@
1
+ import { useEffect, useMemo } from "react";
2
+ import { Platform } from "react-native";
3
+ import * as R from "ramda";
4
+ import { appStore } from "@applicaster/zapp-react-native-redux/AppStore";
5
+ import {
6
+ registerXrayToolbox,
7
+ XrayToolSpec,
8
+ } from "@applicaster/zapp-react-native-utils/logger/xrayToolbox";
9
+ import { modalStore } from "@applicaster/zapp-react-native-utils/modalState";
10
+ import { ROUTE_TYPES } from "@applicaster/zapp-react-native-utils/navigationUtils/routeTypes";
11
+ import { getXrayNavigator } from "@applicaster/zapp-react-native-utils/navigationUtils/xrayNavigator";
12
+ import { isTV } from "@applicaster/zapp-react-native-utils/reactUtils";
13
+ import { zappHookModalStore } from "@applicaster/zapp-react-native-ui-components/Contexts/ZappHookModalContext";
14
+
15
+ import { ZappPipesData } from "./types";
16
+
17
+ /**
18
+ * X-Ray `screen.*` toolbox: what the current screen shows — its river components in order, the
19
+ * feed each one loaded and the entries it displays. Every river component, on every platform,
20
+ * goes through the zappPipes decorator, which writes a record here on mount and drops it on
21
+ * unmount: a plain Set, nothing subscribes to it, so it never renders anything. Records are
22
+ * dropped for good once X-Ray turns out to have no toolbox bridge (release builds).
23
+ *
24
+ * Screens that are not rivers (screen plugins, hooks, the player) report what renders them,
25
+ * read from navigation and the modal stores; a plugin with state of its own registers its own
26
+ * toolbox, as the player does.
27
+ *
28
+ * Mounted is not visible (lists are virtualised): which rows and cells are on screen comes from
29
+ * the native `ui.inspect`. River rows carry testID `component-<componentId>` (mobile, tvOS,
30
+ * Android TV) and cells carry their entry id in their testID.
31
+ */
32
+
33
+ type ComponentRecord = {
34
+ component: ZappUIComponent;
35
+ riverId: string;
36
+ index?: number;
37
+ parentId?: string;
38
+ pathname: string;
39
+ /** Written on each render: the data the component was given, after item filter and limit. */
40
+ feed?: ZappPipesData;
41
+ };
42
+
43
+ /** What a route shows; `screenType` is how ScreenResolver renders it. */
44
+ type RouteState = {
45
+ screenType?: string;
46
+ screen?: any;
47
+ entry?: Partial<ZappEntry>;
48
+ nested?: { screen?: ZappRiver };
49
+ };
50
+
51
+ /** A screen presented over the navigation stack. `covers` is false when it is out of the way. */
52
+ type Layer = RouteState & { pathname: string; covers: boolean };
53
+
54
+ const records = new Set<ComponentRecord>();
55
+
56
+ /** Set once registration failed: X-Ray has no toolbox bridge, so nothing will ever read records. */
57
+ let unavailable = false;
58
+
59
+ const DEFAULT_ENTRIES_LIMIT = 10;
60
+
61
+ const toLimit = (value: unknown) =>
62
+ typeof value === "number" && value >= 0 ? value : DEFAULT_ENTRIES_LIMIT;
63
+
64
+ /** The hooks modal, with the pathname ZappHooksModal gives it; hidden while it runs in the background. */
65
+ const hooksModalLayer = (): Layer | undefined => {
66
+ const { state, isHooksExecutionInProgress, isRunningInBackground } =
67
+ zappHookModalStore.getState();
68
+
69
+ if (!isHooksExecutionInProgress || !state.path) return undefined;
70
+
71
+ const hookPlugin = state.screenData?.hookPlugin;
72
+
73
+ return {
74
+ pathname: `${ROUTE_TYPES.HOOKS_MODAL}/${hookPlugin ? hookPlugin.name : ""}`,
75
+ covers:
76
+ !isRunningInBackground && hookPlugin?.module?.presentFullScreen !== false,
77
+ screenType: "hooks",
78
+ screen: hookPlugin,
79
+ entry: state.screenData?.payload as ZappEntry,
80
+ };
81
+ };
82
+
83
+ /** The modal screen or bottom sheet, with the pathname and props ModalProvider gives it. */
84
+ const modalLayer = (): Layer | undefined => {
85
+ const { visible, screen, props } = modalStore.getState().modalState;
86
+
87
+ if (!visible || !screen) return undefined;
88
+
89
+ const { screenId, screenType, entry, screenData } = {
90
+ screenId: screen.id,
91
+ screenType: "river",
92
+ ...props,
93
+ } as Record<string, any>;
94
+
95
+ // A modal hook (and the ad interstitial) presents a { hookPlugin, payload } wrapper.
96
+ const isHook = screenType === "hooks";
97
+
98
+ return {
99
+ pathname: `${ROUTE_TYPES.MODAL}/${screenId}`,
100
+ covers: true,
101
+ screenType,
102
+ screen: isHook ? (screen.hookPlugin ?? screenData?.hookPlugin) : screen,
103
+ entry: isHook ? screen.payload : entry,
104
+ };
105
+ };
106
+
107
+ const videoModalLayer = (): Layer | undefined => {
108
+ const videoModal = getXrayNavigator()?.videoModalState;
109
+
110
+ if (!videoModal?.visible) return undefined;
111
+
112
+ return {
113
+ pathname: `${ROUTE_TYPES.VIDEO_MODAL}/${videoModal.item?.id}`,
114
+ covers: videoModal.mode !== "MINIMIZED",
115
+ screenType: "playable",
116
+ entry: videoModal.item,
117
+ };
118
+ };
119
+
120
+ /**
121
+ * Topmost first: the hooks modal and modals are native RN Modals, drawn over the video modal.
122
+ * Only layers this platform renders: the mobile layout renders the hooks and video modals, and
123
+ * ModalProvider renders nothing on web.
124
+ */
125
+ const presentedLayers = () => {
126
+ const web = Platform.OS === "web";
127
+ const mobile = !web && !isTV();
128
+
129
+ return [
130
+ mobile && hooksModalLayer(),
131
+ !web && modalLayer(),
132
+ mobile && videoModalLayer(),
133
+ ].filter((layer): layer is Layer => Boolean(layer));
134
+ };
135
+
136
+ /** The pathname of what is on top: a covering presented layer, else the stack top. */
137
+ export const getTopPathname = (): string | undefined =>
138
+ presentedLayers().find((layer) => layer.covers)?.pathname ??
139
+ getXrayNavigator()?.getPathname();
140
+
141
+ /** What the route at `pathname` shows. Presented layers are not on the stack: their stores hold them. */
142
+ const routeState = (pathname: string): RouteState => {
143
+ const layer = presentedLayers().find((item) => item.pathname === pathname);
144
+
145
+ if (layer) return layer;
146
+
147
+ // Not presented any more (a stale modal pathname): nothing to describe.
148
+ if (!pathname.startsWith("/")) return {};
149
+
150
+ const state = getXrayNavigator()?.getStackForPathname(pathname)?.state;
151
+
152
+ // A stack route ends in <screenType>/<screenId>.
153
+ const screenType = pathname.split("/").slice(-2)[0];
154
+
155
+ return {
156
+ screenType,
157
+ ...state,
158
+ // A hook screen's entry is the { payload } wrapper it was pushed with.
159
+ entry:
160
+ screenType === "hooks"
161
+ ? ((state?.entry as any)?.payload ?? state?.entry)
162
+ : state?.entry,
163
+ };
164
+ };
165
+
166
+ /** Screen types ScreenResolver renders itself rather than through a screen plugin. */
167
+ const BUILT_IN_KINDS = {
168
+ playable: "player",
169
+ link: "link",
170
+ favorites: "favorites",
171
+ };
172
+
173
+ /**
174
+ * What renders the screen, as ScreenResolver picks it: a river of components, a built-in screen,
175
+ * a hook plugin, or a screen plugin (a river whose type is a plugin identifier, or a route whose
176
+ * screen type is one).
177
+ */
178
+ const describeKind = ({
179
+ screenType,
180
+ screen,
181
+ }: RouteState): { kind: string; identifier?: string } => {
182
+ if (!screenType) return { kind: "none" };
183
+
184
+ if (screen?.modalBottomSheetContentProps) return { kind: "bottomSheet" };
185
+
186
+ if (screenType === "hooks") {
187
+ return { kind: "hook", identifier: screen?.identifier };
188
+ }
189
+
190
+ if (BUILT_IN_KINDS[screenType]) return { kind: BUILT_IN_KINDS[screenType] };
191
+
192
+ if (screenType !== "river") return { kind: "plugin", identifier: screenType };
193
+
194
+ return screen?.type === "general_content"
195
+ ? { kind: "river" }
196
+ : { kind: "plugin", identifier: screen?.type };
197
+ };
198
+
199
+ const describePlugin = (identifier?: string) => {
200
+ if (!identifier) return null;
201
+
202
+ const plugins = (appStore.get("plugins") ?? []) as QuickBrickPlugin[];
203
+
204
+ return {
205
+ identifier,
206
+ name: plugins.find((plugin) => plugin.identifier === identifier)?.name,
207
+ };
208
+ };
209
+
210
+ const describeFeed = (feed?: ZappPipesData) => {
211
+ if (!feed?.url) return null;
212
+
213
+ const error = feed.error as { message?: string } | undefined;
214
+
215
+ return {
216
+ url: feed.url,
217
+ state: error ? "error" : feed.loading ? "loading" : "loaded",
218
+ ...(error && { error: error.message ?? String(error) }),
219
+ count: feed.data?.entry?.length ?? 0,
220
+ };
221
+ };
222
+
223
+ const byIndex = (a: ComponentRecord, b: ComponentRecord) =>
224
+ (a.index ?? 0) - (b.index ?? 0);
225
+
226
+ /** Components in order, a group's children nested under it (they share the river and its indexes). */
227
+ const describeComponents = (
228
+ mounted: ComponentRecord[],
229
+ belongs: (record: ComponentRecord) => boolean,
230
+ limit: number
231
+ ) =>
232
+ mounted
233
+ .filter(belongs)
234
+ .sort(byIndex)
235
+ .map((record) => {
236
+ const children = describeComponents(
237
+ mounted,
238
+ (child) => child.parentId === record.component.id,
239
+ limit
240
+ );
241
+
242
+ return {
243
+ index: record.index,
244
+ id: record.component.id,
245
+ type: record.component.component_type,
246
+ name: record.component.name,
247
+ feed: describeFeed(record.feed),
248
+ entries: (record.feed?.data?.entry ?? [])
249
+ .slice(0, limit)
250
+ .map((entry, index) => ({ index, id: entry.id, title: entry.title })),
251
+ ...(children.length > 0 && { children }),
252
+ };
253
+ });
254
+
255
+ const describeRiverComponents = (mounted: ComponentRecord[], limit: number) => {
256
+ const ids = new Set(mounted.map((record) => record.component.id));
257
+
258
+ return describeComponents(
259
+ mounted,
260
+ (record) => !record.parentId || !ids.has(record.parentId),
261
+ limit
262
+ );
263
+ };
264
+
265
+ const describeScreen = (pathname: string, limit: number) => {
266
+ const state = routeState(pathname);
267
+ const { screen, entry, nested } = state;
268
+ const { kind, identifier } = describeKind(state);
269
+ const plugin = describePlugin(identifier);
270
+
271
+ const mounted = Array.from(records).filter(
272
+ (record) => record.pathname === pathname
273
+ );
274
+
275
+ const riverIds = R.uniq(mounted.map((record) => record.riverId));
276
+ const rivers = appStore.get("rivers") as Record<string, ZappRiver>;
277
+
278
+ return {
279
+ pathname,
280
+ screen: {
281
+ kind,
282
+ ...(screen && { id: screen.id, name: screen.name, type: screen.type }),
283
+ ...(plugin && { plugin }),
284
+ },
285
+ ...(nested?.screen && {
286
+ nested: {
287
+ id: nested.screen.id,
288
+ name: nested.screen.name,
289
+ type: nested.screen.type,
290
+ },
291
+ }),
292
+ entry: entry ? { id: entry.id, title: entry.title } : null,
293
+ rivers: riverIds.map((riverId) => ({
294
+ id: riverId,
295
+ name: rivers?.[riverId]?.name,
296
+ type: rivers?.[riverId]?.type,
297
+ components: describeRiverComponents(
298
+ mounted.filter((record) => record.riverId === riverId),
299
+ limit
300
+ ),
301
+ })),
302
+ };
303
+ };
304
+
305
+ const tools: XrayToolSpec[] = [
306
+ {
307
+ name: "current",
308
+ description:
309
+ "The screen on top (hooks modal, modal or bottom sheet, video modal unless docked, else stack top) " +
310
+ "or the one at `pathname`: what renders it (kind river, plugin, hook, player, link, favorites, " +
311
+ "bottomSheet, and the plugin), its river components in order (a group's children nested) with " +
312
+ "their feed (url, state, count) and first entries. " +
313
+ "Lists mount ahead of the viewport, so a component here may be off screen: use the native " +
314
+ "ui.inspect for what is visible (row testID component-<id>; cell testIDs contain the entry id).",
315
+ parameters: [
316
+ {
317
+ name: "pathname",
318
+ type: "string",
319
+ description:
320
+ "A mounted screen's route (see mounted); defaults to the top one.",
321
+ },
322
+ {
323
+ name: "limit",
324
+ type: "number",
325
+ description: `Entries per component, default ${DEFAULT_ENTRIES_LIMIT}.`,
326
+ },
327
+ ],
328
+ handler: ({ pathname, limit }) => {
329
+ const target =
330
+ typeof pathname === "string" && pathname ? pathname : getTopPathname();
331
+
332
+ if (!target) throw new Error("Navigation is not available yet");
333
+
334
+ return {
335
+ ...describeScreen(target, toLimit(limit)),
336
+ mounted: R.uniq(Array.from(records, (record) => record.pathname)),
337
+ };
338
+ },
339
+ },
340
+ ];
341
+
342
+ const registerScreenToolbox = () =>
343
+ registerXrayToolbox({
344
+ name: "screen",
345
+ description:
346
+ "What the current screen shows: what renders it, river components, their feeds and entries.",
347
+ tools,
348
+ });
349
+
350
+ /**
351
+ * Keeps a record of a mounted river component for the toolbox. Returns the record so the caller
352
+ * can attach the feed data it renders with.
353
+ */
354
+ export const useScreenToolboxRecord = (
355
+ component: ZappUIComponent,
356
+ riverId: string,
357
+ index: number | undefined,
358
+ parentId: string | undefined,
359
+ pathname: string
360
+ ): ComponentRecord => {
361
+ const record = useMemo(
362
+ () => ({ component, riverId, index, parentId, pathname }),
363
+ [component, riverId, index, parentId, pathname]
364
+ );
365
+
366
+ useEffect(() => {
367
+ if (unavailable) return;
368
+
369
+ registerScreenToolbox().then((registered) => {
370
+ if (!registered) {
371
+ unavailable = true;
372
+ records.clear();
373
+ }
374
+ });
375
+
376
+ records.add(record);
377
+
378
+ return () => {
379
+ records.delete(record);
380
+ };
381
+ }, [record]);
382
+
383
+ return record;
384
+ };
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.113",
3
+ "version": "16.0.0-rc.114",
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.113",
32
- "@applicaster/zapp-react-native-bridge": "16.0.0-rc.113",
33
- "@applicaster/zapp-react-native-redux": "16.0.0-rc.113",
34
- "@applicaster/zapp-react-native-utils": "16.0.0-rc.113",
31
+ "@applicaster/applicaster-types": "16.0.0-rc.114",
32
+ "@applicaster/zapp-react-native-bridge": "16.0.0-rc.114",
33
+ "@applicaster/zapp-react-native-redux": "16.0.0-rc.114",
34
+ "@applicaster/zapp-react-native-utils": "16.0.0-rc.114",
35
35
  "fast-json-stable-stringify": "^2.1.0",
36
36
  "promise": "^8.3.0",
37
37
  "react-native-sortables": "1.7.1",