@applicaster/quick-brick-core 16.0.0-rc.12 → 16.0.0-rc.120
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/App/ActionsProvider/ActionsProvider.tsx +31 -9
- package/App/ActionsProvider/LegacyActionsRegistryAdapter.tsx +123 -0
- package/App/ActionsProvider/__tests__/LegacyActionsRegistryAdapter.test.tsx +64 -0
- package/App/DeepLinking/URLSchemeHandler/SchemeHandlerHooks/__tests__/useOpenSchemeHandler.test.tsx +25 -15
- package/App/ModalProvider/ModalBottomSheet/DraggableBottomSheet/__tests__/index.test.tsx +88 -0
- package/App/ModalProvider/ModalBottomSheet/DraggableBottomSheet/index.tsx +30 -56
- package/App/ModalProvider/ModalBottomSheet/ModalBottomSheetFrame.tsx +9 -2
- package/App/ModalProvider/ModalBottomSheet/hooks/__tests__/useKeyboardHeight.android.test.ts +105 -0
- package/App/ModalProvider/ModalBottomSheet/hooks/__tests__/useKeyboardHeight.test.ts +102 -0
- package/App/ModalProvider/ModalBottomSheet/hooks/index.ts +1 -0
- package/App/ModalProvider/ModalBottomSheet/hooks/useKeyboardHeight.ts +30 -0
- package/App/ModalProvider/ModalBottomSheet/index.tsx +5 -2
- package/App/ModalProvider/ModalContent.tsx +3 -0
- package/App/ModalProvider/__tests__/ModalContent.test.tsx +39 -0
- package/App/ModalProvider/__tests__/index.test.tsx +99 -0
- package/App/ModalProvider/index.tsx +39 -14
- package/App/NavigationProvider/NavigationProvider.tsx +71 -10
- package/App/NavigationProvider/__tests__/navToolbox.test.ts +227 -0
- package/App/NavigationProvider/__tests__/navigationProvider.test.tsx +56 -0
- package/App/NavigationProvider/__tests__/utils.test.ts +31 -1
- package/App/NavigationProvider/navToolbox.ts +224 -0
- package/App/NavigationProvider/utils.ts +35 -0
- package/App/NetworkStatusProvider/NetworkStatusProvider.tsx +32 -4
- package/App/NetworkStatusProvider/ResumeVerificationController.ts +282 -0
- package/App/NetworkStatusProvider/__tests__/NetworkStatusProvider.test.tsx +152 -1
- package/App/NetworkStatusProvider/__tests__/ResumeVerificationController.test.ts +471 -0
- package/App/NetworkStatusProvider/__tests__/resumeVerification.test.ts +126 -0
- package/App/NetworkStatusProvider/__tests__/useResumeVerifiedNetInfo.test.ts +189 -0
- package/App/NetworkStatusProvider/resumeVerification.ts +54 -0
- package/App/NetworkStatusProvider/useResumeVerifiedNetInfo.ts +33 -0
- package/App/NotificationToastRenderer/NotificationToastManager.ts +214 -0
- package/App/NotificationToastRenderer/NotificationToastRenderer.tsx +134 -0
- package/App/NotificationToastRenderer/NotificationToastRenderer.tv.tsx +10 -0
- package/App/NotificationToastRenderer/NotificationToastRenderer.web.tsx +61 -0
- package/App/NotificationToastRenderer/__tests__/NotificationToastManager.test.ts +237 -0
- package/App/NotificationToastRenderer/__tests__/NotificationToastRenderer.test.tsx +233 -0
- package/App/NotificationToastRenderer/__tests__/NotificationToastRenderer.web.test.tsx +90 -0
- package/App/NotificationToastRenderer/__tests__/resolveConfirmationToastStyle.test.ts +118 -0
- package/App/NotificationToastRenderer/__tests__/useNotificationHeight.test.tsx +58 -0
- package/App/NotificationToastRenderer/__tests__/useNotificationToastState.test.ts +112 -0
- package/App/NotificationToastRenderer/index.tsx +9 -0
- package/App/NotificationToastRenderer/resolveConfirmationToastStyle.ts +33 -0
- package/App/NotificationToastRenderer/useNotificationHeight.tsx +14 -0
- package/App/NotificationToastRenderer/useNotificationToastState.tsx +12 -0
- package/App/index.tsx +8 -5
- package/package.json +8 -8
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
const mockRegister = jest.fn().mockResolvedValue(undefined);
|
|
2
|
+
|
|
3
|
+
// The X-Ray mock (__mocks__/@applicaster/quick-brick-xray) has no toolbox API: give it one.
|
|
4
|
+
require("@applicaster/quick-brick-xray").registerReactToolbox = mockRegister;
|
|
5
|
+
|
|
6
|
+
// Required after the toolbox API is in place: loading them can register the player toolbox.
|
|
7
|
+
const {
|
|
8
|
+
modalStore,
|
|
9
|
+
} = require("@applicaster/zapp-react-native-utils/modalState");
|
|
10
|
+
|
|
11
|
+
const {
|
|
12
|
+
setXrayNavigator,
|
|
13
|
+
} = require("@applicaster/zapp-react-native-utils/navigationUtils/xrayNavigator");
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
playerManager,
|
|
17
|
+
} = require("@applicaster/zapp-react-native-utils/appUtils/playerManager");
|
|
18
|
+
|
|
19
|
+
const { registerNavToolbox } = require("../navToolbox");
|
|
20
|
+
|
|
21
|
+
const navigator = (overrides = {}) => {
|
|
22
|
+
const nav = {
|
|
23
|
+
getPathname: () => "/river/a/river/b",
|
|
24
|
+
getStack: () => [{ route: "/river/a" }, { route: "/river/a/river/b" }],
|
|
25
|
+
canGoBack: () => true,
|
|
26
|
+
goBack: jest.fn(),
|
|
27
|
+
goHome: jest.fn(),
|
|
28
|
+
getPlayNextOverlay: () => null,
|
|
29
|
+
getModalState: () => modalStore.getState().modalState,
|
|
30
|
+
dismissModal: jest.fn(),
|
|
31
|
+
videoModalState: { visible: false },
|
|
32
|
+
minimiseVideoModal: jest.fn(),
|
|
33
|
+
maximiseVideoModal: jest.fn(),
|
|
34
|
+
closeVideoModal: jest.fn(),
|
|
35
|
+
...overrides,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
setXrayNavigator(nav as unknown as QuickBrickAppNavigator);
|
|
39
|
+
|
|
40
|
+
return nav;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const videoModal = (mode: string) => ({
|
|
44
|
+
videoModalState: { visible: true, mode, item: { id: "v1", title: "V" } },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const call = (name: string) => {
|
|
48
|
+
// Other toolboxes (player) may register first.
|
|
49
|
+
const { tools } = mockRegister.mock.calls.find(
|
|
50
|
+
([toolbox]) => toolbox.name === "nav"
|
|
51
|
+
)[0];
|
|
52
|
+
|
|
53
|
+
return tools.find((tool) => tool.name === name).handler({});
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
describe("navToolbox", () => {
|
|
57
|
+
beforeAll(() => registerNavToolbox());
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
modalStore.getState().setModalState({ visible: false, screen: null });
|
|
61
|
+
jest.restoreAllMocks();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("throws until the NavigationProvider sets the navigator", () => {
|
|
65
|
+
setXrayNavigator(undefined);
|
|
66
|
+
|
|
67
|
+
expect(() => call("stack")).toThrow("Navigation is not available yet");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("goes back one screen", () => {
|
|
71
|
+
const nav = navigator();
|
|
72
|
+
|
|
73
|
+
expect(call("back")).toEqual({ from: "/river/a/river/b", wentBack: true });
|
|
74
|
+
expect(nav.goBack).toHaveBeenCalledWith(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("does not go back from the root", () => {
|
|
78
|
+
const nav = navigator({ canGoBack: () => false });
|
|
79
|
+
|
|
80
|
+
expect(call("back")).toMatchObject({ wentBack: false });
|
|
81
|
+
expect(nav.goBack).not.toHaveBeenCalled();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("does not pop the screen under a modal or the video modal", () => {
|
|
85
|
+
const nav = navigator(videoModal("MAXIMIZED"));
|
|
86
|
+
|
|
87
|
+
expect(call("back")).toEqual({
|
|
88
|
+
from: "video-modal/v1",
|
|
89
|
+
wentBack: false,
|
|
90
|
+
blockedBy: "video-modal",
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
modalStore.getState().openModal({ item: { id: "m" } });
|
|
94
|
+
|
|
95
|
+
expect(call("back")).toMatchObject({ blockedBy: "modal" });
|
|
96
|
+
expect(nav.goBack).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("only dismisses Play Next when it shows", () => {
|
|
100
|
+
const nav = navigator({ getPlayNextOverlay: () => ({}) });
|
|
101
|
+
|
|
102
|
+
expect(call("back")).toMatchObject({
|
|
103
|
+
wentBack: false,
|
|
104
|
+
dismissed: "playNext",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(nav.goBack).toHaveBeenCalledWith(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("leaves a hook screen to cancel itself", () => {
|
|
111
|
+
const nav = navigator({ getPathname: () => "/river/a/hooks/login" });
|
|
112
|
+
|
|
113
|
+
expect(call("back")).toMatchObject({ wentBack: false, blockedBy: "hook" });
|
|
114
|
+
expect(nav.goBack).not.toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("goes home", () => {
|
|
118
|
+
const nav = navigator();
|
|
119
|
+
|
|
120
|
+
expect(call("home")).toEqual({ from: "/river/a/river/b", wentHome: true });
|
|
121
|
+
expect(nav.goHome).toHaveBeenCalled();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("does not go home under a modal or the video modal", () => {
|
|
125
|
+
const nav = navigator(videoModal("MAXIMIZED"));
|
|
126
|
+
|
|
127
|
+
expect(call("home")).toEqual({
|
|
128
|
+
from: "video-modal/v1",
|
|
129
|
+
wentHome: false,
|
|
130
|
+
blockedBy: "video-modal",
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
modalStore.getState().openModal({ item: { id: "m" } });
|
|
134
|
+
|
|
135
|
+
expect(call("home")).toMatchObject({ blockedBy: "modal" });
|
|
136
|
+
expect(nav.goHome).not.toHaveBeenCalled();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("goes home with the mini player docked", () => {
|
|
140
|
+
const nav = navigator(videoModal("MINIMIZED"));
|
|
141
|
+
|
|
142
|
+
expect(call("home")).toMatchObject({ wentHome: true });
|
|
143
|
+
expect(nav.goHome).toHaveBeenCalled();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("dismisses the modal store's modal, not the video modal", () => {
|
|
147
|
+
const nav = navigator(videoModal("MAXIMIZED"));
|
|
148
|
+
|
|
149
|
+
expect(() => call("dismissModal")).toThrow("No modal is open");
|
|
150
|
+
|
|
151
|
+
modalStore.getState().openModal({ item: { id: "m" } });
|
|
152
|
+
|
|
153
|
+
expect(call("dismissModal")).toBe("OK");
|
|
154
|
+
expect(nav.dismissModal).toHaveBeenCalled();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("minimises only a maximised video modal", () => {
|
|
158
|
+
const fullscreen = navigator(videoModal("FULLSCREEN"));
|
|
159
|
+
|
|
160
|
+
expect(() => call("minimizeVideoModal")).toThrow("mode is FULLSCREEN");
|
|
161
|
+
expect(fullscreen.minimiseVideoModal).not.toHaveBeenCalled();
|
|
162
|
+
|
|
163
|
+
const maximized = navigator(videoModal("MAXIMIZED"));
|
|
164
|
+
|
|
165
|
+
call("minimizeVideoModal");
|
|
166
|
+
|
|
167
|
+
expect(maximized.minimiseVideoModal).toHaveBeenCalled();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("enters and leaves fullscreen through the player, which also rotates the phone", () => {
|
|
171
|
+
const invoke = jest.spyOn(playerManager, "invokeHandler");
|
|
172
|
+
const playerContainer = jest.fn();
|
|
173
|
+
|
|
174
|
+
playerManager.on("toggleFullscreen", playerContainer);
|
|
175
|
+
|
|
176
|
+
navigator(videoModal("MAXIMIZED"));
|
|
177
|
+
call("fullscreenVideoModal");
|
|
178
|
+
|
|
179
|
+
expect(invoke).toHaveBeenCalledWith("toggleFullscreen", {
|
|
180
|
+
fullscreen: true,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const fullscreen = navigator(videoModal("FULLSCREEN"));
|
|
184
|
+
|
|
185
|
+
call("maximizeVideoModal");
|
|
186
|
+
|
|
187
|
+
expect(invoke).toHaveBeenCalledWith("toggleFullscreen", {
|
|
188
|
+
fullscreen: false,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(fullscreen.maximiseVideoModal).not.toHaveBeenCalled();
|
|
192
|
+
|
|
193
|
+
playerManager.removeHandler("toggleFullscreen", playerContainer);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("does not claim fullscreen without a player, or from the mini player", () => {
|
|
197
|
+
navigator(videoModal("MAXIMIZED"));
|
|
198
|
+
|
|
199
|
+
expect(() => call("fullscreenVideoModal")).toThrow("shows no player");
|
|
200
|
+
|
|
201
|
+
navigator(videoModal("MINIMIZED"));
|
|
202
|
+
|
|
203
|
+
expect(() => call("fullscreenVideoModal")).toThrow("mode is MINIMIZED");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("expands the mini player", () => {
|
|
207
|
+
const nav = navigator(videoModal("MINIMIZED"));
|
|
208
|
+
|
|
209
|
+
call("maximizeVideoModal");
|
|
210
|
+
|
|
211
|
+
expect(nav.maximiseVideoModal).toHaveBeenCalled();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("reports the video modal", () => {
|
|
215
|
+
navigator(videoModal("MINIMIZED"));
|
|
216
|
+
|
|
217
|
+
expect(call("videoModal")).toMatchObject({
|
|
218
|
+
visible: true,
|
|
219
|
+
mode: "MINIMIZED",
|
|
220
|
+
entry: { id: "v1", title: "V" },
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
navigator();
|
|
224
|
+
|
|
225
|
+
expect(call("videoModal")).toMatchObject({ visible: false });
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -410,6 +410,62 @@ describe("<NavigationProvider />", () => {
|
|
|
410
410
|
});
|
|
411
411
|
});
|
|
412
412
|
|
|
413
|
+
describe("when backToTop is requested from a non-home tab", () => {
|
|
414
|
+
const listenRiver = {
|
|
415
|
+
id: "LISTEN",
|
|
416
|
+
home: false,
|
|
417
|
+
navigations: [],
|
|
418
|
+
ui_components: [],
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
beforeEach(() => {
|
|
422
|
+
wrapper = renderContextConsumer({
|
|
423
|
+
...store,
|
|
424
|
+
rivers: { ...rivers, LISTEN: listenRiver },
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
act(() => {
|
|
428
|
+
getNavigatorValues(wrapper).navigator.replace(listenRiver);
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("returns to the tab root from the second level", () => {
|
|
433
|
+
act(() => {
|
|
434
|
+
getNavigatorValues(wrapper).navigator.push(rivers.A1234);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
act(() => {
|
|
438
|
+
getNavigatorValues(wrapper).navigator.goBack(false, false, true);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
const { navigator, state } = getNavigatorValues(wrapper);
|
|
442
|
+
|
|
443
|
+
expect(navigator.currentRoute).toEqual("/river/LISTEN");
|
|
444
|
+
expect(navigator.activeRiver).toEqual(listenRiver);
|
|
445
|
+
expect(state.stack.mainStack).toHaveLength(1);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("returns to the tab root from the third level", () => {
|
|
449
|
+
act(() => {
|
|
450
|
+
getNavigatorValues(wrapper).navigator.push(rivers.A1234);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
act(() => {
|
|
454
|
+
getNavigatorValues(wrapper).navigator.push(rivers.C0987);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
act(() => {
|
|
458
|
+
getNavigatorValues(wrapper).navigator.goBack(false, false, true);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
const { navigator, state } = getNavigatorValues(wrapper);
|
|
462
|
+
|
|
463
|
+
expect(navigator.currentRoute).toEqual("/river/LISTEN");
|
|
464
|
+
expect(navigator.activeRiver).toEqual(listenRiver);
|
|
465
|
+
expect(state.stack.mainStack).toHaveLength(1);
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
413
469
|
describe("when there is a previous route and there are hooks", () => {
|
|
414
470
|
beforeEach(() => {
|
|
415
471
|
wrapper = renderContextConsumer();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { targetShouldOpenExternally } = require("../utils");
|
|
1
|
+
const { targetShouldOpenExternally, stripHookPrefix } = require("../utils");
|
|
2
2
|
|
|
3
3
|
const mockedOpenUrl = jest.fn();
|
|
4
4
|
|
|
@@ -116,3 +116,33 @@ describe("targetShouldOpenExternally", () => {
|
|
|
116
116
|
});
|
|
117
117
|
});
|
|
118
118
|
});
|
|
119
|
+
|
|
120
|
+
describe("stripHookPrefix", () => {
|
|
121
|
+
const HOOK = "203d96a9-f4af-49d1-9890-435e683b9274";
|
|
122
|
+
const MANAGE = "9f8e1e06-abd6-48b9-b73a-4caf4601f870";
|
|
123
|
+
|
|
124
|
+
it("drops the hook segment so a hook screen navigates from the root", () => {
|
|
125
|
+
expect(stripHookPrefix(`/hooks/${HOOK}`)).toBe("");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("keeps the screens opened from a hook, without their hook prefix", () => {
|
|
129
|
+
expect(stripHookPrefix(`/hooks/${HOOK}/river/${MANAGE}`)).toBe(
|
|
130
|
+
`/river/${MANAGE}`
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("leaves a regular route untouched", () => {
|
|
135
|
+
expect(stripHookPrefix(`/river/${MANAGE}`)).toBe(`/river/${MANAGE}`);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("only strips a hook segment at the start of the path", () => {
|
|
139
|
+
expect(stripHookPrefix(`/river/${MANAGE}/hooks/${HOOK}`)).toBe(
|
|
140
|
+
`/river/${MANAGE}/hooks/${HOOK}`
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("returns an empty prefix for an empty or missing path", () => {
|
|
145
|
+
expect(stripHookPrefix("")).toBe("");
|
|
146
|
+
expect(stripHookPrefix(undefined)).toBe("");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { Dimensions } from "react-native";
|
|
2
|
+
import {
|
|
3
|
+
registerXrayToolbox,
|
|
4
|
+
XrayToolSpec,
|
|
5
|
+
} from "@applicaster/zapp-react-native-utils/logger/xrayToolbox";
|
|
6
|
+
import { getXrayNavigator } from "@applicaster/zapp-react-native-utils/navigationUtils/xrayNavigator";
|
|
7
|
+
import { playerManager } from "@applicaster/zapp-react-native-utils/appUtils/playerManager";
|
|
8
|
+
import { getTopPathname } from "@applicaster/zapp-react-native-ui-components/Decorators/ZappPipesDataConnector/screenToolbox";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* X-Ray `nav.*` toolbox: lets an agent navigate by intent and read or drive the video modal,
|
|
12
|
+
* which is navigation state. The swipes behind some of these (minimising the player, dragging a
|
|
13
|
+
* sheet) are react-native-gesture-handler recognisers that no public API can feed touches to, so
|
|
14
|
+
* the outcome is offered instead. Pairs with `screen.current` to check where an action landed.
|
|
15
|
+
*
|
|
16
|
+
* Registered by the NavigationProvider, which keeps the navigator in a plain module variable
|
|
17
|
+
* (xrayNavigator), so updating it never renders anything.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const requireNavigator = (): QuickBrickAppNavigator => {
|
|
21
|
+
const navigator = getXrayNavigator();
|
|
22
|
+
|
|
23
|
+
if (!navigator) throw new Error("Navigation is not available yet");
|
|
24
|
+
|
|
25
|
+
return navigator;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** The player's own fullscreen switch: it also rotates the phone, as the fullscreen button does. */
|
|
29
|
+
const toggleFullscreen = (fullscreen: boolean) => {
|
|
30
|
+
// Only a mounted PlayerContainer handles it: not while the video modal runs its hooks or casts.
|
|
31
|
+
if (!playerManager.hasHandler("toggleFullscreen")) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
"The video modal shows no player (hooks running or casting)"
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
playerManager.invokeHandler("toggleFullscreen", { fullscreen });
|
|
38
|
+
|
|
39
|
+
return "OK: the player switches on its next tick, check with videoModal";
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const requireVideoModal = () => {
|
|
43
|
+
const nav = requireNavigator();
|
|
44
|
+
|
|
45
|
+
if (!nav.videoModalState?.visible) {
|
|
46
|
+
throw new Error("No video modal is open");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return nav;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const tools: XrayToolSpec[] = [
|
|
53
|
+
{
|
|
54
|
+
name: "stack",
|
|
55
|
+
description: "Routes on the navigation stack, root first.",
|
|
56
|
+
handler: () => ({
|
|
57
|
+
routes: requireNavigator()
|
|
58
|
+
.getStack()
|
|
59
|
+
.map(({ route }) => route),
|
|
60
|
+
}),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "back",
|
|
64
|
+
description:
|
|
65
|
+
"Go back one screen, as the nav bar back button does: it pops the stack and does not run " +
|
|
66
|
+
"hardware back listeners (Android back key), so a screen plugin's own back handling is skipped. " +
|
|
67
|
+
"Not while a modal, the video modal (unless docked) or a hook screen is on top: blockedBy says " +
|
|
68
|
+
"which; close it first, or cancel a hook with the device back key. With Play Next showing it " +
|
|
69
|
+
"only dismisses that. Check with screen.current.",
|
|
70
|
+
handler: () => {
|
|
71
|
+
const nav = requireNavigator();
|
|
72
|
+
const from = getTopPathname() ?? nav.getPathname();
|
|
73
|
+
|
|
74
|
+
// A presented layer (hooks-modal/, modal/, video-modal/) is on top: popping the stack would
|
|
75
|
+
// change the screen underneath it, which the user's back never does.
|
|
76
|
+
if (!from.startsWith("/")) {
|
|
77
|
+
return { from, wentBack: false, blockedBy: from.split("/")[0] };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (nav.getPlayNextOverlay()) {
|
|
81
|
+
nav.goBack(false);
|
|
82
|
+
|
|
83
|
+
return { from, wentBack: false, dismissed: "playNext" };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The hook screen answers the user's back itself (cancels its flow); a pop would skip that.
|
|
87
|
+
if (from.split("/").slice(-2)[0] === "hooks") {
|
|
88
|
+
return { from, wentBack: false, blockedBy: "hook" };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!nav.canGoBack()) return { from, wentBack: false };
|
|
92
|
+
|
|
93
|
+
nav.goBack(false);
|
|
94
|
+
|
|
95
|
+
return { from, wentBack: true };
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: "home",
|
|
100
|
+
description:
|
|
101
|
+
"Go to the home screen. Not while a modal, the video modal (unless docked) or the hooks modal is " +
|
|
102
|
+
"on top: blockedBy says which; close it first. On TV it does not move focus to the top menu as " +
|
|
103
|
+
"the remote's home does.",
|
|
104
|
+
handler: () => {
|
|
105
|
+
const nav = requireNavigator();
|
|
106
|
+
const from = getTopPathname() ?? nav.getPathname();
|
|
107
|
+
|
|
108
|
+
// goHome only replaces the stack: a presented layer would stay on top of the home screen,
|
|
109
|
+
// and it covers the menu the user would press home in.
|
|
110
|
+
if (!from.startsWith("/")) {
|
|
111
|
+
return { from, wentHome: false, blockedBy: from.split("/")[0] };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
nav.goHome();
|
|
115
|
+
|
|
116
|
+
return { from, wentHome: true };
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: "dismissModal",
|
|
121
|
+
description:
|
|
122
|
+
"Close the open modal screen or bottom sheet, as dragging it down does. Not the video modal: use closeVideoModal.",
|
|
123
|
+
handler: () => {
|
|
124
|
+
const nav = requireNavigator();
|
|
125
|
+
|
|
126
|
+
if (!nav.getModalState()?.visible) throw new Error("No modal is open");
|
|
127
|
+
|
|
128
|
+
nav.dismissModal();
|
|
129
|
+
|
|
130
|
+
return "OK";
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: "videoModal",
|
|
135
|
+
description:
|
|
136
|
+
"The video modal: visible, mode (MAXIMIZED, MINIMIZED = mini player, FULLSCREEN, PIP), its entry, " +
|
|
137
|
+
"and the screen orientation.",
|
|
138
|
+
handler: () => {
|
|
139
|
+
const videoModal = requireNavigator().videoModalState;
|
|
140
|
+
const { width, height } = Dimensions.get("window");
|
|
141
|
+
const orientation = width > height ? "landscape" : "portrait";
|
|
142
|
+
|
|
143
|
+
if (!videoModal?.visible) return { visible: false, orientation };
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
visible: true,
|
|
147
|
+
mode: videoModal.mode,
|
|
148
|
+
entry: videoModal.item
|
|
149
|
+
? { id: videoModal.item.id, title: videoModal.item.title }
|
|
150
|
+
: null,
|
|
151
|
+
orientation,
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
name: "minimizeVideoModal",
|
|
157
|
+
description:
|
|
158
|
+
"Minimise the maximised video modal to the mini player, as swiping it down does.",
|
|
159
|
+
handler: () => {
|
|
160
|
+
const nav = requireVideoModal();
|
|
161
|
+
const { mode } = nav.videoModalState;
|
|
162
|
+
|
|
163
|
+
// The swipe exists only on the maximised modal; other modes have no way to the mini player.
|
|
164
|
+
if (mode !== "MAXIMIZED") {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`Only a maximised video modal minimises (mode is ${mode})`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
nav.minimiseVideoModal();
|
|
171
|
+
|
|
172
|
+
return "OK";
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
name: "maximizeVideoModal",
|
|
177
|
+
description: "Expand the mini player or leave fullscreen.",
|
|
178
|
+
handler: () => {
|
|
179
|
+
const nav = requireVideoModal();
|
|
180
|
+
|
|
181
|
+
if (nav.videoModalState.mode === "FULLSCREEN") {
|
|
182
|
+
return toggleFullscreen(false);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
nav.maximiseVideoModal();
|
|
186
|
+
|
|
187
|
+
return "OK";
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: "fullscreenVideoModal",
|
|
192
|
+
description:
|
|
193
|
+
"Put the video modal in fullscreen, as the player's fullscreen button does.",
|
|
194
|
+
handler: () => {
|
|
195
|
+
const { mode } = requireVideoModal().videoModalState;
|
|
196
|
+
|
|
197
|
+
// The fullscreen button is on the maximised player, not on the mini player.
|
|
198
|
+
if (mode !== "MAXIMIZED") {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`Only a maximised video modal goes fullscreen (mode is ${mode})`
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return toggleFullscreen(true);
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
name: "closeVideoModal",
|
|
209
|
+
description: "Close the video modal and its player.",
|
|
210
|
+
handler: () => {
|
|
211
|
+
requireVideoModal().closeVideoModal();
|
|
212
|
+
|
|
213
|
+
return "OK";
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
export const registerNavToolbox = () =>
|
|
219
|
+
registerXrayToolbox({
|
|
220
|
+
name: "nav",
|
|
221
|
+
description:
|
|
222
|
+
"Navigate the app (stack, back, home, modals) and drive the video modal.",
|
|
223
|
+
tools,
|
|
224
|
+
});
|
|
@@ -18,6 +18,20 @@ const WEBVIEW_SCREEN_IDENTIFIER = "webview_screen_qb";
|
|
|
18
18
|
|
|
19
19
|
const logger = coreAppLogger.addSubsystem("Navigator");
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Drops a leading `/hooks/<screen_id>` segment from a path.
|
|
23
|
+
*
|
|
24
|
+
* Routes are built by appending to the current path, and a hook screen is
|
|
25
|
+
* presented at an absolute `/hooks/<screen_id>`. Without this, every screen
|
|
26
|
+
* opened from a hook would inherit that prefix for good: the hook's stack entry
|
|
27
|
+
* would stay buried under its descendants, and `canGoBack` — which treats any
|
|
28
|
+
* route containing `/hooks` as a hook — would count the whole branch as one
|
|
29
|
+
* entry and send Back home instead of popping.
|
|
30
|
+
*/
|
|
31
|
+
export function stripHookPrefix(pathname?: string): string {
|
|
32
|
+
return pathname?.replace(/^\/hooks\/[^/]+/, "") ?? "";
|
|
33
|
+
}
|
|
34
|
+
|
|
21
35
|
export function targetShouldOpenExternally(
|
|
22
36
|
target: ZappEntry,
|
|
23
37
|
targetScreen: ZappRiver | null,
|
|
@@ -169,6 +183,27 @@ export function getNavigationTarget(
|
|
|
169
183
|
data.entry = targetEntry;
|
|
170
184
|
}
|
|
171
185
|
|
|
186
|
+
if (data.entry && !data.screen) {
|
|
187
|
+
const entryType = (data.entry.type as ZappEntryType)?.value;
|
|
188
|
+
const mappedScreenId = contentTypes?.[entryType]?.screen_id;
|
|
189
|
+
|
|
190
|
+
const mappedRiver = mappedScreenId ? rivers?.[mappedScreenId] : undefined;
|
|
191
|
+
|
|
192
|
+
if (!mappedScreenId) {
|
|
193
|
+
logger.error({
|
|
194
|
+
message: `getNavigationTarget: mapped screen ID missing for content type "${entryType}". Check your types mapping or screen configuration.`,
|
|
195
|
+
data: { entry: data.entry },
|
|
196
|
+
});
|
|
197
|
+
} else if (!mappedRiver) {
|
|
198
|
+
logger.error({
|
|
199
|
+
message: `getNavigationTarget: cannot map screen ID "${mappedScreenId}" to a river. Check your types mapping or screen configuration.`,
|
|
200
|
+
data: { entry: data.entry, mappedScreenId },
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
data.screen = mappedRiver;
|
|
205
|
+
}
|
|
206
|
+
|
|
172
207
|
return data;
|
|
173
208
|
}
|
|
174
209
|
|
|
@@ -12,6 +12,11 @@ import {
|
|
|
12
12
|
unsubscribeToDeviceNetworkChanges,
|
|
13
13
|
} from "./utils";
|
|
14
14
|
import { usePrevious } from "@applicaster/zapp-react-native-utils/reactHooks/utils";
|
|
15
|
+
import { coreAppLogger } from "../logger";
|
|
16
|
+
import { useResumeVerifiedNetInfo } from "./useResumeVerifiedNetInfo";
|
|
17
|
+
import { describeNetInfo } from "./resumeVerification";
|
|
18
|
+
|
|
19
|
+
const logger = coreAppLogger.addSubsystem("NetworkStatusProvider");
|
|
15
20
|
|
|
16
21
|
// Use default netInfo config for all platforms (important for web platform which otherwise
|
|
17
22
|
// will use backend root which is not responding for HEAD request)
|
|
@@ -36,10 +41,23 @@ export const NetworkStatusProvider = ({
|
|
|
36
41
|
// NetInfo library does not have accurate information for Tizen SmartTVs, and possibly WebOS
|
|
37
42
|
// This combined info helps us gather data from the native connection status APIs
|
|
38
43
|
// and includes it in our definition of... online.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
deviceStatus
|
|
42
|
-
|
|
44
|
+
// stable reference between real changes: every new reference is a network update
|
|
45
|
+
const liveNetInfo = React.useMemo<
|
|
46
|
+
NetInfoState & { deviceStatus?: NetStatus }
|
|
47
|
+
>(
|
|
48
|
+
() => ({ ...currentNetInfo, deviceStatus }),
|
|
49
|
+
[currentNetInfo, deviceStatus]
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// NetInfo reports stale offline status right after resume/unlock (iOS),
|
|
53
|
+
// publish the network status verified after the app returns to foreground
|
|
54
|
+
const { netInfo: verifiedNetInfo, stillOfflineAfterResumeCount } =
|
|
55
|
+
useResumeVerifiedNetInfo(liveNetInfo);
|
|
56
|
+
|
|
57
|
+
const combinedNetInfo: NetInfoState & {
|
|
58
|
+
deviceStatus?: NetStatus;
|
|
59
|
+
stillOfflineAfterResumeCount: number;
|
|
60
|
+
} = { ...verifiedNetInfo, stillOfflineAfterResumeCount };
|
|
43
61
|
|
|
44
62
|
const { onConnectionTypeChanged, onConnectionLost, onConnectionRestored } =
|
|
45
63
|
React.useMemo<NetworkHooks>(() => getNetworkHooks(plugins), []);
|
|
@@ -57,6 +75,16 @@ export const NetworkStatusProvider = ({
|
|
|
57
75
|
if (!combinedNetInfo.details) return;
|
|
58
76
|
|
|
59
77
|
if (previousOnline !== undefined && previousOnline !== online) {
|
|
78
|
+
logger.info({
|
|
79
|
+
message: "NetworkStatus: online status changed",
|
|
80
|
+
data: {
|
|
81
|
+
online,
|
|
82
|
+
previousOnline,
|
|
83
|
+
published: describeNetInfo(combinedNetInfo),
|
|
84
|
+
live: describeNetInfo(liveNetInfo),
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
60
88
|
online
|
|
61
89
|
? onConnectionRestored &&
|
|
62
90
|
callAllWith(onConnectionRestored, combinedNetInfo)
|