@applicaster/zapp-react-native-utils 16.0.0-alpha.8114431136 → 16.0.0-alpha.8225069933

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.
@@ -0,0 +1,62 @@
1
+ import { Player } from "../player";
2
+
3
+ const entry = (id: string) => ({ id, title: id }) as unknown as ZappEntry;
4
+
5
+ const makePlayer = () => {
6
+ const player = new Player({ playerId: "test-player" });
7
+ player.entry = entry("first");
8
+
9
+ return player;
10
+ };
11
+
12
+ describe("Player per-entry diagnostics", () => {
13
+ it("keeps the latest of onError and onVideoError, and clears it when a new entry loads", () => {
14
+ const player = makePlayer();
15
+ const second = { message: "second" };
16
+
17
+ player.onError({ message: "first" } as any);
18
+ player.onVideoError(second as any);
19
+
20
+ expect(player.lastError).toBe(second);
21
+
22
+ player.entry = entry("second");
23
+
24
+ expect(player.lastError).toBeNull();
25
+ });
26
+
27
+ it("reads the next entry without creating the overlay observer", () => {
28
+ const player = makePlayer();
29
+
30
+ expect(player.getPlayNextEntry()).toBeNull();
31
+
32
+ jest
33
+ .spyOn(player.getOverlayObservable(), "getPlayNextEntry")
34
+ .mockReturnValue(entry("next"));
35
+
36
+ expect(player.getPlayNextEntry()).toEqual(entry("next"));
37
+ });
38
+
39
+ it("keeps the content clock while a client-side ad plays", () => {
40
+ const player = makePlayer();
41
+
42
+ player.onVideoProgress({
43
+ currentTime: 300,
44
+ duration: 1800,
45
+ seekableDuration: 1800,
46
+ });
47
+
48
+ player.onAdBegin({});
49
+
50
+ player.onVideoProgress({
51
+ currentTime: 5,
52
+ duration: 15,
53
+ seekableDuration: 15,
54
+ });
55
+
56
+ expect(player.getPosition()).toBe(5);
57
+ expect(player.getDuration()).toBe(15);
58
+ expect(player.getContentPosition()).toBe(300);
59
+ expect(player.getContentDuration()).toBe(1800);
60
+ expect(player.getSeekableDuration()).toBe(1800);
61
+ });
62
+ });
@@ -18,6 +18,9 @@ const loadToolbox = () => {
18
18
  const player = (overrides = {}) => ({
19
19
  getSeekableDuration: () => 100,
20
20
  getContentPosition: () => 40,
21
+ isLive: () => false,
22
+ getPlayNextEntry: () => null,
23
+ playNextHandler: jest.fn(),
21
24
  seekTo: jest.fn(),
22
25
  ...overrides,
23
26
  });
@@ -30,7 +33,7 @@ const registeredTools = async (active) => {
30
33
  mockXray = { registerReactToolbox: mockRegister };
31
34
  await loadToolbox().registerPlayerToolbox(manager(active));
32
35
 
33
- const { tools } = mockRegister.mock.calls[0][0];
36
+ const { tools } = mockRegister.mock.lastCall[0];
34
37
 
35
38
  return Object.fromEntries(tools.map((tool) => [tool.name, tool.handler]));
36
39
  };
@@ -87,6 +90,187 @@ describe("playerToolbox", () => {
87
90
  );
88
91
  });
89
92
 
