@applicaster/quick-brick-core 16.0.0-alpha.1263110541 → 16.0.0-alpha.1314750780
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/LegacyActionsRegistryAdapter.tsx +16 -0
- package/App/ActionsProvider/__tests__/LegacyActionsRegistryAdapter.test.tsx +64 -0
- package/App/DeepLinking/URLSchemeHandler/SchemeHandlerHooks/usePresentSchemeHandler.ts +3 -0
- 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 +20 -9
- package/App/NavigationProvider/__tests__/navToolbox.test.ts +227 -0
- package/App/NavigationProvider/__tests__/navigationProvider.test.tsx +56 -0
- package/App/NavigationProvider/navToolbox.ts +224 -0
- package/App/NavigationProvider/utils.ts +21 -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/NotificationToastRenderer.tv.tsx +6 -127
- package/App/NotificationToastRenderer/index.tsx +0 -2
- package/App/NotificationToastRenderer/useNotificationHeight.tsx +1 -0
- package/App/index.tsx +8 -2
- package/App/layoutPreview/LayoutPreviewReminder.tsx +136 -0
- package/App/layoutPreview/__tests__/persistence.test.ts +63 -0
- package/App/layoutPreview/persistence.ts +228 -0
- package/App/remoteContextReloader/getRemoteContextData/getNativeRemoteContextData.ts +64 -19
- package/App/uiActions/index.ts +13 -0
- package/App/uiActions/muteUnmute/__tests__/assetResolver.test.ts +226 -0
- package/App/uiActions/muteUnmute/__tests__/binding.test.ts +51 -0
- package/App/uiActions/muteUnmute/__tests__/defaultAssets.test.ts +73 -0
- package/App/uiActions/muteUnmute/__tests__/entryState.test.ts +57 -0
- package/App/uiActions/muteUnmute/__tests__/labelResolver.test.ts +35 -0
- package/App/uiActions/muteUnmute/assetResolver.ts +87 -0
- package/App/uiActions/muteUnmute/assets/action/flavour_1/mute.png +0 -0
- package/App/uiActions/muteUnmute/assets/action/flavour_1/unmute.png +0 -0
- package/App/uiActions/muteUnmute/assets/action/flavour_2/mute.png +0 -0
- package/App/uiActions/muteUnmute/assets/action/flavour_2/unmute.png +0 -0
- package/App/uiActions/muteUnmute/assets/button/flavour_1/mute.png +0 -0
- package/App/uiActions/muteUnmute/assets/button/flavour_1/unmute.png +0 -0
- package/App/uiActions/muteUnmute/assets/button/flavour_2/mute.png +0 -0
- package/App/uiActions/muteUnmute/assets/button/flavour_2/unmute.png +0 -0
- package/App/uiActions/muteUnmute/defaultAssets.ts +33 -0
- package/App/uiActions/muteUnmute/entryState.ts +28 -0
- package/App/uiActions/muteUnmute/index.ts +79 -0
- package/App/uiActions/muteUnmute/labelResolver.ts +15 -0
- package/App/uiActions/muteUnmute/types.ts +18 -0
- package/package.json +8 -8
|
@@ -66,6 +66,22 @@ function LegacyActionBridge({ plugin }: { plugin: LegacyActionPlugin }) {
|
|
|
66
66
|
};
|
|
67
67
|
}, [plugin.identifier]);
|
|
68
68
|
|
|
69
|
+
// Consumers resolve a snapshot of `value`; when the plugin publishes a new
|
|
70
|
+
// one (e.g. cast devices found), make them re-resolve so availability
|
|
71
|
+
// predicates are not read from a stale value. Registration already
|
|
72
|
+
// notifies, so the first render is skipped.
|
|
73
|
+
const isFirstValueRef = React.useRef(true);
|
|
74
|
+
|
|
75
|
+
React.useEffect(() => {
|
|
76
|
+
if (isFirstValueRef.current) {
|
|
77
|
+
isFirstValueRef.current = false;
|
|
78
|
+
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
uiActionsRegistry.notifyProviderChanged(plugin.identifier);
|
|
83
|
+
}, [value, plugin.identifier]);
|
|
84
|
+
|
|
69
85
|
return null;
|
|
70
86
|
}
|
|
71
87
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render } from "@testing-library/react-native";
|
|
3
|
+
import { uiActionsRegistry } from "@applicaster/zapp-react-native-utils/uiActionsRegistrator";
|
|
4
|
+
import { LegacyActionsRegistryAdapter } from "../LegacyActionsRegistryAdapter";
|
|
5
|
+
|
|
6
|
+
const CastContext = React.createContext<any>(null);
|
|
7
|
+
|
|
8
|
+
const plugin = {
|
|
9
|
+
identifier: "cast-action-test",
|
|
10
|
+
module: { context: CastContext },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const renderWithValue = (value) => (
|
|
14
|
+
<CastContext.Provider value={value}>
|
|
15
|
+
<LegacyActionsRegistryAdapter actionPlugins={{ cast: plugin } as any} />
|
|
16
|
+
</CastContext.Provider>
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
describe("LegacyActionsRegistryAdapter", () => {
|
|
20
|
+
it("makes consumers re-resolve when the plugin publishes a new value", () => {
|
|
21
|
+
const noDevices = { isActionAvailable: () => false };
|
|
22
|
+
const devicesFound = { isActionAvailable: () => true };
|
|
23
|
+
|
|
24
|
+
const { rerender, unmount } = render(renderWithValue(noDevices));
|
|
25
|
+
|
|
26
|
+
// what a consumer resolved at first paint
|
|
27
|
+
expect(
|
|
28
|
+
uiActionsRegistry
|
|
29
|
+
.getAction("cast-action-test")
|
|
30
|
+
?.isActionAvailable?.({} as any)
|
|
31
|
+
).toBe(false);
|
|
32
|
+
|
|
33
|
+
const listener = jest.fn();
|
|
34
|
+
const unsubscribe = uiActionsRegistry.subscribe(listener);
|
|
35
|
+
|
|
36
|
+
rerender(renderWithValue(devicesFound));
|
|
37
|
+
|
|
38
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
39
|
+
|
|
40
|
+
expect(
|
|
41
|
+
uiActionsRegistry
|
|
42
|
+
.getAction("cast-action-test")
|
|
43
|
+
?.isActionAvailable?.({} as any)
|
|
44
|
+
).toBe(true);
|
|
45
|
+
|
|
46
|
+
unsubscribe();
|
|
47
|
+
unmount();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("does not notify again when the value is unchanged", () => {
|
|
51
|
+
const stable = { isActionAvailable: () => true };
|
|
52
|
+
const { rerender, unmount } = render(renderWithValue(stable));
|
|
53
|
+
|
|
54
|
+
const listener = jest.fn();
|
|
55
|
+
const unsubscribe = uiActionsRegistry.subscribe(listener);
|
|
56
|
+
|
|
57
|
+
rerender(renderWithValue(stable));
|
|
58
|
+
|
|
59
|
+
expect(listener).not.toHaveBeenCalled();
|
|
60
|
+
|
|
61
|
+
unsubscribe();
|
|
62
|
+
unmount();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
|
|
29
29
|
import { log_error, log_info } from "../../logger";
|
|
30
30
|
import { useRivers } from "@applicaster/zapp-react-native-utils/reactHooks/state";
|
|
31
|
+
import { rememberLayoutPreview } from "../../../layoutPreview/persistence";
|
|
31
32
|
|
|
32
33
|
const getFeed = (query) => ({
|
|
33
34
|
content: { src: query.data_source },
|
|
@@ -155,6 +156,8 @@ export function usePresentSchemeHandler({
|
|
|
155
156
|
rivers_configuration_id
|
|
156
157
|
);
|
|
157
158
|
|
|
159
|
+
await rememberLayoutPreview(rivers_configuration_id);
|
|
160
|
+
|
|
158
161
|
onFinish((done) => {
|
|
159
162
|
actions?.setAppReady();
|
|
160
163
|
navigator.replace(homeRiver);
|
|
@@ -30,6 +30,9 @@ export function ModalContent(props: Props) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
return (
|
|
33
|
+
// A presented screen fills the window, including the status bar and home
|
|
34
|
+
// indicator. The modal wraps it in its own SafeAreaProvider, so the screen
|
|
35
|
+
// reads the real insets itself and places its own chrome inside them.
|
|
33
36
|
<View style={styles.container}>
|
|
34
37
|
<ScreenResolver {...modalScreenProps} />
|
|
35
38
|
</View>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { StyleSheet } from "react-native";
|
|
3
|
+
import { render } from "@testing-library/react-native";
|
|
4
|
+
|
|
5
|
+
import { ModalContent } from "../ModalContent";
|
|
6
|
+
|
|
7
|
+
jest.mock("../ModalBottomSheet", () => ({ ModalBottomSheet: () => null }));
|
|
8
|
+
|
|
9
|
+
jest.mock(
|
|
10
|
+
"@applicaster/zapp-react-native-ui-components/Components/ScreenResolver",
|
|
11
|
+
() => ({ ScreenResolver: () => null })
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
const flatStyle = (tree: any) => StyleSheet.flatten(tree.props.style);
|
|
15
|
+
|
|
16
|
+
describe("ModalContent", () => {
|
|
17
|
+
it("lets a presented screen fill the modal, leaving the safe area to the screen", () => {
|
|
18
|
+
const tree = render(
|
|
19
|
+
<ModalContent modalScreenProps={{ screenId: "river-1" }} />
|
|
20
|
+
).toJSON();
|
|
21
|
+
|
|
22
|
+
// The modal provides a SafeAreaProvider instead, so the presented screen
|
|
23
|
+
// reads the real insets itself rather than being padded from the outside.
|
|
24
|
+
expect(flatStyle(tree)).toEqual({ flex: 1 });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("leaves a bottom sheet full-bleed", () => {
|
|
28
|
+
const tree = render(
|
|
29
|
+
<ModalContent
|
|
30
|
+
modalScreenProps={{
|
|
31
|
+
screenData: { modalBottomSheetContentProps: { items: [] } },
|
|
32
|
+
}}
|
|
33
|
+
/>
|
|
34
|
+
).toJSON();
|
|
35
|
+
|
|
36
|
+
// Bottom sheets anchor to the screen edge and manage their own insets.
|
|
37
|
+
expect(tree).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render } from "@testing-library/react-native";
|
|
3
|
+
|
|
4
|
+
import { modalStore } from "@applicaster/zapp-react-native-utils/modalState";
|
|
5
|
+
|
|
6
|
+
import { ModalProvider } from "../";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Records the ScreenDataContext value seen by the presented screen — the
|
|
10
|
+
* context useScreenContext()/useCurrentScreenData() read from.
|
|
11
|
+
*/
|
|
12
|
+
const mockSeenScreenData: any[] = [];
|
|
13
|
+
|
|
14
|
+
/** Records the `initialMetrics` each SafeAreaProvider the modal renders got. */
|
|
15
|
+
const mockSafeAreaProviderMetrics: any[] = [];
|
|
16
|
+
|
|
17
|
+
jest.mock(
|
|
18
|
+
"@applicaster/zapp-react-native-ui-components/Components/ScreenResolver",
|
|
19
|
+
() => {
|
|
20
|
+
const ReactLocal = require("react");
|
|
21
|
+
|
|
22
|
+
const {
|
|
23
|
+
ScreenDataContext,
|
|
24
|
+
} = require("@applicaster/zapp-react-native-ui-components/Contexts/ScreenDataContext");
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
ScreenResolver: () => {
|
|
28
|
+
mockSeenScreenData.push(ReactLocal.useContext(ScreenDataContext));
|
|
29
|
+
|
|
30
|
+
return null;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
// Needs the navigator + redux to build navbar state; irrelevant to this test.
|
|
37
|
+
jest.mock(
|
|
38
|
+
"@applicaster/zapp-react-native-ui-components/Contexts/ScreenContext",
|
|
39
|
+
() => ({
|
|
40
|
+
ScreenContextProvider: ({ children }: any) => children,
|
|
41
|
+
})
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// Pulls in the whole Components barrel; only the ScreenResolver branch matters here.
|
|
45
|
+
jest.mock("../ModalBottomSheet", () => ({ ModalBottomSheet: () => null }));
|
|
46
|
+
|
|
47
|
+
jest.mock("react-native-safe-area-context", () => ({
|
|
48
|
+
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
|
|
49
|
+
// A modal renders in its own native window, so it needs its own provider
|
|
50
|
+
// seeded with the window metrics; without them insets read as zero inside.
|
|
51
|
+
initialWindowMetrics: { insets: { top: 59, bottom: 34, left: 0, right: 0 } },
|
|
52
|
+
SafeAreaProvider: ({ children, initialMetrics }: any) => {
|
|
53
|
+
mockSafeAreaProviderMetrics.push(initialMetrics);
|
|
54
|
+
|
|
55
|
+
return children;
|
|
56
|
+
},
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
const river = { id: "river-parent-lock", type: "parent_lock" } as any;
|
|
60
|
+
|
|
61
|
+
const entry = { id: "entry-1", type: { value: "video" } } as any;
|
|
62
|
+
|
|
63
|
+
const seen = () => mockSeenScreenData[mockSeenScreenData.length - 1];
|
|
64
|
+
|
|
65
|
+
describe("ModalProvider", () => {
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
mockSeenScreenData.length = 0;
|
|
68
|
+
mockSafeAreaProviderMetrics.length = 0;
|
|
69
|
+
modalStore.getState().dismissModal();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("gives the presented screen its screen and entry through ScreenDataContext", () => {
|
|
73
|
+
modalStore.getState().openModal({ item: river, props: { entry } });
|
|
74
|
+
|
|
75
|
+
render(<ModalProvider />);
|
|
76
|
+
|
|
77
|
+
expect(seen()).toEqual({ screen: river, entry });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("gives the presented screen its screen when presented without an entry", () => {
|
|
81
|
+
modalStore.getState().openModal({ item: river });
|
|
82
|
+
|
|
83
|
+
render(<ModalProvider />);
|
|
84
|
+
|
|
85
|
+
expect(seen()).toMatchObject({ screen: river });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("seeds the presented screen's safe area with the window metrics", () => {
|
|
89
|
+
const { initialWindowMetrics } = require("react-native-safe-area-context");
|
|
90
|
+
|
|
91
|
+
modalStore.getState().openModal({ item: river });
|
|
92
|
+
|
|
93
|
+
render(<ModalProvider />);
|
|
94
|
+
|
|
95
|
+
expect(mockSafeAreaProviderMetrics).toEqual(
|
|
96
|
+
expect.arrayContaining([initialWindowMetrics])
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
|
|
3
|
+
import {
|
|
4
|
+
initialWindowMetrics,
|
|
5
|
+
SafeAreaProvider,
|
|
6
|
+
} from "react-native-safe-area-context";
|
|
7
|
+
|
|
3
8
|
import { ModalPresenter } from "./ModalPresenter";
|
|
4
9
|
import { ModalContent } from "./ModalContent";
|
|
5
10
|
import { ModalChildrenWrapper } from "./ModalChildrenWrapper";
|
|
6
11
|
import { PathnameContext } from "@applicaster/zapp-react-native-ui-components/Contexts/PathnameContext";
|
|
12
|
+
import { ScreenDataContext } from "@applicaster/zapp-react-native-ui-components/Contexts/ScreenDataContext";
|
|
7
13
|
import { ScreenContextProvider } from "@applicaster/zapp-react-native-ui-components/Contexts/ScreenContext";
|
|
8
14
|
import { ROUTE_TYPES } from "@applicaster/zapp-react-native-utils/navigationUtils/routeTypes";
|
|
9
15
|
import { useModalStoreState } from "@applicaster/zapp-react-native-utils/modalState";
|
|
@@ -14,6 +20,9 @@ export function ModalProvider() {
|
|
|
14
20
|
const [modalVisible, setModalVisible] = React.useState(false);
|
|
15
21
|
const [modalShown, setModalShown] = React.useState(false);
|
|
16
22
|
|
|
23
|
+
const screen = modalState?.screen;
|
|
24
|
+
const entry = modalState?.props?.entry as ZappEntry | undefined;
|
|
25
|
+
|
|
17
26
|
React.useEffect(() => {
|
|
18
27
|
setModalVisible(!!modalState);
|
|
19
28
|
|
|
@@ -22,13 +31,25 @@ export function ModalProvider() {
|
|
|
22
31
|
}
|
|
23
32
|
}, [modalState]);
|
|
24
33
|
|
|
25
|
-
|
|
34
|
+
/**
|
|
35
|
+
* The modal renders as a sibling of the stack navigator, so nothing upstream
|
|
36
|
+
* provides ScreenDataContext and the presented screen would otherwise see
|
|
37
|
+
* `null` from useScreenContext()/useCurrentScreenData(). Provide it here so a
|
|
38
|
+
* presented screen resolves its own screen and entry, the same way a pushed
|
|
39
|
+
* screen does through the navigator's scene.
|
|
40
|
+
*/
|
|
41
|
+
const screenDataContextValue = React.useMemo(
|
|
42
|
+
() => ({ screen, entry }),
|
|
43
|
+
[screen, entry]
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
if (!screen) {
|
|
26
47
|
return null;
|
|
27
48
|
}
|
|
28
49
|
|
|
29
50
|
const modalScreenProps = {
|
|
30
|
-
screenData:
|
|
31
|
-
screenId:
|
|
51
|
+
screenData: entry ? { ...entry, targetScreen: screen } : screen,
|
|
52
|
+
screenId: screen?.id,
|
|
32
53
|
screenType: "river",
|
|
33
54
|
...modalState.props,
|
|
34
55
|
};
|
|
@@ -39,17 +60,21 @@ export function ModalProvider() {
|
|
|
39
60
|
|
|
40
61
|
return (
|
|
41
62
|
<PathnameContext.Provider value={pathname}>
|
|
42
|
-
<
|
|
43
|
-
<
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
<
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
63
|
+
<ScreenDataContext.Provider value={screenDataContextValue}>
|
|
64
|
+
<ScreenContextProvider pathname={pathname}>
|
|
65
|
+
<ModalPresenter
|
|
66
|
+
visible={shouldShowModal}
|
|
67
|
+
{...modalState?.options}
|
|
68
|
+
statusBarTranslucent
|
|
69
|
+
>
|
|
70
|
+
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
|
71
|
+
<ModalChildrenWrapper>
|
|
72
|
+
<ModalContent modalScreenProps={modalScreenProps} />
|
|
73
|
+
</ModalChildrenWrapper>
|
|
74
|
+
</SafeAreaProvider>
|
|
75
|
+
</ModalPresenter>
|
|
76
|
+
</ScreenContextProvider>
|
|
77
|
+
</ScreenDataContext.Provider>
|
|
53
78
|
</PathnameContext.Provider>
|
|
54
79
|
);
|
|
55
80
|
}
|
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
useGetScreenOrientation,
|
|
14
14
|
} from "@applicaster/zapp-react-native-utils/appUtils/orientationHelper";
|
|
15
15
|
import { focusManager } from "@applicaster/zapp-react-native-utils/focusManager/FocusManager";
|
|
16
|
+
import { setXrayNavigator } from "@applicaster/zapp-react-native-utils/navigationUtils/xrayNavigator";
|
|
17
|
+
import { registerNavToolbox } from "./navToolbox";
|
|
16
18
|
|
|
17
19
|
import reducer, {
|
|
18
20
|
ACTIONS,
|
|
@@ -243,6 +245,10 @@ export function NavigationProvider({ children }: Props) {
|
|
|
243
245
|
playNextOverlayState.current = state;
|
|
244
246
|
};
|
|
245
247
|
|
|
248
|
+
// The play-next overlay owns remote focus while it is visible, so key
|
|
249
|
+
// handling (InteractionManager) needs to be able to ask for it.
|
|
250
|
+
const getPlayNextOverlay = () => playNextOverlayState.current ?? null;
|
|
251
|
+
|
|
246
252
|
const setNestedScreenContent = (entry: ZappEntry, screenId: string) => {
|
|
247
253
|
const screen = rivers[screenId];
|
|
248
254
|
const parent = findParent(context, navigator.currentRoute);
|
|
@@ -349,10 +355,7 @@ export function NavigationProvider({ children }: Props) {
|
|
|
349
355
|
state: firstRoute.state,
|
|
350
356
|
};
|
|
351
357
|
|
|
352
|
-
if (
|
|
353
|
-
stateForBack?.state?.entry &&
|
|
354
|
-
"home" in stateForBack?.state?.entry
|
|
355
|
-
) {
|
|
358
|
+
if (stateForBack?.state?.entry?.home) {
|
|
356
359
|
goHome();
|
|
357
360
|
} else {
|
|
358
361
|
dispatch(back(stateForBack));
|
|
@@ -759,11 +762,11 @@ export function NavigationProvider({ children }: Props) {
|
|
|
759
762
|
const getStackForPathname = (
|
|
760
763
|
pathname: string
|
|
761
764
|
): NavigationScreenState | undefined => {
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
765
|
+
// Topmost match: the active screen if a route is on the stack twice.
|
|
766
|
+
return R.findLast(
|
|
767
|
+
({ route }) => route === pathname,
|
|
768
|
+
state?.stack?.mainStack ?? []
|
|
769
|
+
);
|
|
767
770
|
};
|
|
768
771
|
|
|
769
772
|
const navigator: QuickBrickAppNavigator = React.useMemo(
|
|
@@ -777,6 +780,7 @@ export function NavigationProvider({ children }: Props) {
|
|
|
777
780
|
replace: replaceItem,
|
|
778
781
|
setNestedScreenContent,
|
|
779
782
|
setPlayNextOverlay,
|
|
783
|
+
getPlayNextOverlay,
|
|
780
784
|
goHome,
|
|
781
785
|
canGoBack,
|
|
782
786
|
goBack,
|
|
@@ -827,6 +831,13 @@ export function NavigationProvider({ children }: Props) {
|
|
|
827
831
|
navigator.state = state;
|
|
828
832
|
}
|
|
829
833
|
|
|
834
|
+
React.useLayoutEffect(() => {
|
|
835
|
+
setXrayNavigator(navigator);
|
|
836
|
+
registerNavToolbox();
|
|
837
|
+
|
|
838
|
+
return () => setXrayNavigator(undefined);
|
|
839
|
+
}, [navigator]);
|
|
840
|
+
|
|
830
841
|
React.useLayoutEffect(() => {
|
|
831
842
|
if (pathname === "/" && appLaunched) {
|
|
832
843
|
goHome(true);
|
|
@@ -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
|
+
});
|