@applicaster/quick-brick-core 16.0.0-rc.1 → 16.0.0-rc.100
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 +107 -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 +62 -1
- package/App/NavigationProvider/__tests__/utils.test.ts +31 -1
- package/App/NavigationProvider/utils.ts +14 -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);
|
|
@@ -329,6 +334,12 @@ export function NavigationProvider({ children }: Props) {
|
|
|
329
334
|
fromHook = false,
|
|
330
335
|
backToTop = false
|
|
331
336
|
) => {
|
|
337
|
+
logger.debug("Attempting to go back", {
|
|
338
|
+
fallbackToHome,
|
|
339
|
+
fromHook,
|
|
340
|
+
backToTop,
|
|
341
|
+
});
|
|
342
|
+
|
|
332
343
|
if (fallbackToHome && !canGoBack(fromHook)) {
|
|
333
344
|
goHome();
|
|
334
345
|
} else {
|
|
@@ -408,6 +419,19 @@ export function NavigationProvider({ children }: Props) {
|
|
|
408
419
|
},
|
|
409
420
|
dispose
|
|
410
421
|
) => {
|
|
422
|
+
logger.debug({
|
|
423
|
+
message: `Hook screen presented: ${
|
|
424
|
+
hookPlugin.isModalHook() ? `(modal) ${route}` : route
|
|
425
|
+
}`,
|
|
426
|
+
data: {
|
|
427
|
+
route,
|
|
428
|
+
hookIdentifier: hookPlugin?.identifier,
|
|
429
|
+
hookScreenId: hookPlugin?.screen_id,
|
|
430
|
+
isModal: hookPlugin.isModalHook(),
|
|
431
|
+
lastHook: hookPlugin?.lastHook,
|
|
432
|
+
},
|
|
433
|
+
});
|
|
434
|
+
|
|
411
435
|
if (hookPlugin?.lastHook) {
|
|
412
436
|
dispose();
|
|
413
437
|
}
|
|
@@ -530,6 +554,17 @@ export function NavigationProvider({ children }: Props) {
|
|
|
530
554
|
currentStartUpHooks.current && setStartUpHooks(false);
|
|
531
555
|
}
|
|
532
556
|
|
|
557
|
+
logger.debug({
|
|
558
|
+
message: `Hooks complete, navigating to: ${targetRoute}`,
|
|
559
|
+
data: {
|
|
560
|
+
targetRoute,
|
|
561
|
+
hookIdentifier: hookPlugin?.identifier,
|
|
562
|
+
screenId: screen?.id,
|
|
563
|
+
screenName: screen?.name,
|
|
564
|
+
options,
|
|
565
|
+
},
|
|
566
|
+
});
|
|
567
|
+
|
|
533
568
|
dispatch(
|
|
534
569
|
navigationAction(targetRoute, { screen, entry: payload, options })
|
|
535
570
|
);
|
|
@@ -568,12 +603,28 @@ export function NavigationProvider({ children }: Props) {
|
|
|
568
603
|
) => {
|
|
569
604
|
const { entry, screen, targetRoute, externalUrl } = getNavigationTarget(
|
|
570
605
|
item,
|
|
571
|
-
action === ACTIONS.REPLACE ? "" : pathnameRef.current,
|
|
606
|
+
action === ACTIONS.REPLACE ? "" : stripHookPrefix(pathnameRef.current),
|
|
572
607
|
contentTypes,
|
|
573
608
|
layoutVersion,
|
|
574
609
|
rivers
|
|
575
610
|
);
|
|
576
611
|
|
|
612
|
+
logger.debug({
|
|
613
|
+
message: `Navigating: ${pathnameRef.current || "(none)"} -> ${
|
|
614
|
+
targetRoute || externalUrl || "(unresolved)"
|
|
615
|
+
}`,
|
|
616
|
+
data: {
|
|
617
|
+
action,
|
|
618
|
+
from: pathnameRef.current,
|
|
619
|
+
to: targetRoute,
|
|
620
|
+
externalUrl,
|
|
621
|
+
screenId: screen?.id,
|
|
622
|
+
screenName: screen?.name,
|
|
623
|
+
entryType: entry?.type?.value,
|
|
624
|
+
options,
|
|
625
|
+
},
|
|
626
|
+
});
|
|
627
|
+
|
|
577
628
|
if (externalUrl) {
|
|
578
629
|
const openURL = async (url) => {
|
|
579
630
|
const inflatedURL = await inflateUrl(url);
|
|
@@ -662,6 +713,15 @@ export function NavigationProvider({ children }: Props) {
|
|
|
662
713
|
|
|
663
714
|
hooksManager.handleHooks(legacyScreenData({ entry, screen }));
|
|
664
715
|
} else {
|
|
716
|
+
logger.debug({
|
|
717
|
+
message: `Navigating offline (hooks skipped) to: ${targetRoute}`,
|
|
718
|
+
data: {
|
|
719
|
+
targetRoute,
|
|
720
|
+
screenId: screen?.id,
|
|
721
|
+
screenName: screen?.name,
|
|
722
|
+
},
|
|
723
|
+
});
|
|
724
|
+
|
|
665
725
|
dispatch(navigationAction(targetRoute, { screen, entry, options }));
|
|
666
726
|
}
|
|
667
727
|
},
|
|
@@ -721,6 +781,7 @@ export function NavigationProvider({ children }: Props) {
|
|
|
721
781
|
replace: replaceItem,
|
|
722
782
|
setNestedScreenContent,
|
|
723
783
|
setPlayNextOverlay,
|
|
784
|
+
getPlayNextOverlay,
|
|
724
785
|
goHome,
|
|
725
786
|
canGoBack,
|
|
726
787
|
goBack,
|
|
@@ -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
|
+
});
|
|
@@ -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,
|