@applicaster/zapp-react-native-utils 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.
@@ -60,6 +60,30 @@ describe("openBottomSheetAction custom content", () => {
60
60
  });
61
61
  });
62
62
 
63
+ it("passes themePluginId through to the name sheet", async () => {
64
+ const CustomContent = function CustomContent() {
65
+ return null;
66
+ };
67
+
68
+ await openBottomSheetAction({
69
+ type: "openBottomSheet",
70
+ options: {
71
+ header: { title: "Create New Playlist" },
72
+ themePluginId: "playlist-experience",
73
+ content: {
74
+ component: CustomContent,
75
+ props: { buttonLabel: "Create" },
76
+ },
77
+ },
78
+ } as any);
79
+
80
+ const args = (openBottomSheetModal as jest.Mock).mock.calls[0][0];
81
+
82
+ expect(args.modalBottomSheetContentProps.themePluginId).toBe(
83
+ "playlist-experience"
84
+ );
85
+ });
86
+
63
87
  it("returns Error when neither list data nor content.component is provided", async () => {
64
88
  const result = await openBottomSheetAction({
65
89
  type: "openBottomSheet",
@@ -212,6 +212,7 @@ export async function openBottomSheetAction(
212
212
  ...(content.props || {}),
213
213
  actionContext: context,
214
214
  },
215
+ themePluginId,
215
216
  },
216
217
  });
217
218
 
@@ -45,7 +45,7 @@ describe("playerToolbox", () => {
45
45
 
46
46
  await expect(
47
47
  loadToolbox().registerPlayerToolbox(manager(null))
48
- ).resolves.toBeUndefined();
48
+ ).resolves.toBe(false);
49
49
  });
50
50
 
