@applicaster/zapp-react-native-ui-components 16.0.0-alpha.1334507601 → 16.0.0-alpha.1444784746

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.
Files changed (26) hide show
  1. package/Components/LinkHandler/LinkHandler.tsx +2 -7
  2. package/Components/MasterCell/DefaultComponents/LiveImage/executePreloadHooks.ts +2 -4
  3. package/Components/MasterCell/DefaultComponents/LiveImage/index.tsx +2 -3
  4. package/Components/ModalComponent/AudioPlayer/Components/Header.tsx +64 -20
  5. package/Components/ModalComponent/AudioPlayer/Components/Input.tsx +139 -39
  6. package/Components/ModalComponent/AudioPlayer/Components/NowPlayingHeaderSection.tsx +147 -24
  7. package/Components/ModalComponent/AudioPlayer/Components/__tests__/NowPlayingHeaderSection.test.tsx +36 -0
  8. package/Components/ModalComponent/AudioPlayer/utils/__tests__/mergeStyles.test.ts +108 -0
  9. package/Components/ModalComponent/AudioPlayer/utils/mergeStyles.ts +148 -1
  10. package/Components/ModalComponent/BottomSheetModalContent.tsx +95 -9
  11. package/Components/ModalComponent/SortableList.tsx +2 -3
  12. package/Components/ModalComponent/TextInputContent.tsx +27 -4
  13. package/Components/ModalComponent/__tests__/BottomSheetModalContent.test.tsx +74 -1
  14. package/Components/ModalComponent/__tests__/TextInputContent.test.tsx +14 -1
  15. package/Components/ModalComponent/showTextInput.ts +11 -2
  16. package/Components/OfflineHandler/__tests__/index.test.tsx +109 -0
  17. package/Components/OfflineHandler/index.tsx +42 -4
  18. package/Components/PlayerContainer/PlayerContainer.tsx +8 -1
  19. package/Components/Transitioner/AnimationManager.js +1 -1
  20. package/Components/Transitioner/Scene.tsx +4 -3
  21. package/Components/Transitioner/Transitioner.tsx +71 -33
  22. package/Components/Transitioner/__tests__/transitioner.test.js +372 -3
  23. package/Components/VideoLive/LiveImageManager.ts +8 -2
  24. package/Components/VideoLive/__tests__/LiveImageManager.test.ts +59 -0
  25. package/assets/empty-Info.png +0 -0
  26. package/package.json +5 -5
@@ -92,6 +92,8 @@ type ModalComponentProps = {
92
92
  contentComponentProps?: Record<string, any>;
93
93
  styleOverrides?: ModalBlocksConfig | Record<string, any>;
94
94
  measurementPhase?: boolean;
95
+ /** Copy under Next Up when the queue has no upcoming items. */
96
+ emptyMessage?: string;
95
97
  };
96
98
 
97
99
  function _BottomSheetModalContent(props: ModalComponentProps) {
@@ -115,6 +117,7 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
115
117
  contentComponentProps = {},
116
118
  styleOverrides: propStyleOverrides,
117
119
  measurementPhase = false,
120
+ emptyMessage,
118
121
  } = props;
119
122
 
120
123
  const route = useRef(currentRoute);
@@ -150,17 +153,81 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
150
153
  [plugins, themePluginId]
151
154
  );
152
155
 