93
+ describe("state", () => {
94
+ const playing = (overrides = {}, entry: any = { id: "e1", title: "T" }) =>
95
+ player({
96
+ playerId: "p1",
97
+ playerRole: "Primary",
98
+ getEntry: () => entry,
99
+ getPosition: () => 40,
100
+ getDuration: () => 100,
101
+ getContentDuration: () => 100,
102
+ isReadyToPlay: () => true,
103
+ isPlaying: () => true,
104
+ isBuffering: () => false,
105
+ isAd: () => false,
106
+ isLive: () => false,
107
+ getIsMuted: () => false,
108
+ ...overrides,
109
+ });
110
+
111
+ const state = async (overrides = {}, entry?: any) =>
112
+ (await registeredTools(playing(overrides, entry))).state();
113
+
114
+ it("reports inactive when nothing plays", async () => {
115
+ expect((await registeredTools(null)).state()).toEqual({
116
+ active: false,
117
+ casting: false,
118
+ });
119
+ });
120
+
121
+ it("keeps the existing fields and adds the content clock for a VOD", async () => {
122
+ expect(await state()).toEqual({
123
+ active: true,
124
+ playerId: "p1",
125
+ entry: { id: "e1", title: "T" },
126
+ position: 40,
127
+ duration: 100,
128
+ playing: true,
129
+ buffering: false,
130
+ readyToPlay: true,
131
+ ad: false,
132
+ live: false,
133
+ muted: false,
134
+ role: "Primary",
135
+ plugin: null,
136
+ engine: null,
137
+ casting: false,
138
+ seekableDuration: 100,
139
+ contentPosition: 40,
140
+ contentDuration: 100,
141
+ drm: null,
142
+ lastError: null,
143
+ next: null,
144
+ });
145
+ });
146
+
147
+ it("reports the content's clock next to the ad's during a client-side ad", async () => {
148
+ const result = await state({
149
+ isAd: () => true,
150
+ getPosition: () => 5,
151
+ getDuration: () => 15,
152
+ getContentPosition: () => 300,
153
+ getContentDuration: () => 1800,
154
+ });
155
+
156
+ expect(result).toMatchObject({
157
+ ad: true,
158
+ position: 5,
159
+ duration: 15,
160
+ contentPosition: 300,
161
+ contentDuration: 1800,
162
+ });
163
+ });
164
+
165
+ it("reports non-finite numbers as null", async () => {
166
+ expect(
167
+ await state({
168
+ getSeekableDuration: () => NaN,
169
+ getContentPosition: () => null,
170
+ getContentDuration: () => Infinity,
171
+ })
172
+ ).toMatchObject({
173
+ seekableDuration: null,
174
+ contentPosition: null,
175
+ contentDuration: null,
176
+ });
177
+ });
178
+
179
+ it("reports the player plugin and its web engine", async () => {
180
+ expect(
181
+ await state({
182
+ playerPluginId: "QuickBrickPlayerPlugin",
183
+ engine: "hls.js",
184
+ })
185
+ ).toMatchObject({ plugin: "QuickBrickPlayerPlugin", engine: "hls.js" });
186
+ });
187
+
188
+ it("lists the entry's DRM key systems, or null", async () => {
189
+ const drm = { widevine: { license_url: "w" }, playready: {} };
190
+
191
+ expect((await state({}, { id: "e", extensions: { drm } })).drm).toEqual([
192
+ "widevine",
193
+ "playready",
194
+ ]);
195
+
196
+ expect((await state({}, { id: "e", extensions: { drm: {} } })).drm).toBe(
197
+ null
198
+ );
199
+
200
+ expect((await state({}, { id: "e", extensions: {} })).drm).toBe(null);
201
+ expect((await state({}, null)).drm).toBe(null);
202
+ });
203
+
204
+ it("reports no last error as null", async () => {
205
+ expect((await state({ lastError: null })).lastError).toBeNull();
206
+ });
207
+ });
208
+
209
+ describe("play next", () => {
210
+ const nextEntry = { id: "n1", title: "Next", extensions: {} };
211
+
212
+ const stateWith = async (overrides) =>
213
+ (
214
+ await registeredTools(
215
+ player({
216
+ getEntry: () => null,
217
+ getPosition: () => 0,
218
+ getDuration: () => 0,
219
+ getContentDuration: () => 0,
220
+ isReadyToPlay: () => true,
221
+ isPlaying: () => true,
222
+ isBuffering: () => false,
223
+ isAd: () => false,
224
+ getIsMuted: () => false,
225
+ ...overrides,
226
+ })
227
+ )
228
+ ).state();
229
+
230
+ it("reports the entry Play Next would play", async () => {
231
+ expect(
232
+ (await stateWith({ getPlayNextEntry: () => nextEntry })).next
233
+ ).toEqual({ id: "n1", title: "Next" });
234
+ });
235
+
236
+ it("reports no next entry as null", async () => {
237
+ expect((await stateWith({})).next).toBeNull();
238
+ });
239
+
240
+ it("plays the next entry through the player's Play Next action", async () => {
241
+ const active = player({ getPlayNextEntry: () => nextEntry });
242
+ const tools = await registeredTools(active);
243
+
244
+ expect(tools.next()).toEqual({ id: "n1", title: "Next" });
245
+ expect(active.playNextHandler).toHaveBeenCalledTimes(1);
246
+ });
247
+
248
+ it("fails without a next entry", async () => {
249
+ const active = player();
250
+ const tools = await registeredTools(active);
251
+
252
+ expect(() => tools.next()).toThrow("No next item");
253
+ expect(active.playNextHandler).not.toHaveBeenCalled();
254
+ });
255
+
256
+ it("fails when no Play Next action is registered", async () => {
257
+ const tools = await registeredTools(
258
+ player({
259
+ getPlayNextEntry: () => nextEntry,
260
+ playNextHandler: null,
261
+ })
262
+ );
263
+
264
+ expect(() => tools.next()).toThrow("No next item");
265
+ });
266
+
267
+ it("fails without an active player", async () => {
268
+ const tools = await registeredTools(null);
269
+
270
+ expect(() => tools.next()).toThrow("No active player");
271
+ });
272
+ });
273
+
90
274
  it("refuses to seek a stream that is not seekable", async () => {
91
275
  const tools = await registeredTools(
92
276
  player({ getSeekableDuration: () => 0 })
@@ -76,6 +76,12 @@ export class Player {
76
76
  private overlaysObserver?: OverlaysObserver | null;
77
77
  private entryObservable?: BehaviorSubject<ZappEntry> | null;
78
78
  private contentSubject?: BehaviorSubject<PlayerContent> | null;
79
+ // Read by the X-Ray `player` toolbox: the last onError/onVideoError of the current entry,
80
+ // and the Play Next action of the PlayerContainer showing this player.
81
+ public lastError: unknown = null;
82
+ public playNextHandler: (() => void) | null = null;
83
+ // The web engine the default player picked (hls.js, dash.js, shaka, video.js); null elsewhere.
84
+ public engine: string | null = null;
79
85
 
80
86
  protected listeners: {
81
87
  [key: string]: QuickBrickPlayer.SharedPlayerCallBacks;
@@ -89,6 +95,7 @@ export class Player {
89
95
 
90
96
  set entry(newEntry: ZappEntry | null) {
91
97
  this._entry = newEntry;
98
+ this.lastError = null;
92
99
  // The live entry is scoped to the entry it overlays - see
93
100
  // `OverlaysObserver.clearLiveEntry`. No-op when there is no observer,
94
101
  // which is every non-live player.
@@ -271,6 +278,10 @@ export class Player {
271
278
 
272
279
  getEntry = (): ZappEntry | null => this.entry;
273
280
 
281
+ /** The entry Play Next would play now; null without one. Never creates the overlay observer. */
282
+ getPlayNextEntry = (): ZappEntry | null =>
283
+ this.overlaysObserver?.getPlayNextEntry() ?? null;
284
+
274
285
  logState = (text, additionalParams = {}) => {
275
286
  const isSeeking = this.playerState.seekPosition !== null;
276
287
 
@@ -492,6 +503,8 @@ export class Player {
492
503
  };
493
504
 
494
505
  onVideoError = (error: Error) => {
506
+ this.lastError = error;
507
+
495
508
  this.invokeListenersUpdate({
496
509
  callbackName: SharedPlayerCallBacksKeys.OnVideoError,
497
510
  event: error,
@@ -499,6 +512,8 @@ export class Player {
499
512
  };
500
513
 
501
514
  onError = (error: Error) => {
515
+ this.lastError = error;
516
+
502
517
  this.invokeListenersUpdate({
503
518
  callbackName: SharedPlayerCallBacksKeys.OnError,
504
519
  event: error,
@@ -14,6 +14,12 @@ const toNumber = (value: unknown) =>
14
14
 
15
15
  const clamp = (value: number, max: number) => Math.min(Math.max(value, 0), max);
16
16
 
17
+ /** Key systems the entry declares (widevine, playready, fairplay...). */
18
+ const drmKeySystems = (drm: unknown) =>
19
+ drm && typeof drm === "object" && Object.keys(drm).length
20
+ ? Object.keys(drm)
21
+ : null;
22
+
17
23
  /** The range a seek can reach: the DVR window on live, the content on VOD. */
18
24
  const requireSeekable = (player: {
19
25
  getSeekableDuration: () => number | undefined;
@@ -38,14 +44,21 @@ const tools = (manager: PlayerManager): XrayToolSpec[] => {
38
44
  {
39
45
  name: "state",
40
46
  description:
41
- "The active player: entry, position and duration in seconds, playing/buffering/ad/live/muted, " +
42
- "role (Primary, Cell, Chromecast) and casting. active: false when nothing plays.",
47
+ "The active player: entry, position and duration in seconds (the ad's while an ad plays), " +
48
+ "playing/buffering/ad/live/muted, role (Primary, Cell, Chromecast) and casting; in seconds, " +
49
+ "seekableDuration and contentPosition/contentDuration (the content's, even during ads); " +
50
+ "drm key systems, lastError {message} and next {id, title}, each null when absent; " +
51
+ "plugin (player plugin id) and engine (the default player's web engine, else null). " +
52
+ "active: false when nothing plays.",
43
53
  handler: () => {
44
54
  const player = manager.getActivePlayer();
45
55
 
46
56
  if (!player) return { active: false, casting: manager.isCasting() };
47
57
 
48
58
  const entry = player.getEntry();
59
+ const seekableDuration = toNumber(player.getSeekableDuration());
60
+ const next = player.getPlayNextEntry();
61
+ const error: any = player.lastError;
49
62
 
50
63
  return {
51
64
  active: true,
@@ -61,7 +74,18 @@ const tools = (manager: PlayerManager): XrayToolSpec[] => {
61
74
  live: !!player.isLive(),
62
75
  muted: !!player.getIsMuted(),
63
76
  role: player.playerRole,
77
+ plugin: player.playerPluginId ?? null,
78
+ engine: player.engine ?? null,
64
79
  casting: manager.isCasting(),
80
+ seekableDuration,
81
+ contentPosition: toNumber(player.getContentPosition()),
82
+ contentDuration: toNumber(player.getContentDuration()),
83
+ drm: drmKeySystems(entry?.extensions?.drm),
84
+ lastError:
85
+ error == null
86
+ ? null
87
+ : { message: String(error?.message ?? error?.error ?? error) },
88
+ next: next ? { id: next.id, title: next.title } : null,
65
89
  };
66
90
  },
67
91
  },
@@ -83,6 +107,21 @@ const tools = (manager: PlayerManager): XrayToolSpec[] => {
83
107
  return "OK";
84
108
  },
85
109
  },
110
+ {
111
+ name: "next",
112
+ description:
113
+ "Play the next item, as the Play Next overlay does. Fails when there is none.",
114
+ handler: () => {
115
+ const player = requirePlayer();
116
+ const next = player.getPlayNextEntry();
117
+
118
+ if (!next || !player.playNextHandler) throw new Error("No next item");
119
+
120
+ player.playNextHandler();
121
+
122
+ return { id: next.id, title: next.title };
123
+ },
124
+ },
86
125
  {
87
126
  name: "seek",
88
127
  description:
@@ -0,0 +1,28 @@
1
+ import { getButtonsEnabled } from "..";
2
+
3
+ describe("getButtonsEnabled", () => {
4
+ it("returns true when the buttons container is enabled", () => {
5
+ expect(
6
+ getButtonsEnabled({ mobile_buttons_container_buttons_enabled: true })
7
+ ).toBe(true);
8
+
9
+ expect(
10
+ getButtonsEnabled({ mobile_buttons_container_buttons_enabled: "true" })
11
+ ).toBe(true);
12
+ });
13
+
14
+ it("returns false when the buttons container is disabled", () => {
15
+ expect(
16
+ getButtonsEnabled({ mobile_buttons_container_buttons_enabled: false })
17
+ ).toBe(false);
18
+
19
+ expect(
20
+ getButtonsEnabled({ mobile_buttons_container_buttons_enabled: "false" })
21
+ ).toBe(false);
22
+ });
23
+
24
+ it("returns false when the key or styles are missing", () => {
25
+ expect(getButtonsEnabled({})).toBe(false);
26
+ expect(getButtonsEnabled(undefined)).toBe(false);
27
+ });
28
+ });
@@ -0,0 +1,36 @@
1
+ import { getCellNavigationEnabled } from "..";
2
+
3
+ describe("getCellNavigationEnabled", () => {
4
+ it("returns true when cell navigation is enabled", () => {
5
+ expect(
6
+ getCellNavigationEnabled({
7
+ mobile_buttons_container_cell_navigation_enabled: true,
8
+ })
9
+ ).toBe(true);
10
+
11
+ expect(
12
+ getCellNavigationEnabled({
13
+ mobile_buttons_container_cell_navigation_enabled: "true",
14
+ })
15
+ ).toBe(true);
16
+ });
17
+
18
+ it("returns false when cell navigation is disabled", () => {
19
+ expect(
20
+ getCellNavigationEnabled({
21
+ mobile_buttons_container_cell_navigation_enabled: false,
22
+ })
23
+ ).toBe(false);
24
+
25
+ expect(
26
+ getCellNavigationEnabled({
27
+ mobile_buttons_container_cell_navigation_enabled: "false",
28
+ })
29
+ ).toBe(false);
30
+ });
31
+
32
+ it("returns false when the key or styles are missing", () => {
33
+ expect(getCellNavigationEnabled({})).toBe(false);
34
+ expect(getCellNavigationEnabled(undefined)).toBe(false);
35
+ });
36
+ });
@@ -0,0 +1,35 @@
1
+ import { getIsCellNavigationEnabled } from "..";
2
+
3
+ describe("getIsCellNavigationEnabled", () => {
4
+ it("is disabled when buttons are enabled and cell navigation is off", () => {
5
+ expect(
6
+ getIsCellNavigationEnabled({
7
+ mobile_buttons_container_buttons_enabled: true,
8
+ mobile_buttons_container_cell_navigation_enabled: false,
9
+ })
10
+ ).toBe(false);
11
+ });
12
+
13
+ it("is enabled when buttons and cell navigation are both on", () => {
14
+ expect(
15
+ getIsCellNavigationEnabled({
16
+ mobile_buttons_container_buttons_enabled: true,
17
+ mobile_buttons_container_cell_navigation_enabled: true,
18
+ })
19
+ ).toBe(true);
20
+ });
21
+
22
+ it("is enabled when buttons are disabled", () => {
23
+ expect(
24
+ getIsCellNavigationEnabled({
25
+ mobile_buttons_container_buttons_enabled: false,
26
+ mobile_buttons_container_cell_navigation_enabled: false,
27
+ })
28
+ ).toBe(true);
29
+ });
30
+
31
+ it("is enabled when the keys or styles are missing", () => {
32
+ expect(getIsCellNavigationEnabled({})).toBe(true);
33
+ expect(getIsCellNavigationEnabled(undefined)).toBe(true);
34
+ });
35
+ });
@@ -0,0 +1,21 @@
1
+ import { isCellNavigationEnabled } from "..";
2
+
3
+ describe("isCellNavigationEnabled", () => {
4
+ it("is enabled when buttons are disabled, regardless of the toggle", () => {
5
+ expect(isCellNavigationEnabled(false, false)).toBe(true);
6
+ expect(isCellNavigationEnabled(false, true)).toBe(true);
7
+ expect(isCellNavigationEnabled(undefined, undefined)).toBe(true);
8
+ expect(isCellNavigationEnabled("false", false)).toBe(true);
9
+ });
10
+
11
+ it("is disabled when buttons are enabled and the toggle is off or missing", () => {
12
+ expect(isCellNavigationEnabled(true, false)).toBe(false);
13
+ expect(isCellNavigationEnabled(true, undefined)).toBe(false);
14
+ expect(isCellNavigationEnabled("true", "false")).toBe(false);
15
+ });
16
+
17
+ it("is enabled when buttons and the toggle are both enabled", () => {
18
+ expect(isCellNavigationEnabled(true, true)).toBe(true);
19
+ expect(isCellNavigationEnabled("true", "true")).toBe(true);
20
+ });
21
+ });
@@ -12,6 +12,7 @@ import { isWeb } from "@applicaster/zapp-react-native-utils/reactUtils";
12
12
  import { isNotNil } from "@applicaster/zapp-react-native-utils/reactUtils/helpers";
13
13
 
14
14
  import { toNumberWithDefault, toNumberWithDefaultZero } from "../numberUtils";
15
+ import { isTrue } from "../booleanUtils";
15
16
 
16
17
  const CUSTOM_KEY = "other";
17
18
 
@@ -572,3 +573,42 @@ export const hasTextLabelsEnabled = (
572
573
  return Boolean(value);
573
574
  });
574
575
  };
576
+
577
+ /**
578
+ * Reads the mobile buttons container "buttons enabled" switch from cell styles.
579
+ */
580
+ export const getButtonsEnabled = (styles?: Record<string, any>): boolean => {
581
+ return isTrue(styles?.mobile_buttons_container_buttons_enabled);
582
+ };
583
+
584
+ /**
585
+ * Reads the mobile buttons container "cell navigation enabled" switch from cell styles.
586
+ */
587
+ export const getCellNavigationEnabled = (
588
+ styles?: Record<string, any>
589
+ ): boolean => {
590
+ return isTrue(styles?.mobile_buttons_container_cell_navigation_enabled);
591
+ };
592
+
593
+ /**
594
+ * Whether tapping a cell outside its action buttons triggers the cell's navigation.
595
+ * Always true when the buttons are disabled.
596
+ * @param buttonsEnabled the cell's "buttons enabled" switch value
597
+ * @param cellNavigationEnabled the cell's "cell navigation enabled" switch value
598
+ */
599
+ export const isCellNavigationEnabled = (
600
+ buttonsEnabled: unknown,
601
+ cellNavigationEnabled: unknown
602
+ ): boolean => !isTrue(buttonsEnabled) || isTrue(cellNavigationEnabled);
603
+
604
+ /**
605
+ * Whether tapping a mobile cell outside its action buttons triggers the cell's navigation,
606
+ * read from the cell styles.
607
+ */
608
+ export const getIsCellNavigationEnabled = (
609
+ styles?: Record<string, any>
610
+ ): boolean =>
611
+ isCellNavigationEnabled(
612
+ getButtonsEnabled(styles),
613
+ getCellNavigationEnabled(styles)
614
+ );
@@ -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
 
@@ -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",
@@ -478,7 +484,7 @@ const MOBILE_ACTION_BUTTON_FIELDS = [
478
484
  { text: "share", value: "native_share_action" },
479
485
  { text: "downloads", value: "offline-content-button" },
480
486
  { text: "trailer", value: "trailer_action" },
481
- { text: "mute/unmute", value: "mute_unmute" },
487
+ { text: "mute/unmute", value: "mute_unmute_action" },
482
488
  ],
483
489
  },
484
490
  {
@@ -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
  );
@@ -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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applicaster/zapp-react-native-utils",
3
- "version": "16.0.0-alpha.8114431136",
3
+ "version": "16.0.0-alpha.8225069933",
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-alpha.8114431136",
30
+ "@applicaster/applicaster-types": "16.0.0-alpha.8225069933",
31
31
  "buffer": "^5.2.1",
32
32
  "camelize": "^1.0.0",
33
33
  "dayjs": "^1.11.10",
@@ -36,6 +36,7 @@
36
36
  "prop-types": "^15.0.0"
37
37
  },
38
38
  "peerDependencies": {
39
+ "@applicaster/quick-brick-assets": "*",
39
40
  "@applicaster/zapp-pipes-v2-client": "*",
40
41
  "@react-native-community/netinfo": "*",
41
42
  "react": "*",
@@ -0,0 +1,18 @@
1
+ // The Jest stand-in for @applicaster/quick-brick-assets maps each existing key to itself
2
+ import { getQuickBrickAsset } from "..";
3
+
4
+ describe("getQuickBrickAsset", () => {
5
+ it("returns the asset for a plugin identifier and path", () => {
6
+ expect(
7
+ getQuickBrickAsset("mute_unmute_action", "action/flavour_1/mute.png")
8
+ ).toBe("mute_unmute_action/action/flavour_1/mute.png");
9
+ });
10
+
11
+ it("rejects unknown paths and plugin identifiers at compile time", () => {
12
+ // @ts-expect-error - no such file in mute_unmute_action
13
+ getQuickBrickAsset("mute_unmute_action", "action/flavour_9/mute.png");
14
+
15
+ // @ts-expect-error - no assets folder for this plugin
16
+ getQuickBrickAsset("unknown_plugin", "action/flavour_1/mute.png");
17
+ });
18
+ });
@@ -0,0 +1,35 @@
1
+ import assets from "@applicaster/quick-brick-assets";
2
+ import type {
3
+ QuickBrickAssetKey,
4
+ QuickBrickAssetSource,
5
+ } from "@applicaster/quick-brick-assets";
6
+
7
+ export type { QuickBrickAssetKey, QuickBrickAssetSource };
8
+
9
+ type PluginOf<K> = K extends `${infer Plugin}/${string}` ? Plugin : never;
10
+
11
+ type PathOf<K, Plugin extends string> = K extends `${Plugin}/${infer Path}`
12
+ ? Path
13
+ : never;
14
+
15
+ /** Plugin identifiers that have a folder in `@applicaster/quick-brick-assets`. */
16
+ export type QuickBrickAssetPlugin = PluginOf<QuickBrickAssetKey>;
17
+
18
+ /** Asset paths inside the given plugin's folder. */
19
+ export type QuickBrickAssetPath<Plugin extends QuickBrickAssetPlugin> = PathOf<
20
+ QuickBrickAssetKey,
21
+ Plugin
22
+ >;
23
+
24
+ /**
25
+ * Looks up an asset of `@applicaster/quick-brick-assets` by plugin identifier
26
+ * and path relative to the plugin folder, e.g.
27
+ * `getQuickBrickAsset("mute_unmute_action", "action/flavour_1/mute.png")`.
28
+ * Both arguments are checked against the package's files at compile time.
29
+ * Density variants (`@2x` / `@3x`) are not supported.
30
+ */
31
+ export const getQuickBrickAsset = <Plugin extends QuickBrickAssetPlugin>(
32
+ pluginIdentifier: Plugin,
33
+ assetPath: QuickBrickAssetPath<Plugin>
34
+ ): QuickBrickAssetSource =>
35
+ assets[`${pluginIdentifier}/${assetPath}` as QuickBrickAssetKey];
@@ -0,0 +1,82 @@
1
+ import { renderHook } from "@testing-library/react-native";
2
+
3
+ const plugin = {
4
+ identifier: "cast",
5
+ configuration: {
6
+ title: "Configured",
7
+ localizations: { fr: { close: "Fermer" } },
8
+ },
9
+ };
10
+
11
+ jest.mock("@applicaster/zapp-react-native-redux/hooks", () => ({
12
+ usePlugins: () => [plugin],
13
+ }));
14
+
15
+ jest.mock(
16
+ "@applicaster/zapp-react-native-utils/appUtils/localizationsHelper",
17
+ () => ({ getUILanguage: () => "fr" })
18
+ );
19
+
20
+ import {
21
+ getPluginConfiguration,
22
+ mergeLocalizations,
23
+ usePluginConfiguration,
24
+ } from "../usePluginConfiguration";
25
+
26
+ const manifest = {
27
+ custom_configuration_fields: [
28
+ { key: "title", initial_value: "Default" },
29
+ { key: "color", initial_value: "red" },
30
+ ],
31
+ localizations: {
32
+ fields: [
33
+ { key: "close", initial_value: "Close" },
34
+ { key: "cancel", initial_value: "Cancel" },
35
+ ],
36
+ },
37
+ };
38
+
39
+ it("matches usePluginConfiguration outside React", () => {
40
+ const { result } = renderHook(() => usePluginConfiguration("cast", manifest));
41
+
42
+ expect(getPluginConfiguration(plugin, manifest)).toEqual(result.current);
43
+ });
44
+
45
+ // Expected values are what R.mergeDeepWith((l, r) => (isNil(l) ? r : l)) returned before ramda was removed
46
+ describe("mergeLocalizations", () => {
47
+ const defaults = { en: { close: "Close", cancel: "Cancel" } };
48
+
49
+ it("returns the defaults when nothing is configured", () => {
50
+ expect(mergeLocalizations(undefined, defaults)).toEqual(defaults);
51
+ });
52
+
53
+ it("keeps configured values, fills nil ones and missing keys from defaults", () => {
54
+ expect(
55
+ mergeLocalizations(
56
+ { en: { close: null, extra: "X" }, fr: { close: "Fermer" } },
57
+ defaults
58
+ )
59
+ ).toEqual({
60
+ en: { close: "Close", extra: "X", cancel: "Cancel" },
61
+ fr: { close: "Fermer" },
62
+ });
63
+ });
64
+
65
+ it("does not merge arrays", () => {
66
+ expect(
67
+ mergeLocalizations(
68
+ { en: { close: ["a"] } },
69
+ { en: { close: ["b"], cancel: "C" } }
70
+ )
71
+ ).toEqual({ en: { close: ["a"], cancel: "C" } });
72
+ });
73
+ });
74
+
75
+ it("keeps configured languages first, so the fallback language stays the configured one", () => {
76
+ const merged = mergeLocalizations(
77
+ { he: { close: "סגור" }, en: { close: "Close it" } },
78
+ { en: { close: "Close" } }
79
+ );
80
+
81
+ expect(Object.keys(merged)).toEqual(["he", "en"]);
82
+ });
@@ -21,17 +21,45 @@ type ActionContexts<T = unknown> = {
21
21
  };
22
22
 
23
23
  /**
24
- * React Hook to subscribe to the custom action context
24
+ * A value that changes whenever a provider is added to or removed from
25
+ * `uiActionsRegistry`.
26
+ *
27
+ * Providers can register after the UI that wants them has rendered - the audio
28
+ * player registers playback speed only once its controller has state, and
29
+ * plugin-backed actions are bridged in from an effect. Put this in the
30
+ * dependency list of anything that resolves actions, so the list is re-read
31
+ * instead of being frozen at first paint.
32
+ */
33
+ export function useRegistryRevision(): number {
34
+ const [revision, setRevision] = useState(0);
35
+
36
+ useEffect(
37
+ () =>
38
+ uiActionsRegistry.subscribe(() => setRevision((current) => current + 1)),
39
+ []
40
+ );
41
+
42
+ return revision;
43
+ }
44
+
45
+ /**
46
+ * Resolves a legacy action context or falls back to the UI action registry.
25
47
  * @param {*} plugId - subscribes to the context of that action plugin
26
48
  * @return {*} - global action provider context or context for specific action plugin
27
49
  */
28
50
  export function useActions<T = unknown>(plugId: string): any {
29
51
  const [logged, setLogged] = useState(false);
30
52
  const context = useContext<ActionContexts<T>>(ActionsContext);
53
+ useRegistryRevision();
54
+
31
55
  const actionContext = context?.actions?.[plugId]?.module?.context;
32
56
 
33
57
  if (plugId) {
34
58
  if (!actionContext) {
59
+ const registeredAction = uiActionsRegistry.getAction(plugId);
60
+
61
+ if (registeredAction) return registeredAction;
62
+
35
63
  if (!logged) {
36
64
  logger.warning({
37
65
  message: `useActions: Couldn't find an action for ${plugId} plugin`,
@@ -139,25 +167,3 @@ export function useEntryActionState(
139
167
 
140
168
  return { state, invokeAction };
141
169
  }
142
-
143
- /**
144
- * A value that changes whenever a provider is added to or removed from
145
- * `uiActionsRegistry`.
146
- *
147
- * Providers can register after the UI that wants them has rendered - the audio
148
- * player registers playback speed only once its controller has state, and
149
- * plugin-backed actions are bridged in from an effect. Put this in the
150
- * dependency list of anything that resolves actions, so the list is re-read
151
- * instead of being frozen at first paint.
152
- */
153
- export function useRegistryRevision(): number {
154
- const [revision, setRevision] = useState(0);
155
-
156
- useEffect(
157
- () =>
158
- uiActionsRegistry.subscribe(() => setRevision((current) => current + 1)),
159
- []
160
- );
161
-
162
- return revision;
163
- }
@@ -1,4 +1,3 @@
1
- import * as R from "ramda";
2
1
  import { useMemo } from "react";
3
2
 
4
3
  import { usePlugins } from "@applicaster/zapp-react-native-redux/hooks";
@@ -6,7 +5,8 @@ import { usePlugins } from "@applicaster/zapp-react-native-redux/hooks";
6
5
  // importing this way prevents typescript packages importing from this
7
6
  // file to go crazy reporting errors on JS
8
7
  import { populateConfigurationValues } from "@applicaster/zapp-react-native-utils/configurationUtils";
9
- import { useLocalizedStrings } from "../localizationUtils";
8
+ import { getLocalizedStrings, useLocalizedStrings } from "../localizationUtils";
9
+ import { isNil, pathOr } from "../utils";
10
10
 
11
11
  const populateFields = (
12
12
  data,
@@ -14,16 +14,72 @@ const populateFields = (
14
14
  pathToValues: string | string[] = "configuration",
15
15
  skipDefaults = false
16
16
  ) =>
17
- R.compose(
18
- (config) =>
19
- populateConfigurationValues(manifestFields, config, skipDefaults),
20
- R.pathOr({}, R.unless(Array.isArray, Array)(pathToValues))
21
- )(data);
17
+ populateConfigurationValues(
18
+ manifestFields,
19
+ pathOr({}, pathToValues, data),
20
+ skipDefaults
21
+ );
22
22
 
23
23
  export function parsePluginConfiguration(plugin, manifest) {
24
24
  return populateFields(plugin, manifest.custom_configuration_fields);
25
25
  }
26
26
 
27
+ const getDefaultLocalizations = (manifest) =>
28
+ manifest?.localizations?.fields?.reduce(
29
+ (defaults, { key, initial_value }) => ({
30
+ en: { ...defaults.en, [key]: initial_value },
31
+ }),
32
+ { en: {} }
33
+ ) || {};
34
+
35
+ const isPlainObject = (value) =>
36
+ Object.prototype.toString.call(value) === "[object Object]";
37
+
38
+ // Deep merge where configured values win, except nil ones (defaults fill the gaps)
39
+ export const mergeLocalizations = (configured, defaults) => {
40
+ if (!isPlainObject(configured)) return { ...defaults };
41
+ if (!isPlainObject(defaults)) return { ...configured };
42
+
43
+ // Configured keys first, as R.mergeDeepWith: the first language is the fallback
44
+ return Object.keys({ ...configured, ...defaults }).reduce((merged, key) => {
45
+ const left = configured[key];
46
+ const right = defaults[key];
47
+
48
+ if (!(key in defaults)) {
49
+ merged[key] = left;
50
+ } else if (!(key in configured)) {
51
+ merged[key] = right;
52
+ } else if (isPlainObject(left) && isPlainObject(right)) {
53
+ merged[key] = mergeLocalizations(left, right);
54
+ } else {
55
+ merged[key] = isNil(left) ? right : left;
56
+ }
57
+
58
+ return merged;
59
+ }, {});
60
+ };
61
+
62
+ /**
63
+ * usePluginConfiguration outside React: the same result, read once instead of subscribed.
64
+ */
65
+ export function getPluginConfiguration<T = any, S = Record<string, any>>(
66
+ plugin,
67
+ manifest?: any
68
+ ): { configurationFields: T; localizations: S } {
69
+ return {
70
+ configurationFields: populateFields(
71
+ plugin,
72
+ manifest?.custom_configuration_fields
73
+ ),
74
+ localizations: getLocalizedStrings(
75
+ mergeLocalizations(
76
+ plugin?.configuration?.localizations,
77
+ getDefaultLocalizations(manifest)
78
+ )
79
+ ),
80
+ };
81
+ }
82
+
27
83
  export function usePluginConfiguration<T = any, S = Record<string, any>>(
28
84
  identifier: string,
29
85
  manifest?: any
@@ -31,7 +87,7 @@ export function usePluginConfiguration<T = any, S = Record<string, any>>(
31
87
  const plugins = usePlugins();
32
88
 
33
89
  const plugin = useMemo(
34
- () => R.find(R.propEq("identifier", identifier), plugins),
90
+ () => plugins?.find((item) => item?.identifier === identifier),
35
91
  [plugins, identifier]
36
92
  );
37
93
 
@@ -40,29 +96,20 @@ export function usePluginConfiguration<T = any, S = Record<string, any>>(
40
96
  [plugin, manifest]
41
97
  );
42
98
 
43
- const defaultLocalizations = useMemo(() => {
44
- return (
45
- manifest?.localizations?.fields?.reduce(
46
- (defaults, { key, initial_value }) =>
47
- R.assocPath(["en", key], initial_value, defaults),
48
- { en: {} }
49
- ) || {}
50
- );
51
- }, [manifest]);
52
-
53
- const localizationsParams = useMemo(() => {
54
- return {
55
- localizations: R.mergeDeepWith(
56
- (left, right) => {
57
- if (R.isNil(left)) return right;
99
+ const defaultLocalizations = useMemo(
100
+ () => getDefaultLocalizations(manifest),
101
+ [manifest]
102
+ );
58
103
 
59
- return left;
60
- },
104
+ const localizationsParams = useMemo(
105
+ () => ({
106
+ localizations: mergeLocalizations(
61
107
  plugin?.configuration?.localizations,
62
108
  defaultLocalizations
63
109
  ),
64
- };
65
- }, [plugin?.configuration?.localizations, defaultLocalizations]);
110
+ }),
111
+ [plugin?.configuration?.localizations, defaultLocalizations]
112
+ );
66
113
 
67
114
  const localizations: S = useLocalizedStrings(localizationsParams);
68
115
 
@@ -105,29 +152,20 @@ export function useLayoutConfiguration<
105
152
  "localizations",
106
153
  ];
107
154
 
108
- const defaultLocalizations = useMemo(() => {
109
- return (
110
- manifest?.localizations?.fields?.reduce(
111
- (defaults, { key, initial_value }) =>
112
- R.assocPath(["en", key], initial_value, defaults),
113
- { en: {} }
114
- ) || {}
115
- );
116
- }, [manifest]);
117
-
118
- const localizationsParams = useMemo(() => {
119
- return {
120
- localizations: R.mergeDeepWith(
121
- (left, right) => {
122
- if (R.isNil(left)) return right;
155
+ const defaultLocalizations = useMemo(
156
+ () => getDefaultLocalizations(manifest),
157
+ [manifest]
158
+ );
123
159
 
124
- return left;
125
- },
160
+ const localizationsParams = useMemo(
161
+ () => ({
162
+ localizations: mergeLocalizations(
126
163
  layoutData?.localizations,
127
164
  defaultLocalizations
128
165
  ),
129
- };
130
- }, [layoutData?.localizations, defaultLocalizations]);
166
+ }),
167
+ [layoutData?.localizations, defaultLocalizations]
168
+ );
131
169
 
132
170
  // Always call useLocalizedStrings regardless of whether localizations is in the keys
133
171
  const localizations: L = useLocalizedStrings(localizationsParams);