51
51
  it("does nothing when getXray returns null", async () => {
@@ -53,7 +53,7 @@ describe("playerToolbox", () => {
53
53
 
54
54
  await expect(
55
55
  loadToolbox().registerPlayerToolbox(manager(null))
56
- ).resolves.toBeUndefined();
56
+ ).resolves.toBe(false);
57
57
  });
58
58
 
59
59
  it("swallows a failed registration", async () => {
@@ -62,7 +62,7 @@ describe("playerToolbox", () => {
62
62
 
63
63
  await expect(
64
64
  loadToolbox().registerPlayerToolbox(manager(null))
65
- ).resolves.toBeUndefined();
65
+ ).resolves.toBe(false);
66
66
  });
67
67
 
68
68
  it("seeks to an absolute position, from the end when negative, clamped", async () => {
@@ -397,6 +397,11 @@ export class PlayerManager {
397
397
  return this.manager.removeHandler(event, handler);
398
398
  }
399
399
 
400
+ /** Whether anything handles `event` now, e.g. a mounted PlayerContainer for "toggleFullscreen". */
401
+ public hasHandler(event: string) {
402
+ return this.manager.handlers[event]?.length > 0;
403
+ }
404
+
400
405
  // TODO: should not be
401
406
  public invokeHandler(event, ...args) {
402
407
  return this.manager.invokeHandler(event, ...args);
@@ -1,4 +1,4 @@
1
- import { getXray } from "../../logger";
1
+ import { registerXrayToolbox, XrayToolSpec } from "../../logger/xrayToolbox";
2
2
  import type { PlayerManager } from "./index";
3
3
 
4
4
  /**
@@ -9,25 +9,6 @@ import type { PlayerManager } from "./index";
9
9
  * How the player is shown (video modal mode, mini player) is navigation state: see `nav.*`.
10
10
  */
11
11
 
12
- type ToolSpec = {
13
- name: string;
14
- description: string;
15
- parameters?: Array<{
16
- name: string;
17
- type: "number";
18
- description: string;
19
- }>;
20
- handler: (args: Record<string, unknown>) => unknown;
21
- };
22
-
23
- type RegisterReactToolbox = (toolbox: {
24
- name: string;
25
- description: string;
26
- tools: ToolSpec[];
27
- }) => Promise<void>;
28
-
29
- let registration: Promise<void> | undefined;
30
-
31
12
  const toNumber = (value: unknown) =>
32
13
  typeof value === "number" && Number.isFinite(value) ? value : null;
33
14
 
@@ -44,7 +25,7 @@ const requireSeekable = (player: {
44
25
  return seekable;
45
26
  };
46
27
 
47
- const tools = (manager: PlayerManager): ToolSpec[] => {
28
+ const tools = (manager: PlayerManager): XrayToolSpec[] => {
48
29
  const requirePlayer = () => {
49
30
  const player = manager.getActivePlayer();
50
31
 
@@ -168,25 +149,9 @@ const tools = (manager: PlayerManager): ToolSpec[] => {
168
149
  ];
169
150
  };
170
151
 
171
- export const registerPlayerToolbox = (manager: PlayerManager) => {
172
- if (registration) return registration;
173
-
174
- const register = (
175
- getXray() as
176
- | { registerReactToolbox?: RegisterReactToolbox }
177
- | null
178
- | undefined
179
- )?.registerReactToolbox;
180
-
181
- if (!register) return Promise.resolve();
182
-
183
- registration = register({
152
+ export const registerPlayerToolbox = (manager: PlayerManager) =>
153
+ registerXrayToolbox({
184
154
  name: "player",
185
155
  description: "Inspect and control the active player.",
186
156
  tools: tools(manager),
187
- }).catch(() => {
188
- registration = undefined;
189
157
  });
190
-
191
- return registration;
192
- };
@@ -141,4 +141,14 @@ describe("getArtworkImage", () => {
141
141
 
142
142
  expect(result).toBe(DEFAULT_IMAGE);
143
143
  });
144
+
145
+ it("returns undefined when entry explicitly sets an empty key", () => {
146
+ const result = getArtworkImage({
147
+ key: "artwork_key",
148
+ entry: { ...entryWithImage, extensions: { artwork_key: "" } },
149
+ plugin_configuration: pluginConfigWithImage,
150
+ });
151
+
152
+ expect(result).toBeUndefined();
153
+ });
144
154
  });
@@ -110,10 +110,15 @@ export const getArtworkImage = ({
110
110
  key,
111
111
  entry,
112
112
  plugin_configuration,
113
- }: GetArtworkImageParams): string => {
113
+ }: GetArtworkImageParams): Option<string> => {
114
114
  // 1) image_key from extensions
115
115
  const imageKeyFromExtensions = getPropertyFromExtensions(key, entry);
116
116
 
117
+ // An explicitly empty key means the feed wants no artwork (background only)
118
+ if (imageKeyFromExtensions === "") {
119
+ return undefined;
120
+ }
121
+
117
122
  const imageFromExtensions = getImageFromEntry({
118
123
  entry,
119
124
  imageKey: imageKeyFromExtensions,
@@ -165,7 +170,7 @@ const AUDIO_PLAYER_ARTWORK_IMAGE_KEY = "audio_player_artwork_image_key";
165
170
  const audioPlayerArtworkImageKeySelector = (config) =>
166
171
  config?.[AUDIO_PLAYER_ARTWORK_IMAGE_KEY];
167
172
 
168
- export const useArtworkImage = (entry: ZappEntry): string => {
173
+ export const useArtworkImage = (entry: ZappEntry): Option<string> => {
169
174
  const configuration = useZStore("playerConfiguration");
170
175
 
171
176
  const audio_player_artwork_image_key_value = configuration(
@@ -329,4 +329,64 @@ describe("getEmptyModalBlocksStructure", () => {
329
329
  expect(result.sectionHeader.title).toEqual({ default: {} });
330
330
  expect(result.sectionHeader.actionButtons).toEqual({ default: {} });
331
331
  });
332
+
333
+ it("maps playlist button font family and the new green style keys", () => {
334
+ const result = parseModalBlocksConfiguration(
335
+ {
336
+ "button.title.ios.font_family": "GreycliffCF-Bold",
337
+ "button.title.inactive_font_color": "#111111",
338
+ "button.title.focused_font_color": "#222222",
339
+ "button.title.text_transform": "uppercase",
340
+ "button.background.inactive_color": "#999999",
341
+ "button.background.horizontal_gutter": 6,
342
+ "button.icons.width": 24,
343
+ "button.icons.height": 24,
344
+ },
345
+ "playlist-experience"
346
+ );
347
+
348
+ expect(result.button.title.default).toEqual(
349
+ expect.objectContaining({
350
+ fontFamily: "GreycliffCF-Bold",
351
+ inactiveFontColor: "#111111",
352
+ textTransform: "uppercase",
353
+ })
354
+ );
355
+
356
+ // "focused_" is a state prefix, so this key lands on the focused bucket.
357
+ expect(result.button.title.focused).toEqual(
358
+ expect.objectContaining({
359
+ fontColor: "#222222",
360
+ })
361
+ );
362
+
363
+ expect(result.button.background.default).toEqual(
364
+ expect.objectContaining({
365
+ inactiveColor: "#999999",
366
+ horizontalGutter: 6,
367
+ })
368
+ );
369
+
370
+ expect(result.button.icons.default).toEqual(
371
+ expect.objectContaining({
372
+ width: 24,
373
+ height: 24,
374
+ })
375
+ );
376
+ });
377
+
378
+ it("keeps a font-selector object on the button title", () => {
379
+ const result = parseModalBlocksConfiguration(
380
+ {
381
+ "button.title.ios.font_family": { fontFamily: "GreycliffCF-Bold" },
382
+ },
383
+ "playlist-experience"
384
+ );
385
+
386
+ expect(result.button.title.default).toEqual(
387
+ expect.objectContaining({
388
+ fontFamily: { fontFamily: "GreycliffCF-Bold" },
389
+ })
390
+ );
391
+ });
332
392
  });
@@ -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
+ };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The app navigator for the X-Ray toolboxes (nav, screen), which run outside React. The
3
+ * NavigationProvider sets it after each commit; it is a plain module variable, so updating it
4
+ * never renders anything.
5
+ */
6
+ let navigator: QuickBrickAppNavigator | undefined;
7
+
8
+ export const setXrayNavigator = (value: QuickBrickAppNavigator | undefined) => {
9
+ navigator = value;
10
+ };
11
+
12
+ export const getXrayNavigator = () => navigator;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applicaster/zapp-react-native-utils",
3
- "version": "16.0.0-rc.113",
3
+ "version": "16.0.0-rc.114",
4
4
  "description": "Applicaster Zapp React Native utilities package",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "homepage": "https://github.com/applicaster/quickbrick#readme",
29
29
  "dependencies": {
30
- "@applicaster/applicaster-types": "16.0.0-rc.113",
30
+ "@applicaster/applicaster-types": "16.0.0-rc.114",
31
31
  "buffer": "^5.2.1",
32
32
  "camelize": "^1.0.0",
33
33
  "dayjs": "^1.11.10",
@@ -34,6 +34,7 @@ type Props = {
34
34
  type ItemProps = {
35
35
  children: React.ReactNode;
36
36
  index: number;
37
+ testID?: string;
37
38
  };
38
39
 
39
40
  type MeasurementContext = {
@@ -153,11 +154,12 @@ export const ScreenLoadingMeasurements = ({
153
154
  export const ScreenLoadingMeasurementsListItemWrapper = ({
154
155
  children,
155
156
  index,
157
+ testID,
156
158
  }: ItemProps) => {
157
159
  const { onLayout } = React.useContext(MeasurementsSettersContext);
158
160
 
159
161
  return (
160
- <View style={styles.container} onLayout={onLayout?.(index)}>
162
+ <View style={styles.container} onLayout={onLayout?.(index)} testID={testID}>
161
163
  {children}
162
164
  </View>
163
165
  );