153
- const styleOverrides = useMemo(() => {
154
- if (propStyleOverrides) {
155
- return propStyleOverrides;
156
- }
157
-
156
+ const styleOverrides = useMemo<ModalBlocksConfig | null>(() => {
158
157
  const configuration =
159
158
  plugin?.configuration || (plugin as any)?.configuration_json;
160
159
 
161
- if (!configuration || !themePluginId) return null;
160
+ const parsed =
161
+ configuration && themePluginId
162
+ ? parseModalBlocksConfiguration(configuration, themePluginId)
163
+ : null;
164
+
165
+ if (!propStyleOverrides) {
166
+ return parsed;
167
+ }
168
+
169
+ // hydrate(QueueStyles) only emits sectionHeader after the plugin class
170
+ // includes it. Until then, Studio/local keys in configuration_json still
171
+ // apply via the parser.
172
+ const hydratedSection = propStyleOverrides.sectionHeader;
173
+
174
+ const hasTitleStyle = (style?: Record<string, any>) =>
175
+ Boolean(
176
+ style &&
177
+ (style.fontColor ||
178
+ style.fontSize !== undefined ||
179
+ style.lineHeight !== undefined ||
180
+ style.fontFamily ||
181
+ style.iosFontFamily ||
182
+ style.androidFontFamily ||
183
+ style.letterSpacing !== undefined ||
184
+ style.iosLetterSpacing !== undefined ||
185
+ style.androidLetterSpacing !== undefined ||
186
+ style.textTransform ||
187
+ style.alignment)
188
+ );
189
+
190
+ const hasActionStyle = (style?: Record<string, any>) =>
191
+ Boolean(
192
+ style &&
193
+ (style.backgroundColor ||
194
+ style.fontColor ||
195
+ style.fontSize !== undefined ||
196
+ style.lineHeight !== undefined ||
197
+ style.fontFamily ||
198
+ style.iosFontFamily ||
199
+ style.androidFontFamily ||
200
+ style.letterSpacing !== undefined ||
201
+ style.iosLetterSpacing !== undefined ||
202
+ style.androidLetterSpacing !== undefined ||
203
+ style.textTransform ||
204
+ style.borderRadius !== undefined ||
205
+ style.cornerRadius !== undefined ||
206
+ style.paddingTop !== undefined ||
207
+ style.paddingRight !== undefined ||
208
+ style.paddingBottom !== undefined ||
209
+ style.paddingLeft !== undefined)
210
+ );
162
211
 
163
- return parseModalBlocksConfiguration(configuration, themePluginId);
212
+ const actionNode = hydratedSection?.actionButtons;
213
+
214
+ // An empty `default: {}` is truthy and would hide fields set on the flat
215
+ // object or on the focused bucket.
216
+ const hasHydratedSection =
217
+ hasTitleStyle(hydratedSection?.title?.default) ||
218
+ hasTitleStyle(hydratedSection?.title) ||
219
+ hasActionStyle(actionNode?.default) ||
220
+ hasActionStyle(actionNode?.focused) ||
221
+ hasActionStyle(actionNode?.focus) ||
222
+ hasActionStyle(actionNode);
223
+
224
+ return {
225
+ ...parsed,
226
+ ...propStyleOverrides,
227
+ sectionHeader: hasHydratedSection
228
+ ? hydratedSection
229
+ : parsed?.sectionHeader,
230
+ };
164
231
  }, [propStyleOverrides, plugin, themePluginId]);
165
232
 
166
233
  const pluginLocalizations = useLocalizedStrings({
@@ -178,17 +245,26 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
178
245
  const nextUpTitle =
179
246
  pluginLocalizations?.next_up_title ||
180
247
  (normalizedPluginId
181
- ? pluginLocalizations?.[`${normalizedPluginId}_next_up_title`]
248
+ ? pluginLocalizations?.[`${normalizedPluginId}_next_up_title`] ||
249
+ pluginLocalizations?.[`${normalizedPluginId}_label_next_up`]
182
250
  : undefined) ||
183
251
  "Next Up";
184
252
 
185
253
  const nowPlayingTitle =
186
254
  pluginLocalizations?.now_playing_title ||
187
255
  (normalizedPluginId
188
- ? pluginLocalizations?.[`${normalizedPluginId}_now_playing_title`]
256
+ ? pluginLocalizations?.[`${normalizedPluginId}_now_playing_title`] ||
257
+ pluginLocalizations?.[`${normalizedPluginId}_label_now_playing`]
189
258
  : undefined) ||
190
259
  "Now Playing";
191
260
 
261
+ const clearLabel =
262
+ pluginLocalizations?.clear_label ||
263
+ (normalizedPluginId
264
+ ? pluginLocalizations?.[`${normalizedPluginId}_label_clear`]
265
+ : undefined) ||
266
+ "Clear";
267
+
192
268
  const resolvedTitle = props.title || localizedHeaderTitle || title;
193
269
 
194
270
  const itemsToRender = viewModel ? fetchedItems : items;
@@ -312,6 +388,7 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
312
388
  const hasNowPlaying = hasOperation(operations, OPERATIONS.NOW_PLAYING);
313
389
  const hasClearAll = hasOperation(operations, OPERATIONS.CLEAR_ALL);
314
390
  const hasAdd = hasOperation(operations, OPERATIONS.ADD);
391
+ const isQueueEmpty = hasNowPlaying && (itemsToRender?.length ?? 0) === 0;
315
392
 
316
393
  const handleDeleteCollection = useCallback(() => {
317
394
  editableCollection?.deleteCollection?.();
@@ -336,6 +413,9 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
336
413
  onClear={hasClearAll ? handleDeleteCollection : undefined}
337
414
  nextUpTitle={nextUpTitle}
338
415
  nowPlayingTitle={nowPlayingTitle}
416
+ isEmpty={isQueueEmpty}
417
+ emptyMessage={emptyMessage}
418
+ clearLabel={clearLabel}
339
419
  />
340
420
  ),
341
421
  });
@@ -361,6 +441,9 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
361
441
  handleDeleteCollection,
362
442
  nextUpTitle,
363
443
  nowPlayingTitle,
444
+ isQueueEmpty,
445
+ emptyMessage,
446
+ clearLabel,
364
447
  hasAdd,
365
448
  handleCreatePlaylist,
366
449
  ]);
@@ -470,6 +553,9 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
470
553
  onClear={hasClearAll ? handleDeleteCollection : undefined}
471
554
  nextUpTitle={nextUpTitle}
472
555
  nowPlayingTitle={nowPlayingTitle}
556
+ isEmpty={isQueueEmpty}
557
+ emptyMessage={emptyMessage}
558
+ clearLabel={clearLabel}
473
559
  />
474
560
  ) : null}
