@applicaster/zapp-react-native-ui-components 16.0.0-rc.121 → 16.0.0-rc.123

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.
@@ -96,6 +96,14 @@ export const ActionButton = React.memo(function ActionButtonComponent(
96
96
  }, [item?.id, setActionState, actionContext]);
97
97
 
98
98
  if (actionDisabled || !actionContext) return null;
99
+
100
+ if (
101
+ actionState?.visibleInCells &&
102
+ !actionState.visibleInCells.includes(cellUUID)
103
+ ) {
104
+ return null;
105
+ }
106
+
99
107
  const AssetComponent = actionState?.asset;
100
108
 
101
109
  // Default state is not yet ready
@@ -109,6 +117,11 @@ export const ActionButton = React.memo(function ActionButtonComponent(
109
117
  accessibilityLabel={props?.accessibilityLabel || `${item?.id}`}
110
118
  accessibilityHint={props?.accessibilityHint}
111
119
  accessibilityRole="button"
120
+ accessibilityState={
121
+ typeof actionState.checked === "boolean"
122
+ ? { checked: actionState.checked }
123
+ : undefined
124
+ }
112
125
  accessible={!!(props?.testID || props?.accessibilityLabel)}
113
126
  style={props?.style}
114
127
  >
@@ -11,6 +11,7 @@ type Props = {
11
11
  accessibilityHint?: string;
12
12
  onPress?: () => void;
13
13
  testOnly_pressed?: boolean;
14
+ checked?: boolean;
14
15
  };
15
16
 
16
17
  export function PressableView({
@@ -22,11 +23,13 @@ export function PressableView({
22
23
  pressedStyle,
23
24
  onPress,
24
25
  testOnly_pressed = false,
26
+ checked,
25
27
  }: Props) {
26
28
  return (
27
29
  <Pressable
28
30
  testOnly_pressed={testOnly_pressed}
29
31
  onPress={onPress}
32
+ accessibilityState={checked === undefined ? undefined : { checked }}
30
33
  testID={testID}
31
34
  accessibilityLabel={accessibilityLabel}
32
35
  accessibilityHint={accessibilityHint}
@@ -0,0 +1,288 @@
1
+ import React from "react";
2
+ import { act, fireEvent, render } from "@testing-library/react-native";
3
+
4
+ import { ActionButton } from "../ActionButton";
5
+ import { ActionButton as MobileActionButton } from "../mobile/MobileActionButtons/ActionButton";
6
+ import { Text } from "react-native";
7
+
8
+ type EntryStateListener = (state: CellActionEntryState) => void;
9
+
10
+ const PLUGIN_ASSETS = {
11
+ mute: ["plugin_mute_1", "plugin_mute_2"],
12
+ unmute: ["plugin_unmute_1", "plugin_unmute_2"],
13
+ };
14
+
15
+ // Fake of the mute_unmute_action context, following its entry state contract.
16
+ type FakeState = {
17
+ available: boolean;
18
+ muted: boolean;
19
+ visibleInCells?: string[];
20
+ };
21
+
22
+ const createFakeContext = (initial: FakeState) => {
23
+ let current = initial;
24
+ const listeners = new Set<EntryStateListener>();
25
+
26
+ const toEntryState = ({
27
+ available,
28
+ muted,
29
+ visibleInCells,
30
+ }: FakeState): CellActionEntryState => ({
31
+ state: muted ? 1 : 0,
32
+ asset: available
33
+ ? muted
34
+ ? PLUGIN_ASSETS.unmute
35
+ : PLUGIN_ASSETS.mute
36
+ : undefined,
37
+ label: muted ? "Unmute" : "Mute",
38
+ mobileButtonAssets: muted ? PLUGIN_ASSETS.unmute : PLUGIN_ASSETS.mute,
39
+ visibleInCells,
40
+ });
41
+
42
+ const emit = (next: FakeState) => {
43
+ current = next;
44
+ listeners.forEach((listener) => listener(toEntryState(current)));
45
+ };
46
+
47
+ const context = {
48
+ initialEntryState: jest.fn(() => toEntryState(current)),
49
+ addListener: jest.fn((_id: string, listener: EntryStateListener) => {
50
+ listeners.add(listener);
51
+
52
+ return () => listeners.delete(listener);
53
+ }),
54
+ invokeAction: jest.fn((_item, options) => {
55
+ if (!current.available) return;
56
+
57
+ current = { ...current, muted: !current.muted };
58
+ options?.updateState?.(toEntryState(current));
59
+ listeners.forEach((listener) => listener(toEntryState(current)));
60
+ }),
61
+ };
62
+
63
+ return { context, emit, listeners };
64
+ };
65
+
66
+ let mockContext: ReturnType<typeof createFakeContext>["context"];
67
+
68
+ jest.mock("@applicaster/zapp-react-native-utils/reactHooks/actions", () => ({
69
+ useActions: jest.fn(() => mockContext),
70
+ }));
71
+
72
+ jest.mock(
73
+ "@applicaster/zapp-react-native-ui-components/Contexts/UIComponentContext",
74
+ () => ({ useUIComponentContext: jest.fn(() => undefined) })
75
+ );
76
+
77
+ jest.mock("../Image", () => {
78
+ const { Text: MockText } = require("react-native");
79
+
80
+ return {
81
+ __esModule: true,
82
+ default: ({ uri }) => <MockText testID="asset">{uri}</MockText>,
83
+ };
84
+ });
85
+
86
+ const item = { id: "entry-1" } as unknown as ZappEntry;
87
+
88
+ it("keeps the standalone button visible and limits actions to the playing cell instance", () => {
89
+ const fake = createFakeContext({
90
+ available: true,
91
+ muted: true,
92
+ visibleInCells: [],
93
+ });
94
+
95
+ mockContext = fake.context;
96
+
97
+ const Label = ({
98
+ children,
99
+ testID,
100
+ }: {
101
+ children?: React.ReactNode;
102
+ testID?: string;
103
+ mobileActionRole?: string;
104
+ }) => <Text testID={testID}>{children}</Text>;
105
+
106
+ const { getByTestId, queryByTestId } = render(
107
+ <>
108
+ {["first", "second"].map((cellUUID) => (
109
+ <ActionButton
110
+ key={cellUUID}
111
+ item={item}
112
+ action={{ identifier: "mute_unmute_action" }}
113
+ cellUUID={cellUUID}
114
+ testID={`cell-${cellUUID}`}
115
+ style={{}}
116
+ />
117
+ ))}
118
+ <MobileActionButton
119
+ action={{ identifier: "mute_unmute_action" }}
120
+ entry={item}
121
+ testID="standalone"
122
+ style={{}}
123
+ focusedStyles={{}}
124
+ >
125
+ <Label mobileActionRole="label">Toggle sound</Label>
126
+ </MobileActionButton>
127
+ </>
128
+ );
129
+
130
+ expect(queryByTestId("cell-first")).toBeNull();
131
+ expect(queryByTestId("cell-second")).toBeNull();
132
+ expect(getByTestId("standalone")).toBeTruthy();
133
+ fireEvent.press(getByTestId("standalone"));
134
+ expect(fake.context.invokeAction).toHaveBeenCalledTimes(1);
135
+
136
+ act(() =>
137
+ fake.emit({ available: true, muted: false, visibleInCells: ["first"] })
138
+ );
139
+
140
+ expect(getByTestId("cell-first")).toBeTruthy();
141
+ expect(queryByTestId("cell-second")).toBeNull();
142
+
143
+ act(() =>
144
+ fake.emit({ available: true, muted: false, visibleInCells: ["second"] })
145
+ );
146
+
147
+ expect(queryByTestId("cell-first")).toBeNull();
148
+ expect(getByTestId("cell-second")).toBeTruthy();
149
+
150
+ act(() => fake.emit({ available: true, muted: false, visibleInCells: [] }));
151
+
152
+ expect(queryByTestId("cell-first")).toBeNull();
153
+ expect(queryByTestId("cell-second")).toBeNull();
154
+ expect(getByTestId("standalone")).toBeTruthy();
155
+ });
156
+
157
+ const renderButton = (
158
+ flavour: "flavour_1" | "flavour_2",
159
+ asset?: React.ComponentProps<typeof ActionButton>["asset"]
160
+ ) =>
161
+ render(
162
+ <ActionButton
163
+ item={item}
164
+ flavour={flavour}
165
+ action={{ identifier: "mute_unmute_action" }}
166
+ asset={asset}
167
+ style={{}}
168
+ testID="mute-button"
169
+ />
170
+ );
171
+
172
+ const cellAsset = {
173
+ props: {},
174
+ src: { active: "cell_selected", inactive: "cell_unselected" },
175
+ style: {},
176
+ type: "Image",
177
+ } as const;
178
+
179
+ describe.each([
180
+ ["flavour_1", 0],
181
+ ["flavour_2", 1],
182
+ ] as const)("mute_unmute_action cell action (%s)", (flavour, index) => {
183
+ let fake: ReturnType<typeof createFakeContext>;
184
+
185
+ const setup = (state: { available: boolean; muted: boolean }, asset?) => {
186
+ fake = createFakeContext(state);
187
+ mockContext = fake.context;
188
+
189
+ return renderButton(flavour, asset);
190
+ };
191
+
192
+ it("renders nothing while the preview has not started", () => {
193
+ const { queryByTestId } = setup({ available: false, muted: false });
194
+
195
+ expect(queryByTestId("mute-button")).toBeNull();
196
+ expect(queryByTestId("asset")).toBeNull();
197
+ });
198
+
199
+ it("shows the Mute icon while the preview plays with sound on", () => {
200
+ const { getByTestId } = setup({ available: true, muted: false });
201
+
202
+ expect(getByTestId("asset").props.children).toBe(PLUGIN_ASSETS.mute[index]);
203
+ });
204
+
205
+ it("shows the Unmute icon while the preview plays muted", () => {
206
+ const { getByTestId } = setup({ available: true, muted: true });
207
+
208
+ expect(getByTestId("asset").props.children).toBe(
209
+ PLUGIN_ASSETS.unmute[index]
210
+ );
211
+ });
212
+
213
+ it("toggles the sound and updates the icon on tap", () => {
214
+ const { getByTestId } = setup({ available: true, muted: false });
215
+
216
+ fireEvent.press(getByTestId("mute-button"));
217
+
218
+ expect(fake.context.invokeAction).toHaveBeenCalledTimes(1);
219
+
220
+ expect(getByTestId("asset").props.children).toBe(
221
+ PLUGIN_ASSETS.unmute[index]
222
+ );
223
+
224
+ fireEvent.press(getByTestId("mute-button"));
225
+
226
+ expect(getByTestId("asset").props.children).toBe(PLUGIN_ASSETS.mute[index]);
227
+ });
228
+
229
+ it("follows state emitted through addListener", () => {
230
+ const { getByTestId, queryByTestId } = setup({
231
+ available: false,
232
+ muted: false,
233
+ });
234
+
235
+ act(() => fake.emit({ available: true, muted: false }));
236
+
237
+ expect(getByTestId("asset").props.children).toBe(PLUGIN_ASSETS.mute[index]);
238
+
239
+ act(() => fake.emit({ available: true, muted: true }));
240
+
241
+ expect(getByTestId("asset").props.children).toBe(
242
+ PLUGIN_ASSETS.unmute[index]
243
+ );
244
+
245
+ expect(queryByTestId("mute-button")).not.toBeNull();
246
+ });
247
+
248
+ it("hides again when the preview stops or another cell starts", () => {
249
+ const { queryByTestId } = setup({ available: true, muted: false });
250
+
251
+ expect(queryByTestId("mute-button")).not.toBeNull();
252
+
253
+ act(() => fake.emit({ available: false, muted: false }));
254
+
255
+ expect(queryByTestId("mute-button")).toBeNull();
256
+ });
257
+
258
+ it("unsubscribes from the context on unmount", () => {
259
+ const { unmount } = setup({ available: true, muted: false });
260
+
261
+ expect(fake.listeners.size).toBe(1);
262
+
263
+ unmount();
264
+
265
+ expect(fake.listeners.size).toBe(0);
266
+ });
267
+
268
+ it("prefers the cell's selected icon when muted (state 1)", () => {
269
+ const { getByTestId } = setup({ available: true, muted: true }, cellAsset);
270
+
271
+ expect(getByTestId("asset").props.children).toBe("cell_selected");
272
+ });
273
+
274
+ it("prefers the cell's unselected icon when sound is on (state 0)", () => {
275
+ const { getByTestId } = setup({ available: true, muted: false }, cellAsset);
276
+
277
+ expect(getByTestId("asset").props.children).toBe("cell_unselected");
278
+ });
279
+
280
+ it("uses the plugin asset when the cell has no icons", () => {
281
+ const { getByTestId } = setup({ available: true, muted: false }, {
282
+ ...cellAsset,
283
+ src: { active: undefined, inactive: undefined },
284
+ } as any);
285
+
286
+ expect(getByTestId("asset").props.children).toBe(PLUGIN_ASSETS.mute[index]);
287
+ });
288
+ });
@@ -169,6 +169,7 @@ export const ActionButton = ({
169
169
  style={[style, props.alignment]}
170
170
  pressedStyle={focusedStyles}
171
171
  onPress={renderProps.onPress}
172
+ checked={renderProps.actionState?.checked}
172
173
  accessibilityLabel={props.accessibilityLabel || `${entry?.id}`}
173
174
  accessibilityHint={props.accessibilityHint}
174
175
  >
@@ -273,7 +273,10 @@ const PlayerContainerComponent = (props: Props) => {
273
273
  const playEntry = (entry: ZappEntry) => navigator.replaceTop(entry, { mode });
274
274
 
275
275
  const onPlayNextPerformNextVideoPlay = React.useCallback(() => {
276
- if (!playNextOverlayState.entry) {
276
+ // The overlay's entry, or the player's when the overlay is not showing yet (X-Ray player.next)
277
+ const nextEntry = playNextOverlayState?.entry ?? player?.getPlayNextEntry();
278
+
279
+ if (!nextEntry) {
277
280
  log_warning(
278
281
  "onPlayNextPerformNextVideoPlay: playNextOverlayState.entry is not defined"
279
282
  );
@@ -282,16 +285,30 @@ const PlayerContainerComponent = (props: Props) => {
282
285
  }
283
286
 
284
287
  log_debug(
285
- `onPlayNextPerformNextVideoPlay: delay completed, replaceTop to item: ${playNextOverlayState.entry.title}, id: ${playNextOverlayState.entry.id}, outgoing item id: ${item?.id}`
288
+ `onPlayNextPerformNextVideoPlay: delay completed, replaceTop to item: ${nextEntry.title}, id: ${nextEntry.id}, outgoing item id: ${item?.id}`
286
289
  );
287
290
 
288
- playNextOverlayState.hidePlayNext();
291
+ (
292
+ playNextOverlayState?.hidePlayNext ??
293
+ player?.getOverlayObservable()?.hidePlayNext
294
+ )?.();
289
295
 
290
296
  playerEvent(PLAYER_EVENTS.PlayNextTriggered, { id: item?.id });
291
297
 
292
298
  setIsLoadingNextVideo(true);
293
- playEntry(playNextOverlayState.entry);
294
- }, [playNextOverlayState, item?.id]);
299
+ playEntry(nextEntry);
300
+ }, [playNextOverlayState, item?.id, player]);
301
+
302
+ // Lets the X-Ray player.next tool run the same Play Next path as the overlay.
303
+ useEffect(() => {
304
+ if (!player) return;
305
+
306
+ player.playNextHandler = onPlayNextPerformNextVideoPlay;
307
+
308
+ return () => {
309
+ player.playNextHandler = null;
310
+ };
311
+ }, [player, onPlayNextPerformNextVideoPlay]);
295
312
 
296
313
  const playNextData: Nullable<PlayNextData> = React.useMemo(() => {
297
314
  if (!playNextOverlayState) {
@@ -127,6 +127,8 @@ jest.mock(
127
127
  seekTo: jest.fn(),
128
128
  getPluginConfiguration: jest.fn(() => ({})),
129
129
  getOverlayObservable: jest.fn(() => ({ getPlayNextEntry: () => null })),
130
+ getPlayNextEntry: jest.fn(() => null),
131
+ playNextHandler: null,
130
132
  };
131
133
 
132
134
  return {
@@ -335,3 +337,52 @@ describe("PlayerContainer hardware back handling", () => {
335
337
  expect(mockNavigator.closeVideoModal).not.toHaveBeenCalled();
336
338
  });
337
339
  });
340
+
341
+ describe("PlayerContainer Play Next action for X-Ray player.next", () => {
342
+ beforeEach(() => {
343
+ jest.clearAllMocks();
344
+ mockIsTV.mockReturnValue(false);
345
+ mockPlayer.getPluginConfiguration.mockReturnValue({});
346
+ mockPlayerManager.getPluginConfiguration.mockReturnValue({});
347
+ mockPlayerManager.on.mockReturnThis();
348
+ mockPlayerManager.removeHandler.mockReturnThis();
349
+ });
350
+
351
+ it("sets its Play Next action on the player and clears it on unmount", () => {
352
+ const { unmount } = renderPlayerContainer();
353
+
354
+ expect(mockPlayer.playNextHandler).toEqual(expect.any(Function));
355
+
356
+ unmount();
357
+
358
+ expect(mockPlayer.playNextHandler).toBeNull();
359
+ });
360
+
361
+ it("plays the player's next entry once, with analytics and the overlay hidden, when the overlay is not showing", () => {
362
+ const next = { id: "entry-2", title: "Next" };
363
+ const hidePlayNext = jest.fn();
364
+ mockPlayer.getPlayNextEntry.mockReturnValue(next);
365
+
366
+ mockPlayer.getOverlayObservable.mockReturnValue({
367
+ getPlayNextEntry: () => next,
368
+ hidePlayNext,
369
+ });
370
+
371
+ renderPlayerContainer();
372
+
373
+ mockPlayer.playNextHandler();
374
+
375
+ expect(hidePlayNext).toHaveBeenCalledTimes(1);
376
+
377
+ expect(mockPlayerManager.invokeHandler).toHaveBeenCalledWith(
378
+ "play_next_triggered",
379
+ { id: "entry-1" }
380
+ );
381
+
382
+ expect(mockNavigator.replaceTop).toHaveBeenCalledTimes(1);
383
+
384
+ expect(mockNavigator.replaceTop).toHaveBeenCalledWith(next, {
385
+ mode: VideoModalMode.MAXIMIZED,
386
+ });
387
+ });
388
+ });