@applicaster/quick-brick-core 16.0.0-rc.11 → 16.0.0-rc.111
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 +57 -5
- package/App/NavigationProvider/__tests__/navigationProvider.test.tsx +56 -0
- package/App/NavigationProvider/__tests__/utils.test.ts +31 -1
- package/App/NavigationProvider/utils.ts +14 -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,105 @@
|
|
|
1
|
+
import { act, renderHook } from "@testing-library/react-native";
|
|
2
|
+
import { Keyboard } from "react-native";
|
|
3
|
+
|
|
4
|
+
jest.mock("@applicaster/zapp-react-native-utils/reactUtils", () => ({
|
|
5
|
+
isApplePlatform: () => false,
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
// Import after mocks so module-level isApple resolves to false.
|
|
9
|
+
// eslint-disable-next-line import/first
|
|
10
|
+
import { useKeyboardHeight } from "../useKeyboardHeight";
|
|
11
|
+
|
|
12
|
+
type KeyboardListener = (event?: {
|
|
13
|
+
endCoordinates: { height: number };
|
|
14
|
+
}) => void;
|
|
15
|
+
|
|
16
|
+
function setupKeyboardListenerMock() {
|
|
17
|
+
const listeners: Record<string, KeyboardListener> = {};
|
|
18
|
+
const removeMocks: Record<string, jest.Mock> = {};
|
|
19
|
+
|
|
20
|
+
const addListenerSpy = jest
|
|
21
|
+
.spyOn(Keyboard, "addListener")
|
|
22
|
+
.mockImplementation((event: string, callback: KeyboardListener) => {
|
|
23
|
+
listeners[event] = callback;
|
|
24
|
+
|
|
25
|
+
const remove = jest.fn();
|
|
26
|
+
removeMocks[event] = remove;
|
|
27
|
+
|
|
28
|
+
return { remove };
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return { listeners, removeMocks, addListenerSpy };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("useKeyboardHeight (non-Apple platforms)", () => {
|
|
35
|
+
let listeners: Record<string, KeyboardListener>;
|
|
36
|
+
let removeMocks: Record<string, jest.Mock>;
|
|
37
|
+
let addListenerSpy: jest.SpyInstance;
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
const mock = setupKeyboardListenerMock();
|
|
41
|
+
listeners = mock.listeners;
|
|
42
|
+
removeMocks = mock.removeMocks;
|
|
43
|
+
addListenerSpy = mock.addListenerSpy;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
addListenerSpy.mockRestore();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns 0 initially and subscribes to didShow / didHide events", () => {
|
|
51
|
+
const { result } = renderHook(() => useKeyboardHeight());
|
|
52
|
+
|
|
53
|
+
expect(result.current).toBe(0);
|
|
54
|
+
expect(addListenerSpy).toHaveBeenCalledTimes(2);
|
|
55
|
+
|
|
56
|
+
expect(addListenerSpy).toHaveBeenCalledWith(
|
|
57
|
+
"keyboardDidShow",
|
|
58
|
+
expect.any(Function)
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
expect(addListenerSpy).toHaveBeenCalledWith(
|
|
62
|
+
"keyboardDidHide",
|
|
63
|
+
expect.any(Function)
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("updates height when keyboardDidShow fires", () => {
|
|
68
|
+
const { result } = renderHook(() => useKeyboardHeight());
|
|
69
|
+
|
|
70
|
+
act(() => {
|
|
71
|
+
listeners.keyboardDidShow?.({
|
|
72
|
+
endCoordinates: { height: 412 },
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(result.current).toBe(412);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("resets height to 0 when keyboardDidHide fires", () => {
|
|
80
|
+
const { result } = renderHook(() => useKeyboardHeight());
|
|
81
|
+
|
|
82
|
+
act(() => {
|
|
83
|
+
listeners.keyboardDidShow?.({
|
|
84
|
+
endCoordinates: { height: 412 },
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(result.current).toBe(412);
|
|
89
|
+
|
|
90
|
+
act(() => {
|
|
91
|
+
listeners.keyboardDidHide?.();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(result.current).toBe(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("removes listeners on unmount", () => {
|
|
98
|
+
const { unmount } = renderHook(() => useKeyboardHeight());
|
|
99
|
+
|
|
100
|
+
unmount();
|
|
101
|
+
|
|
102
|
+
expect(removeMocks.keyboardDidShow).toHaveBeenCalledTimes(1);
|
|
103
|
+
expect(removeMocks.keyboardDidHide).toHaveBeenCalledTimes(1);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { act, renderHook } from "@testing-library/react-native";
|
|
2
|
+
import { Keyboard } from "react-native";
|
|
3
|
+
import { useKeyboardHeight } from "../useKeyboardHeight";
|
|
4
|
+
|
|
5
|
+
jest.mock("@applicaster/zapp-react-native-utils/reactUtils", () => ({
|
|
6
|
+
isApplePlatform: () => true,
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
type KeyboardListener = (event?: {
|
|
10
|
+
endCoordinates: { height: number };
|
|
11
|
+
}) => void;
|
|
12
|
+
|
|
13
|
+
function setupKeyboardListenerMock() {
|
|
14
|
+
const listeners: Record<string, KeyboardListener> = {};
|
|
15
|
+
const removeMocks: Record<string, jest.Mock> = {};
|
|
16
|
+
|
|
17
|
+
const addListenerSpy = jest
|
|
18
|
+
.spyOn(Keyboard, "addListener")
|
|
19
|
+
.mockImplementation((event: string, callback: KeyboardListener) => {
|
|
20
|
+
listeners[event] = callback;
|
|
21
|
+
|
|
22
|
+
const remove = jest.fn();
|
|
23
|
+
removeMocks[event] = remove;
|
|
24
|
+
|
|
25
|
+
return { remove };
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
return { listeners, removeMocks, addListenerSpy };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("useKeyboardHeight (Apple platforms)", () => {
|
|
32
|
+
let listeners: Record<string, KeyboardListener>;
|
|
33
|
+
let removeMocks: Record<string, jest.Mock>;
|
|
34
|
+
let addListenerSpy: jest.SpyInstance;
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
const mock = setupKeyboardListenerMock();
|
|
38
|
+
listeners = mock.listeners;
|
|
39
|
+
removeMocks = mock.removeMocks;
|
|
40
|
+
addListenerSpy = mock.addListenerSpy;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
addListenerSpy.mockRestore();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("returns 0 initially and subscribes to willShow / willHide events", () => {
|
|
48
|
+
const { result } = renderHook(() => useKeyboardHeight());
|
|
49
|
+
|
|
50
|
+
expect(result.current).toBe(0);
|
|
51
|
+
expect(addListenerSpy).toHaveBeenCalledTimes(2);
|
|
52
|
+
|
|
53
|
+
expect(addListenerSpy).toHaveBeenCalledWith(
|
|
54
|
+
"keyboardWillShow",
|
|
55
|
+
expect.any(Function)
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
expect(addListenerSpy).toHaveBeenCalledWith(
|
|
59
|
+
"keyboardWillHide",
|
|
60
|
+
expect.any(Function)
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("updates height when keyboardWillShow fires", () => {
|
|
65
|
+
const { result } = renderHook(() => useKeyboardHeight());
|
|
66
|
+
|
|
67
|
+
act(() => {
|
|
68
|
+
listeners.keyboardWillShow?.({
|
|
69
|
+
endCoordinates: { height: 336 },
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(result.current).toBe(336);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("resets height to 0 when keyboardWillHide fires", () => {
|
|
77
|
+
const { result } = renderHook(() => useKeyboardHeight());
|
|
78
|
+
|
|
79
|
+
act(() => {
|
|
80
|
+
listeners.keyboardWillShow?.({
|
|
81
|
+
endCoordinates: { height: 280 },
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(result.current).toBe(280);
|
|
86
|
+
|
|
87
|
+
act(() => {
|
|
88
|
+
listeners.keyboardWillHide?.();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(result.current).toBe(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("removes listeners on unmount", () => {
|
|
95
|
+
const { unmount } = renderHook(() => useKeyboardHeight());
|
|
96
|
+
|
|
97
|
+
unmount();
|
|
98
|
+
|
|
99
|
+
expect(removeMocks.keyboardWillShow).toHaveBeenCalledTimes(1);
|
|
100
|
+
expect(removeMocks.keyboardWillHide).toHaveBeenCalledTimes(1);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { useKeyboardHeight } from "./useKeyboardHeight";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Keyboard, KeyboardEvent } from "react-native";
|
|
3
|
+
import { isApplePlatform } from "@applicaster/zapp-react-native-utils/reactUtils";
|
|
4
|
+
|
|
5
|
+
const isApple = isApplePlatform();
|
|
6
|
+
|
|
7
|
+
export function useKeyboardHeight() {
|
|
8
|
+
const [keyboardHeight, setKeyboardHeight] = React.useState(0);
|
|
9
|
+
|
|
10
|
+
React.useEffect(() => {
|
|
11
|
+
// iOS supports smooth 'willShow' / 'willHide' events
|
|
12
|
+
const showEvent = isApple ? "keyboardWillShow" : "keyboardDidShow";
|
|
13
|
+
const hideEvent = isApple ? "keyboardWillHide" : "keyboardDidHide";
|
|
14
|
+
|
|
15
|
+
const showListener = Keyboard.addListener(showEvent, (e: KeyboardEvent) => {
|
|
16
|
+
setKeyboardHeight(e.endCoordinates.height);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const hideListener = Keyboard.addListener(hideEvent, () => {
|
|
20
|
+
setKeyboardHeight(0);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
return () => {
|
|
24
|
+
showListener.remove();
|
|
25
|
+
hideListener.remove();
|
|
26
|
+
};
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
return keyboardHeight;
|
|
30
|
+
}
|
|
@@ -47,9 +47,11 @@ export function ModalBottomSheet(props: Props) {
|
|
|
47
47
|
|
|
48
48
|
const {
|
|
49
49
|
ModalBottomSheetContent = ModalComponent,
|
|
50
|
-
modalBottomSheetContentProps,
|
|
50
|
+
modalBottomSheetContentProps = {},
|
|
51
51
|
} = props;
|
|
52
52
|
|
|
53
|
+
const { key, ...restContentProps } = modalBottomSheetContentProps;
|
|
54
|
+
|
|
53
55
|
useEffect(() => {
|
|
54
56
|
Keyboard.dismiss();
|
|
55
57
|
}, []);
|
|
@@ -82,11 +84,12 @@ export function ModalBottomSheet(props: Props) {
|
|
|
82
84
|
}}
|
|
83
85
|
>
|
|
84
86
|
<ModalBottomSheetContent
|
|
87
|
+
key={key as any}
|
|
85
88
|
width={
|
|
86
89
|
Math.min(sheetTabletMaxWidth, dimensions.width) || dimensions.width
|
|
87
90
|
}
|
|
88
91
|
maxHeight={sheetMaxHeight}
|
|
89
|
-
{...
|
|
92
|
+
{...restContentProps}
|
|
90
93
|
dismiss={dismiss}
|
|
91
94
|
currentRoute={navigator.currentRoute}
|
|
92
95
|
/>
|
|
@@ -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
|
}
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
getTargetScreen,
|
|
56
56
|
legacyScreenData,
|
|
57
57
|
openExternalUrl,
|
|
58
|
+
stripHookPrefix,
|
|
58
59
|
} from "./utils";
|
|
59
60
|
import {
|
|
60
61
|
debounce,
|
|
@@ -242,6 +243,10 @@ export function NavigationProvider({ children }: Props) {
|
|
|
242
243
|
playNextOverlayState.current = state;
|
|
243
244
|
};
|
|
244
245
|
|
|
246
|
+
// The play-next overlay owns remote focus while it is visible, so key
|
|
247
|
+
// handling (InteractionManager) needs to be able to ask for it.
|
|
248
|
+
const getPlayNextOverlay = () => playNextOverlayState.current ?? null;
|
|
249
|
+
|
|
245
250
|
const setNestedScreenContent = (entry: ZappEntry, screenId: string) => {
|
|
246
251
|
const screen = rivers[screenId];
|
|
247
252
|
const parent = findParent(context, navigator.currentRoute);
|
|
@@ -348,10 +353,7 @@ export function NavigationProvider({ children }: Props) {
|
|
|
348
353
|
state: firstRoute.state,
|
|
349
354
|
};
|
|
350
355
|
|
|
351
|
-
if (
|
|
352
|
-
stateForBack?.state?.entry &&
|
|
353
|
-
"home" in stateForBack?.state?.entry
|
|
354
|
-
) {
|
|
356
|
+
if (stateForBack?.state?.entry?.home) {
|
|
355
357
|
goHome();
|
|
356
358
|
} else {
|
|
357
359
|
dispatch(back(stateForBack));
|
|
@@ -414,6 +416,19 @@ export function NavigationProvider({ children }: Props) {
|
|
|
414
416
|
},
|
|
415
417
|
dispose
|
|
416
418
|
) => {
|
|
419
|
+
logger.debug({
|
|
420
|
+
message: `Hook screen presented: ${
|
|
421
|
+
hookPlugin.isModalHook() ? `(modal) ${route}` : route
|
|
422
|
+
}`,
|
|
423
|
+
data: {
|
|
424
|
+
route,
|
|
425
|
+
hookIdentifier: hookPlugin?.identifier,
|
|
426
|
+
hookScreenId: hookPlugin?.screen_id,
|
|
427
|
+
isModal: hookPlugin.isModalHook(),
|
|
428
|
+
lastHook: hookPlugin?.lastHook,
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
|
|
417
432
|
if (hookPlugin?.lastHook) {
|
|
418
433
|
dispose();
|
|
419
434
|
}
|
|
@@ -536,6 +551,17 @@ export function NavigationProvider({ children }: Props) {
|
|
|
536
551
|
currentStartUpHooks.current && setStartUpHooks(false);
|
|
537
552
|
}
|
|
538
553
|
|
|
554
|
+
logger.debug({
|
|
555
|
+
message: `Hooks complete, navigating to: ${targetRoute}`,
|
|
556
|
+
data: {
|
|
557
|
+
targetRoute,
|
|
558
|
+
hookIdentifier: hookPlugin?.identifier,
|
|
559
|
+
screenId: screen?.id,
|
|
560
|
+
screenName: screen?.name,
|
|
561
|
+
options,
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
|
|
539
565
|
dispatch(
|
|
540
566
|
navigationAction(targetRoute, { screen, entry: payload, options })
|
|
541
567
|
);
|
|
@@ -574,12 +600,28 @@ export function NavigationProvider({ children }: Props) {
|
|
|
574
600
|
) => {
|
|
575
601
|
const { entry, screen, targetRoute, externalUrl } = getNavigationTarget(
|
|
576
602
|
item,
|
|
577
|
-
action === ACTIONS.REPLACE ? "" : pathnameRef.current,
|
|
603
|
+
action === ACTIONS.REPLACE ? "" : stripHookPrefix(pathnameRef.current),
|
|
578
604
|
contentTypes,
|
|
579
605
|
layoutVersion,
|
|
580
606
|
rivers
|
|
581
607
|
);
|
|
582
608
|
|
|
609
|
+
logger.debug({
|
|
610
|
+
message: `Navigating: ${pathnameRef.current || "(none)"} -> ${
|
|
611
|
+
targetRoute || externalUrl || "(unresolved)"
|
|
612
|
+
}`,
|
|
613
|
+
data: {
|
|
614
|
+
action,
|
|
615
|
+
from: pathnameRef.current,
|
|
616
|
+
to: targetRoute,
|
|
617
|
+
externalUrl,
|
|
618
|
+
screenId: screen?.id,
|
|
619
|
+
screenName: screen?.name,
|
|
620
|
+
entryType: entry?.type?.value,
|
|
621
|
+
options,
|
|
622
|
+
},
|
|
623
|
+
});
|
|
624
|
+
|
|
583
625
|
if (externalUrl) {
|
|
584
626
|
const openURL = async (url) => {
|
|
585
627
|
const inflatedURL = await inflateUrl(url);
|
|
@@ -668,6 +710,15 @@ export function NavigationProvider({ children }: Props) {
|
|
|
668
710
|
|
|
669
711
|
hooksManager.handleHooks(legacyScreenData({ entry, screen }));
|
|
670
712
|
} else {
|
|
713
|
+
logger.debug({
|
|
714
|
+
message: `Navigating offline (hooks skipped) to: ${targetRoute}`,
|
|
715
|
+
data: {
|
|
716
|
+
targetRoute,
|
|
717
|
+
screenId: screen?.id,
|
|
718
|
+
screenName: screen?.name,
|
|
719
|
+
},
|
|
720
|
+
});
|
|
721
|
+
|
|
671
722
|
dispatch(navigationAction(targetRoute, { screen, entry, options }));
|
|
672
723
|
}
|
|
673
724
|
},
|
|
@@ -727,6 +778,7 @@ export function NavigationProvider({ children }: Props) {
|
|
|
727
778
|
replace: replaceItem,
|
|
728
779
|
setNestedScreenContent,
|
|
729
780
|
setPlayNextOverlay,
|
|
781
|
+
getPlayNextOverlay,
|
|
730
782
|
goHome,
|
|
731
783
|
canGoBack,
|
|
732
784
|
goBack,
|
|
@@ -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();
|