@applicaster/zapp-react-native-ui-components 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.
- package/Components/MasterCell/DefaultComponents/ActionButton.tsx +13 -0
- package/Components/MasterCell/DefaultComponents/LiveImage/index.tsx +12 -3
- package/Components/MasterCell/DefaultComponents/PressableView.tsx +3 -0
- package/Components/MasterCell/DefaultComponents/__tests__/ActionButton.test.tsx +288 -0
- package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/ActionButton.tsx +2 -1
- package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/__tests__/PressableView.test.tsx +4 -2
- package/Components/MasterCell/__tests__/masterCell.test.js +60 -0
- package/Components/MasterCell/index.tsx +28 -4
- package/Components/MasterCell/utils/__tests__/withoutFocusedState.test.js +20 -0
- package/Components/MasterCell/utils/index.ts +18 -0
- package/Components/PlayerContainer/PlayerContainer.tsx +22 -5
- package/Components/PlayerContainer/__tests__/PlayerContainer.test.tsx +51 -0
- package/Components/VideoLive/LiveImageManager.ts +126 -12
- package/Components/VideoLive/MuteUnmuteController.ts +135 -0
- package/Components/VideoLive/PlayerLiveImageComponent.tsx +15 -19
- package/Components/VideoLive/__tests__/LiveImageMuteState.test.ts +311 -0
- package/Components/VideoLive/__tests__/MuteUnmuteController.test.ts +135 -0
- package/Components/VideoLive/__tests__/PlayerLiveImageComponent.test.tsx +63 -1
- package/Components/VideoModal/PlayerWrapper.tsx +4 -1
- package/Components/VideoModal/__tests__/PlayerWrapper.test.tsx +1 -0
- package/Components/VideoModal/__tests__/playerWrapperStyle.test.ts +58 -0
- package/Components/VideoModal/__tests__/playerWrapperUtils.test.ts +16 -0
- package/Components/VideoModal/playerWrapperStyle.ts +21 -3
- package/Components/VideoModal/playerWrapperUtils.ts +7 -1
- package/package.json +5 -5
|
@@ -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
|
>
|
|
@@ -245,10 +245,19 @@ const LiveImageComponent = (props: LiveImageProps) => {
|
|
|
245
245
|
|
|
246
246
|
const isCurrentlyFocused = useTrackCurrentAutoScrollingElement(componentId);
|
|
247
247
|
|
|
248
|
-
|
|
249
|
-
|
|
248
|
+
// New object identities here restart PlayerLiveImage's register effect,
|
|
249
|
+
// which throws away the in-flight player and runs preload hooks again.
|
|
250
|
+
const playerConfig = React.useMemo(
|
|
251
|
+
() => getPlayerConfig(player_screen_id, actionIdentifier),
|
|
252
|
+
[player_screen_id, actionIdentifier]
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
const { playerPluginId, screenConfig, preloadHooks } = playerConfig ?? {};
|
|
250
256
|
|
|
251
|
-
const playableEntry =
|
|
257
|
+
const playableEntry = React.useMemo(
|
|
258
|
+
() => (playerPluginId ? getPlayableEntry(item) : null),
|
|
259
|
+
[playerPluginId, item?.id]
|
|
260
|
+
);
|
|
252
261
|
|
|
253
262
|
const playerPlugin = getPluginByIdentifier(playerPluginId);
|
|
254
263
|
|
|
@@ -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
|
+
});
|
|
@@ -166,9 +166,10 @@ export const ActionButton = ({
|
|
|
166
166
|
<PressableView
|
|
167
167
|
testOnly_pressed={props.testOnly_pressed}
|
|
168
168
|
testID={props.testID || `${entry?.id}`}
|
|
169
|
-
style={style}
|
|
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
|
>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { Pressable, Text as RNText, View } from "react-native";
|
|
2
|
+
import { Pressable, StyleSheet, Text as RNText, View } from "react-native";
|
|
3
3
|
import { render, fireEvent } from "@testing-library/react-native";
|
|
4
4
|
import { useActions } from "@applicaster/zapp-react-native-utils/reactHooks/actions";
|
|
5
5
|
|
|
@@ -282,7 +282,9 @@ describe("MobileActionButton", () => {
|
|
|
282
282
|
expect(pressable.props.testID).toBe("mobile_action_button_1");
|
|
283
283
|
expect(pressable.props.accessibilityLabel).toBe("entry-1");
|
|
284
284
|
|
|
285
|
-
expect(
|
|
285
|
+
expect(
|
|
286
|
+
StyleSheet.flatten(pressable.props.style({ pressed: false }))
|
|
287
|
+
).toMatchObject({
|
|
286
288
|
backgroundColor: "rgba(1,1,1,1)",
|
|
287
289
|
borderColor: "rgba(2,2,2,1)",
|
|
288
290
|
borderWidth: 1,
|
|
@@ -76,6 +76,66 @@ describe("masterCellBuilder", () => {
|
|
|
76
76
|
});
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
+
describe("cellOptions.interactive", () => {
|
|
80
|
+
// renders a single View tagged with the state the cell was built with
|
|
81
|
+
const dataAdapter =
|
|
82
|
+
() =>
|
|
83
|
+
({ state }) => [
|
|
84
|
+
{ type: "View", style: {}, props: { testID: `state-${state}` } },
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
// Only the root View gets this testID: cell elements pass their own testID, which overrides it
|
|
88
|
+
const ROOT_TEST_ID = "master-cell-root";
|
|
89
|
+
|
|
90
|
+
// forwardRef: MasterCell attaches a ref to its root View
|
|
91
|
+
const TestView = React.forwardRef((props, ref) => (
|
|
92
|
+
<View testID={ROOT_TEST_ID} {...props} ref={ref} />
|
|
93
|
+
));
|
|
94
|
+
|
|
95
|
+
const renderCell = (cellOptions, state) => {
|
|
96
|
+
const MasterCell = masterCellBuilder({
|
|
97
|
+
cellConfiguration: singleView,
|
|
98
|
+
dataAdapter,
|
|
99
|
+
components: { View: TestView },
|
|
100
|
+
cellOptions,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return renderWithProviders(<MasterCell item={entry} state={state} />);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const getRoot = (wrapper) => wrapper.getByTestId(ROOT_TEST_ID);
|
|
107
|
+
|
|
108
|
+
it("keeps focused state and default root props when omitted", () => {
|
|
109
|
+
const wrapper = renderCell(undefined, "focused");
|
|
110
|
+
const root = getRoot(wrapper);
|
|
111
|
+
|
|
112
|
+
expect(wrapper.getByTestId("state-focused")).toBeDefined();
|
|
113
|
+
expect(root.props.onStartShouldSetResponder).toBeUndefined();
|
|
114
|
+
expect(root.props.collapsable).toBeUndefined();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("claims body touches on a non-collapsable root when false", () => {
|
|
118
|
+
const wrapper = renderCell({ interactive: false }, "default");
|
|
119
|
+
const root = getRoot(wrapper);
|
|
120
|
+
|
|
121
|
+
expect(root.props.onStartShouldSetResponder()).toBe(true);
|
|
122
|
+
expect(root.props.collapsable).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("renders focused as default when false", () => {
|
|
126
|
+
const wrapper = renderCell({ interactive: false }, "focused");
|
|
127
|
+
|
|
128
|
+
expect(wrapper.getByTestId("state-default")).toBeDefined();
|
|
129
|
+
expect(wrapper.queryByTestId("state-focused")).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("renders focused_selected as selected when false", () => {
|
|
133
|
+
const wrapper = renderCell({ interactive: false }, "focused_selected");
|
|
134
|
+
|
|
135
|
+
expect(wrapper.getByTestId("state-selected")).toBeDefined();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
79
139
|
describe.skip("when using custom default components", () => {
|
|
80
140
|
// the collapsable prop is added here to easily check from the snapshot that the mock
|
|
81
141
|
// component is being used instead of the default one
|
|
@@ -10,7 +10,11 @@ import { useScreenState } from "@applicaster/zapp-react-native-utils/screenState
|
|
|
10
10
|
import { defaultComponents } from "./DefaultComponents";
|
|
11
11
|
import { defaultDataAdapter } from "./dataAdapter";
|
|
12
12
|
import { elementMapper, mapElementWithKey } from "./elementMapper";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
getEntryState,
|
|
15
|
+
hasFocusableInsideBuilder,
|
|
16
|
+
withoutFocusedState,
|
|
17
|
+
} from "./utils";
|
|
14
18
|
|
|
15
19
|
type Props = {
|
|
16
20
|
item: ZappEntry;
|
|
@@ -19,6 +23,16 @@ type Props = {
|
|
|
19
23
|
|
|
20
24
|
const defaultContainerStyle = { flex: 1 };
|
|
21
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Root props of a non-interactive cell: the root view claims touches on the cell body,
|
|
28
|
+
* so the host list's touchable never fires (buttons, being deeper, still receive theirs).
|
|
29
|
+
* `collapsable: false` keeps Android / Fabric from flattening the view away.
|
|
30
|
+
*/
|
|
31
|
+
const nonInteractiveRootProps = {
|
|
32
|
+
onStartShouldSetResponder: () => true,
|
|
33
|
+
collapsable: false,
|
|
34
|
+
};
|
|
35
|
+
|
|
22
36
|
/**
|
|
23
37
|
* Constructs a MasterCell with the provided options
|
|
24
38
|
* @param {Object} options
|
|
@@ -27,6 +41,8 @@ const defaultContainerStyle = { flex: 1 };
|
|
|
27
41
|
* @param {Function} options.dataAdapter optional dataAdapter to use instead of the default one.
|
|
28
42
|
* See dataAdapter.js for more info
|
|
29
43
|
* @param {containerStyles} options.containerStyle style to apply at the top level of the master cell
|
|
44
|
+
* @param {Object} options.cellOptions optional cell options. `interactive: false` makes the cell body
|
|
45
|
+
* ignore taps and render without focused / pressed states (inner buttons keep working). Defaults to `true`.
|
|
30
46
|
* @returns {Function} Functional React Component to render the masterCell with the provided options
|
|
31
47
|
*/
|
|
32
48
|
|
|
@@ -39,6 +55,7 @@ type MasterCellBuilderOptions = {
|
|
|
39
55
|
isRTL?: boolean;
|
|
40
56
|
skipButtons?: boolean;
|
|
41
57
|
component?: ZappComponent;
|
|
58
|
+
interactive?: boolean;
|
|
42
59
|
};
|
|
43
60
|
};
|
|
44
61
|
|
|
@@ -49,7 +66,7 @@ export function masterCellBuilder({
|
|
|
49
66
|
containerStyle = defaultContainerStyle,
|
|
50
67
|
cellOptions = {},
|
|
51
68
|
}: MasterCellBuilderOptions) {
|
|
52
|
-
const { skipButtons = false, component } = cellOptions;
|
|
69
|
+
const { skipButtons = false, component, interactive = true } = cellOptions;
|
|
53
70
|
|
|
54
71
|
const componentsMap = R.merge(defaultComponents, components);
|
|
55
72
|
const { View } = componentsMap;
|
|
@@ -88,7 +105,10 @@ export function masterCellBuilder({
|
|
|
88
105
|
() =>
|
|
89
106
|
elementsBuilder({
|
|
90
107
|
entry: item,
|
|
91
|
-
state: getEntryState(
|
|
108
|
+
state: getEntryState(
|
|
109
|
+
interactive ? state : withoutFocusedState(state),
|
|
110
|
+
entryIsSelected
|
|
111
|
+
),
|
|
92
112
|
allowDynamicColorsOutsideExtensions:
|
|
93
113
|
theme?.allow_dynamic_colors_outside_extensions,
|
|
94
114
|
}),
|
|
@@ -134,7 +154,11 @@ export function masterCellBuilder({
|
|
|
134
154
|
);
|
|
135
155
|
|
|
136
156
|
return (
|
|
137
|
-
<View
|
|
157
|
+
<View
|
|
158
|
+
style={containerStyle}
|
|
159
|
+
ref={wrapperRef}
|
|
160
|
+
{...(interactive ? undefined : nonInteractiveRootProps)}
|
|
161
|
+
>
|
|
138
162
|
{elements.map(remapElements)}
|
|
139
163
|
</View>
|
|
140
164
|
);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { withoutFocusedState } from "..";
|
|
2
|
+
|
|
3
|
+
describe("withoutFocusedState", () => {
|
|
4
|
+
it("maps focused to default", () => {
|
|
5
|
+
expect(withoutFocusedState("focused")).toBe("default");
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
it("maps focused_selected to selected", () => {
|
|
9
|
+
expect(withoutFocusedState("focused_selected")).toBe("selected");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("keeps default and selected states", () => {
|
|
13
|
+
expect(withoutFocusedState("default")).toBe("default");
|
|
14
|
+
expect(withoutFocusedState("selected")).toBe("selected");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("keeps a missing state", () => {
|
|
18
|
+
expect(withoutFocusedState(undefined)).toBeUndefined();
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -386,3 +386,21 @@ export function getEntryState(state, selected) {
|
|
|
386
386
|
|
|
387
387
|
return selected ? "selected" : "default";
|
|
388
388
|
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Drops focused (pressed) variants of a cell state, so a non-interactive cell
|
|
392
|
+
* always renders its idle look. `selected` is kept – it is not touch feedback.
|
|
393
|
+
*/
|
|
394
|
+
export function withoutFocusedState(
|
|
395
|
+
state?: CellState | null
|
|
396
|
+
): CellState | null | undefined {
|
|
397
|
+
if (state === "focused_selected") {
|
|
398
|
+
return "selected";
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (state === "focused") {
|
|
402
|
+
return "default";
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return state;
|
|
406
|
+
}
|
|
@@ -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
|
-
|
|
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: ${
|
|
288
|
+
`onPlayNextPerformNextVideoPlay: delay completed, replaceTop to item: ${nextEntry.title}, id: ${nextEntry.id}, outgoing item id: ${item?.id}`
|
|
286
289
|
);
|
|
287
290
|
|
|
288
|
-
|
|
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(
|
|
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
|
+
});
|