475
561
  {hasAdd ? (
@@ -87,9 +87,7 @@ export const SortableList = ({
87
87
  const [localData, setLocalData] = useState(sortableData);
88
88
 
89
89
  useEffect(() => {
90
- setLocalData((localData) =>
91
- localData !== sortableData ? sortableData : localData
92
- );
90
+ setLocalData(sortableData);
93
91
  }, [sortableData]);
94
92
 
95
93
  const handleDragEnd = useCallback(
@@ -142,6 +140,7 @@ export const SortableList = ({
142
140
  >
143
141
  {renderHeader}
144
142
  <Sortable.Grid
143
+ key={`sortable-grid-${localData.length}`}
145
144
  scrollableRef={scrollableRef}
146
145
  customHandle
147
146
  columns={columns}
@@ -9,6 +9,7 @@ import {
9
9
  AudioPlayerButton,
10
10
  VARIANT_TEXT_ONLY,
11
11
  } from "./AudioPlayer/Components/Button";
12
+ import { logger } from "@applicaster/zapp-react-native-utils/logger";
12
13
 
13
14
  export type TextInputContentProps = {
14
15
  /** Injected by the sheet host; unused for layout (header is owned by ModalComponent). */
@@ -19,17 +20,25 @@ export type TextInputContentProps = {
19
20
  inputLabel?: string;
20
21
  defaultValue?: string;
21
22
  buttonLabel: string;
23
+ validationLabel?: string;
24
+ maxLength?: number;
22
25
  actions: ActionType[];
23
26
  actionContext?: Record<string, any>;
24
27
  };
25
28
 
29
+ // Spacing mirrors the `component_style_*` keys in text input component.
26
30
  const styles = StyleSheet.create({
27
31
  container: {
28
- paddingHorizontal: 20,
29
- paddingBottom: 8,
32
+ backgroundColor: "transparent", // component_style_background_color
33
+ paddingLeft: 40, // component_style_padding_left
34
+ paddingRight: 40, // component_style_padding_right
35
+ paddingTop: 0, // component_style_padding_top
36
+ paddingBottom: 0, // component_style_padding_bottom
37
+ marginTop: 20, // component_style_margin_top
38
+ marginBottom: 20, // component_style_margin_bottom
30
39
  },
31
40
  inputWrap: {
32
- marginTop: 8,
41
+ // Gap down to the submit button; owned by the modal, not the reference.
33
42
  marginBottom: 24,
34
43
  },
35
44
  });
@@ -45,11 +54,24 @@ export function TextInputContent(props: TextInputContentProps) {
45
54
  buttonLabel,
46
55
  actions,
47
56
  actionContext,
57
+ maxLength = 255,
58
+ validationLabel,
48
59
  } = props;
49
60
 
50
61
  const [textValue, setTextValue] = useState(defaultValue);
51
62
  const [submitting, setSubmitting] = useState(false);
52
63
 
64
+ const validationMessage =
65
+ textValue.length > maxLength
66
+ ? validationLabel || "Too many characters"
67
+ : undefined;
68
+
69
+ if (!validationLabel) {
70
+ logger.warn(
71
+ "Modal/TextInputContent: Validation label (validationLabel) is not provided, using default message 'Too many characters'."
72
+ );
73
+ }
74
+
53
75
  const onSubmit = useCallback(async () => {
54
76
  if (submitting) {
55
77
  return;
@@ -97,6 +119,7 @@ export function TextInputContent(props: TextInputContentProps) {
97
119
  label: inputLabel,
98
120
  }}
99
121
  value={textValue}
122
+ validationMessage={validationMessage}
100
123
  onChangeText={setTextValue}
101
124
  onClearPress={() => setTextValue("")}
102
125
  />
@@ -105,7 +128,7 @@ export function TextInputContent(props: TextInputContentProps) {
105
128
  configuration={VARIANT_TEXT_ONLY}
106
129
  data={{ title: buttonLabel }}
107
130
  onPress={onSubmit}
108
- disabled={submitting}
131
+ disabled={!!validationMessage || submitting}
109
132
  />
110
133
  </View>
111
134
  );
@@ -83,9 +83,14 @@ jest.mock("../AudioPlayer/Components/Item", () => ({
83
83
  }));
84
84
 
85
85
  const mockAudioPlayerButtonProps: Array<any> = [];
86
+ const mockNowPlayingHeaderProps: Array<any> = [];
86
87
 
87
88
  jest.mock("../AudioPlayer/Components", () => ({
88
- NowPlayingHeaderSection: () => null,
89
+ NowPlayingHeaderSection: (props: any) => {
90
+ mockNowPlayingHeaderProps.push(props);
91
+
92
+ return null;
93
+ },
89
94
  AudioPlayerButton: (props: any) => {
90
95
  const { Text } = require("react-native");
91
96
 
@@ -216,6 +221,74 @@ describe("BottomSheetModalContent role/behavior cells", () => {
216
221
  });
217
222
  });
218
223
 
224
+ it("shows the empty Next Up message only when now playing is on and the list is empty", () => {
225
+ mockNowPlayingHeaderProps.length = 0;
226
+ const { BehaviorSubject } = require("rxjs");
227
+
228
+ const emptyQueue = new ContentViewModel({
229
+ title: "Queue",
230
+ items: [],
231
+ role: "dynamic_collection",
232
+ dynamicCollection: {
233
+ operations: ["nowPlaying", "remove", "reorder", "clearAll"],
234
+ },
235
+ });
236
+
237
+ emptyQueue.editableCollection = {
238
+ items$: new BehaviorSubject([]),
239
+ operations: ["nowPlaying", "remove", "reorder", "clearAll"],
240
+ move: jest.fn(),
241
+ };
242
+
243
+ const { rerender } = render(
244
+ <BottomSheetModalContent
245
+ {...baseProps}
246
+ items={[]}
247
+ emptyMessage="Queued items will be added here"
248
+ viewModel={emptyQueue}
249
+ />
250
+ );
251
+
252
+ expect(mockNowPlayingHeaderProps.at(-1)).toMatchObject({
253
+ isEmpty: true,
254
+ emptyMessage: "Queued items will be added here",
255
+ });
256
+
257
+ const withItem = new ContentViewModel({
258
+ title: "Queue",
259
+ items: [{ id: "t1", title: "Track 1" }],
260
+ role: "dynamic_collection",
261
+ dynamicCollection: {
262
+ operations: ["nowPlaying", "remove", "reorder", "clearAll"],
263
+ },
264
+ });
265
+
266
+ withItem.editableCollection = {
267
+ items$: new BehaviorSubject([{ id: "t1", title: "Track 1" }]),
268
+ operations: ["nowPlaying", "remove", "reorder", "clearAll"],
269
+ move: jest.fn(),
270
+ };
271
+
272
+ rerender(
273
+ <BottomSheetModalContent
274
+ {...baseProps}
275
+ items={[{ id: "t1", title: "Track 1", label: "Track 1" }]}
276
+ emptyMessage="Queued items will be added here"
277
+ viewModel={withItem}
278
+ />
279
+ );
280
+
281
+ expect(mockNowPlayingHeaderProps.at(-1)).toMatchObject({
282
+ isEmpty: false,
283
+ });
284
+
285
+ mockNowPlayingHeaderProps.length = 0;
286
+
287
+ render(<BottomSheetModalContent {...baseProps} />);
288
+
289
+ expect(mockNowPlayingHeaderProps).toHaveLength(0);
290
+ });
291
+
219
292
  it("honors disableScrollViewWrap in contentComponentProps to bypass ScrollView wrapping", () => {
220
293
  const CustomComponent = () => (
221
294
  <React.Fragment>Custom Content</React.Fragment>
@@ -19,7 +19,7 @@ jest.mock(
19
19
  );
20
20
 
21
21
  jest.mock("../AudioPlayer/Components/Input", () => ({
22
- AudioPlayerInput: ({ value, onChangeText, data }: any) => {
22
+ AudioPlayerInput: ({ value, onChangeText, data, validationMessage }: any) => {
23
23
  const { TextInput, Text } = require("react-native");
24
24
 
25
25
  return (
@@ -31,6 +31,9 @@ jest.mock("../AudioPlayer/Components/Input", () => ({
31
31
  onChangeText={onChangeText}
32
32
  placeholder={data?.placeholder}
33
33
  />
34
+ {validationMessage ? (
35
+ <Text testID="input-validation-message">{validationMessage}</Text>
36
+ ) : null}
34
37
  </>
35
38
  );
36
39
  },
@@ -87,6 +90,16 @@ describe("TextInputContent", () => {
87
90
  expect(screen.getByText("Update")).toBeTruthy();
88
91
  });
89
92
 
93
+ it("shows validation below the input when its value is too long", () => {
94
+ render(<TextInputContent maxLength={100} {...baseProps} />);
95
+
96
+ fireEvent.changeText(screen.getByTestId("playlist-input"), "x".repeat(101));
97
+
98
+ expect(screen.getByTestId("input-validation-message")).toHaveTextContent(
99
+ "Too many characters"
100
+ );
101
+ });
102
+
90
103
  it("on submit merges name into sendCloudEvent data then dismisses", async () => {
91
104
  render(<TextInputContent {...baseProps} />);
92
105
 
@@ -13,8 +13,15 @@ export async function showTextInputAction(
13
13
  action: ActionType,
14
14
  context?: Record<string, any>
15
15
  ): Promise<ActionResult> {
16
- const { headerTitle, inputLabel, defaultValue, buttonLabel, actions } =
17
- action.options || {};
16
+ const {
17
+ headerTitle,
18
+ inputLabel,
19
+ defaultValue,
20
+ buttonLabel,
21
+ validationLabel,
22
+ actions,
23
+ maxLength,
24
+ } = action.options || {};
18
25
 
19
26
  if (!headerTitle || !buttonLabel || !actions || !actions.length) {
20
27
  return ActionResult.Error;
@@ -30,10 +37,12 @@ export async function showTextInputAction(
30
37
  content: {
31
38
  component: TextInputContent,
32
39
  props: {
40
+ maxLength,
33
41
  inputLabel,
34
42
  defaultValue: defaultValue ?? "",
35
43
  buttonLabel,
36
44
  actions,
45
+ validationLabel,
37
46
  },
38
47
  },
39
48
  },
@@ -10,6 +10,28 @@ jest.useFakeTimers({ legacyFakeTimers: true });
10
10
  // isConnected/isInternetReachable/details fields, so the mock has to mirror
11
11
  // that shape for online/offline to actually be distinguishable.
12
12
  let mockConnectionStatus = false;
13
+ let mockStillOfflineAfterResumeCount = 0;
14
+
15
+ const mockComponentsLogger = {
16
+ log: jest.fn(),
17
+ debug: jest.fn(),
18
+ info: jest.fn(),
19
+ warning: jest.fn(),
20
+ error: jest.fn(),
21
+ };
22
+
23
+ jest.mock("../../../Helpers/logger", () => {
24
+ const proxy = {
25
+ log: (event) => mockComponentsLogger.log(event),
26
+ debug: (event) => mockComponentsLogger.debug(event),
27
+ info: (event) => mockComponentsLogger.info(event),
28
+ warning: (event) => mockComponentsLogger.warning(event),
29
+ error: (event) => mockComponentsLogger.error(event),
30
+ addSubsystem: () => proxy,
31
+ };
32
+
33
+ return { componentsLogger: proxy };
34
+ });
13
35
 
14
36
  jest.mock("@applicaster/zapp-react-native-utils/reactHooks/connection", () => {
15
37
  return {
@@ -20,6 +42,7 @@ jest.mock("@applicaster/zapp-react-native-utils/reactHooks/connection", () => {
20
42
  details: {},
21
43
  isConnected: mockConnectionStatus,
22
44
  isInternetReachable: mockConnectionStatus,
45
+ stillOfflineAfterResumeCount: mockStillOfflineAfterResumeCount,
23
46
  })),
24
47
  };
25
48
  });
@@ -58,6 +81,7 @@ describe("OfflineHandler", () => {
58
81
 
59
82
  beforeEach(() => {
60
83
  mockConnectionStatus = false;
84
+ mockStillOfflineAfterResumeCount = 0;
61
85
  });
62
86
 
63
87
  it("renders its children without wrapping them in a notification view", () => {
@@ -131,4 +155,89 @@ describe("OfflineHandler", () => {
131
155
 
132
156
  unsubscribe();
133
157
  });
158
+
159
+ it("re-shows the offline toast when still offline after app resume", () => {
160
+ mockConnectionStatus = false;
161
+
162
+ const handleShowToast = jest.fn();
163
+ const unsubscribe = subscriberFor("showToast", handleShowToast);
164
+
165
+ const { rerender } = render(<OfflineHandler />);
166
+ handleShowToast.mockClear();
167
+
168
+ mockStillOfflineAfterResumeCount = 1;
169
+ rerender(<OfflineHandler />);
170
+
171
+ expect(handleShowToast).toHaveBeenCalledTimes(1);
172
+
173
+ expect(handleShowToast).toHaveBeenCalledWith(
174
+ expect.objectContaining({
175
+ id: "offline-status",
176
+ message: "No internet connection",
177
+ timeout: 5000,
178
+ }),
179
+ expect.any(Function)
180
+ );
181
+
182
+ // further renders with the same counter must not re-emit
183
+ rerender(<OfflineHandler />);
184
+ expect(handleShowToast).toHaveBeenCalledTimes(1);
185
+
186
+ unsubscribe();
187
+ });
188
+
189
+ it("does not re-show the offline toast for a counter bump while online", () => {
190
+ mockConnectionStatus = true;
191
+
192
+ const handleShowToast = jest.fn();
193
+ const unsubscribe = subscriberFor("showToast", handleShowToast);
194
+
195
+ const { rerender } = render(<OfflineHandler />);
196
+
197
+ mockStillOfflineAfterResumeCount = 1;
198
+ rerender(<OfflineHandler />);
199
+
200
+ expect(handleShowToast).not.toHaveBeenCalled();
201
+
202
+ unsubscribe();
203
+ });
204
+
205
+ it("does not emit on mount just because the counter is already > 0", () => {
206
+ mockConnectionStatus = true;
207
+ mockStillOfflineAfterResumeCount = 3;
208
+
209
+ const handleShowToast = jest.fn();
210
+ const unsubscribe = subscriberFor("showToast", handleShowToast);
211
+
212
+ render(<OfflineHandler />);
213
+
214
+ expect(handleShowToast).not.toHaveBeenCalled();
215
+
216
+ unsubscribe();
217
+ });
218
+
219
+ it("logs toast emissions at info level", () => {
220
+ mockConnectionStatus = false;
221
+ mockComponentsLogger.info.mockClear();
222
+ mockComponentsLogger.warning.mockClear();
223
+
224
+ const { rerender } = render(<OfflineHandler />);
225
+
226
+ mockConnectionStatus = true;
227
+ rerender(<OfflineHandler />);
228
+
229
+ expect(mockComponentsLogger.info).toHaveBeenCalledWith(
230
+ expect.objectContaining({
231
+ message: "OfflineHandler: device is offline, emit toast",
232
+ })
233
+ );
234
+
235
+ expect(mockComponentsLogger.info).toHaveBeenCalledWith(
236
+ expect.objectContaining({
237
+ message: "OfflineHandler: device is back online, emit toast",
238
+ })
239
+ );
240
+
241
+ expect(mockComponentsLogger.warning).not.toHaveBeenCalled();
242
+ });
134
243
  });
@@ -29,12 +29,22 @@ export const OfflineHandler = ({ children }: Props) => {
29
29
  const { online } = parseConnectionInfo(connectionInfo);
30
30
  const prevOnline = usePrevious(online);
31
31
 
32
+ const stillOfflineAfterResumeCount =
33
+ connectionInfo?.stillOfflineAfterResumeCount ?? 0;
34
+
35
+ const prevStillOfflineAfterResumeCount = usePrevious(
36
+ stillOfflineAfterResumeCount
37
+ );
38
+
32
39
  const emitShowToast = useSubscriberFor(SHOW_TOAST_EVENT);
33
40
 
34
41
  const { message, extraMessage, style } = useOfflineHandlerProps(online);
35
42
 
36
43
  const showOnlineToast = useCallback(() => {
37
- logger.log("Device is back online, emit toast");
44
+ logger.info({
45
+ message: "OfflineHandler: device is back online, emit toast",
46
+ data: { online, prevOnline },
47
+ });
38
48
 
39
49
  emitShowToast({
40
50
  id: OFFLINE_STATUS_TOAST_ID,
@@ -43,10 +53,13 @@ export const OfflineHandler = ({ children }: Props) => {
43
53
  style,
44
54
  timeout: timers.goingOnline,
45
55
  });
46
- }, [emitShowToast, message, extraMessage, style]);
56
+ }, [emitShowToast, message, extraMessage, style, online, prevOnline]);
47
57
 
48
58
  const showOfflineToast = useCallback(() => {
49
- logger.log("Device went offline, emit toast");
59
+ logger.info({
60
+ message: "OfflineHandler: device is offline, emit toast",
61
+ data: { online, prevOnline, stillOfflineAfterResumeCount },
62
+ });
50
63
 
51
64
  emitShowToast({
52
65
  id: OFFLINE_STATUS_TOAST_ID,
@@ -55,7 +68,15 @@ export const OfflineHandler = ({ children }: Props) => {
55
68
  style,
56
69
  timeout: timers.goingOffline,
57
70
  });
58
- }, [emitShowToast, message, extraMessage, style]);
71
+ }, [
72
+ emitShowToast,
73
+ message,
74
+ extraMessage,
75
+ style,
76
+ online,
77
+ prevOnline,
78
+ stillOfflineAfterResumeCount,
79
+ ]);
59
80
 
60
81
  useEffect(() => {
61
82
  // On mount there is no previous state yet: only surface the offline toast
@@ -79,5 +100,22 @@ export const OfflineHandler = ({ children }: Props) => {
79
100
  }
80
101
  }, [online, prevOnline, showOfflineToast, showOnlineToast]);
81
102
 
103
+ useEffect(() => {
104
+ // NetworkStatusProvider bumps this counter when the app resumed while offline
105
+ // and is still offline after the verification delay: remind the user.
106
+ if (
107
+ prevStillOfflineAfterResumeCount !== undefined &&
108
+ stillOfflineAfterResumeCount > prevStillOfflineAfterResumeCount &&
109
+ !online
110
+ ) {
111
+ showOfflineToast();
112
+ }
113
+ }, [
114
+ stillOfflineAfterResumeCount,
115
+ prevStillOfflineAfterResumeCount,
116
+ online,
117
+ showOfflineToast,
118
+ ]);
119
+
82
120
  return children;
83
121
  };
@@ -404,10 +404,17 @@ const PlayerContainerComponent = (props: Props) => {
404
404
  const { eventType } = event;
405
405
 
406
406
  if (!isLanguageOverlayVisible && eventType === "menu") {
407
+ // First Back dismisses Play Next, the next one closes the player
408
+ if (playNextOverlayState) {
409
+ playNextOverlayState.handleUserCancelPlayNext();
410
+
411
+ return;
412
+ }
413
+
407
414
  close();
408
415
  }
409
416
  },
410
- [close]
417
+ [close, playNextOverlayState]
411
418
  );
412
419
 
413
420
  // Effects
@@ -66,7 +66,7 @@ export class AnimationManager {
66
66
  *
67
67
  * @param {object} to contains style to animate and scenes index
68
68
  * @param {string} action push|back
69
- * @param {number} stepsBack number of steps back
69
+ * @param {number} [stepsBack] number of steps back, used by the back action only
70
70
  */
71
71
  stateForAction({ to, scenes }, action, stepsBack) {
72
72
  let rootIndex = to.index;
@@ -17,13 +17,14 @@ const fullWidthDimensions = {
17
17
  type Props = {
18
18
  children: any;
19
19
  style: any;
20
- pointerEvents: ViewProps["pointerEvents"];
21
- overlayStyle: ViewStyle;
22
- animating: boolean;
20
+ pointerEvents?: ViewProps["pointerEvents"];
21
+ overlayStyle?: ViewStyle;
22
+ animating?: boolean;
23
23
  contentStyle: ViewStyle;
24
24
  pathname: string;
25
25
  isActive: boolean;
26
26
  screenData: NavigationScreenData;
27
+ screenUniqueId?: string;
27
28
  };
28
29
 
29
30
  export function CurrentScreenContextProvider({