@applicaster/zapp-react-native-ui-components 15.0.0-alpha.8680244503 → 15.0.0-alpha.9102777840
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/Components/BaseFocusable/index.ios.ts +12 -2
- package/Components/Cell/Cell.tsx +6 -0
- package/Components/Cell/CellWithFocusable.tsx +9 -0
- package/Components/Cell/FocusableWrapper.tsx +3 -0
- package/Components/Cell/TvOSCellComponent.tsx +5 -0
- package/Components/Focusable/Focusable.tsx +4 -2
- package/Components/Focusable/FocusableTvOS.tsx +18 -1
- package/Components/Focusable/__tests__/__snapshots__/FocusableTvOS.test.tsx.snap +1 -0
- package/Components/FocusableGroup/FocusableTvOS.tsx +32 -1
- package/Components/GeneralContentScreen/GeneralContentScreen.tsx +3 -8
- package/Components/GeneralContentScreen/utils/__tests__/useCurationAPI.test.js +1 -1
- package/Components/GeneralContentScreen/utils/useCurationAPI.ts +22 -6
- package/Components/HandlePlayable/HandlePlayable.tsx +16 -29
- package/Components/HandlePlayable/utils.ts +31 -0
- package/Components/MasterCell/DefaultComponents/SecondaryImage/Image.tsx +40 -39
- package/Components/MasterCell/DefaultComponents/SecondaryImage/__tests__/Image.test.tsx +95 -0
- package/Components/MasterCell/DefaultComponents/SecondaryImage/__tests__/__snapshots__/Image.test.tsx.snap +86 -0
- package/Components/MasterCell/DefaultComponents/SecondaryImage/__tests__/index.test.ts +141 -0
- package/Components/MasterCell/DefaultComponents/SecondaryImage/hooks/__tests__/useGetImageDimensions.test.ts +7 -6
- package/Components/MasterCell/DefaultComponents/SecondaryImage/index.ts +1 -1
- package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/index.ts +6 -2
- package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/utils/__tests__/getPluginIdentifier.test.ts +233 -11
- package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/utils/index.ts +19 -15
- package/Components/MasterCell/hoc/__tests__/withAsyncRender.test.tsx +219 -0
- package/Components/MasterCell/hoc/withAsyncRender.tsx +9 -7
- package/Components/OfflineHandler/NotificationView/NotificationView.lg.tsx +17 -9
- package/Components/OfflineHandler/NotificationView/NotificationView.samsung.tsx +16 -8
- package/Components/OfflineHandler/NotificationView/utils.ts +34 -0
- package/Components/PlayerContainer/PlayerContainer.tsx +40 -47
- package/Components/River/ComponentsMap/ComponentsMap.tsx +16 -0
- package/Components/River/ComponentsMap/hooks/__tests__/useLoadingState.test.ts +1 -1
- package/Components/River/RefreshControl.tsx +9 -3
- package/Components/River/TV/River.tsx +31 -14
- package/Components/River/TV/index.tsx +8 -4
- package/Components/River/TV/utils/__tests__/toStringOrEmpty.test.ts +30 -0
- package/Components/River/TV/utils/index.ts +4 -0
- package/Components/River/TV/withFocusableGroupForContent.tsx +71 -0
- package/Components/River/__tests__/__snapshots__/componentsMap.test.js.snap +2 -0
- package/Components/River/__tests__/componentsMap.test.js +38 -0
- package/Components/Screen/__tests__/Screen.test.tsx +1 -0
- package/Components/Screen/hooks.ts +73 -3
- package/Components/Screen/index.tsx +7 -1
- package/Components/Screen/orientationHandler.ts +7 -10
- package/Components/ScreenResolver/index.tsx +14 -10
- package/Components/Tabs/TabContent.tsx +7 -4
- package/Components/Transitioner/Scene.tsx +9 -15
- package/Components/VideoModal/hooks/__tests__/useDelayedPlayerDetails.test.ts +15 -7
- package/Components/Viewport/ViewportAware/__tests__/viewportAware.test.js +0 -2
- package/Components/Viewport/ViewportAware/index.tsx +16 -7
- package/Components/Viewport/ViewportEvents/__tests__/viewportEvents.test.js +1 -1
- package/Contexts/ScreenContext/index.tsx +54 -19
- package/Contexts/ScreenTrackedViewPositionsContext/__tests__/index.test.tsx +1 -1
- package/Contexts/ZappHookModalContext/index.tsx +37 -61
- package/Contexts/ZappPipesContext/ZappPipesContextFactory.tsx +18 -7
- package/Contexts/index.ts +0 -2
- package/Decorators/ConfigurationWrapper/__tests__/__snapshots__/withConfigurationProvider.test.tsx.snap +1 -0
- package/Decorators/ConfigurationWrapper/const.ts +1 -0
- package/Decorators/ZappPipesDataConnector/ResolverSelector.tsx +25 -7
- package/Decorators/ZappPipesDataConnector/__tests__/ResolverSelector.test.tsx +212 -5
- package/Decorators/ZappPipesDataConnector/__tests__/UrlFeedResolver.test.tsx +39 -21
- package/Decorators/ZappPipesDataConnector/__tests__/zappPipesDataConnector.test.js +1 -1
- package/Decorators/ZappPipesDataConnector/resolvers/UrlFeedResolver.tsx +18 -7
- package/events/index.ts +3 -0
- package/events/scrollEndReached.ts +15 -0
- package/package.json +5 -5
- package/Components/PlayerContainer/ErrorDisplay/ErrorDisplay.tsx +0 -57
- package/Components/PlayerContainer/ErrorDisplay/index.ts +0 -9
- package/Components/River/TV/withTVEventHandler.tsx +0 -27
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { sessionStorage } from "@applicaster/zapp-react-native-bridge/ZappStorage/SessionStorage";
|
|
3
|
+
|
|
4
|
+
export const NETWORK_STATUS_LOCALIZATIONS_KEY = "network_status_localizations";
|
|
5
|
+
|
|
6
|
+
export const THEME_STORAGE_NAMESPACE = "quick-brick-theme";
|
|
7
|
+
|
|
8
|
+
export function useNetworkStatusLocalizations() {
|
|
9
|
+
const [storedLocalizations, setStoredLocalizations] = React.useState<Record<
|
|
10
|
+
string,
|
|
11
|
+
string
|
|
12
|
+
> | null>(null);
|
|
13
|
+
|
|
14
|
+
React.useEffect(() => {
|
|
15
|
+
async function loadStoredLocalizations() {
|
|
16
|
+
try {
|
|
17
|
+
const stored = await sessionStorage.getItem(
|
|
18
|
+
NETWORK_STATUS_LOCALIZATIONS_KEY,
|
|
19
|
+
THEME_STORAGE_NAMESPACE
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
if (stored) {
|
|
23
|
+
setStoredLocalizations(stored);
|
|
24
|
+
}
|
|
25
|
+
} catch (error) {
|
|
26
|
+
console.error("Error loading network status localizations", error);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
loadStoredLocalizations();
|
|
31
|
+
}, []);
|
|
32
|
+
|
|
33
|
+
return storedLocalizations;
|
|
34
|
+
}
|
|
@@ -46,7 +46,6 @@ import {
|
|
|
46
46
|
PlayerContainerContextProvider,
|
|
47
47
|
} from "./PlayerContainerContext";
|
|
48
48
|
import { FocusableGroup } from "@applicaster/zapp-react-native-ui-components/Components/FocusableGroup";
|
|
49
|
-
import { ErrorDisplay } from "./ErrorDisplay";
|
|
50
49
|
import { PlayerFocusableWrapperView } from "./WappersView/PlayerFocusableWrapperView";
|
|
51
50
|
import { FocusableGroupMainContainerId } from "./index";
|
|
52
51
|
import { isPlayable } from "@applicaster/zapp-react-native-utils/navigationUtils/itemTypeMatchers";
|
|
@@ -264,7 +263,14 @@ const PlayerContainerComponent = (props: Props) => {
|
|
|
264
263
|
|
|
265
264
|
showNavBar(true);
|
|
266
265
|
navigator.goBack();
|
|
267
|
-
}, [isModal,
|
|
266
|
+
}, [isModal, state.playerId, showNavBar, navigator]);
|
|
267
|
+
|
|
268
|
+
const pluginConfiguration = React.useMemo(() => {
|
|
269
|
+
return (
|
|
270
|
+
playerManager.getPluginConfiguration() ||
|
|
271
|
+
R.prop("__plugin_configuration", Player)
|
|
272
|
+
);
|
|
273
|
+
}, [playerManager.isRegistered()]);
|
|
268
274
|
|
|
269
275
|
const playEntry = (entry) => navigator.replaceTop(entry, { mode });
|
|
270
276
|
|
|
@@ -332,12 +338,6 @@ const PlayerContainerComponent = (props: Props) => {
|
|
|
332
338
|
playerContainerLogger.error(errorObj);
|
|
333
339
|
|
|
334
340
|
setState({ error: errorObj });
|
|
335
|
-
|
|
336
|
-
if (!isTvOS) {
|
|
337
|
-
setTimeout(() => {
|
|
338
|
-
close();
|
|
339
|
-
}, 800);
|
|
340
|
-
}
|
|
341
341
|
},
|
|
342
342
|
[close]
|
|
343
343
|
);
|
|
@@ -456,13 +456,6 @@ const PlayerContainerComponent = (props: Props) => {
|
|
|
456
456
|
}
|
|
457
457
|
}, []);
|
|
458
458
|
|
|
459
|
-
const pluginConfiguration = React.useMemo(() => {
|
|
460
|
-
return (
|
|
461
|
-
playerManager.getPluginConfiguration() ||
|
|
462
|
-
R.prop("__plugin_configuration", Player)
|
|
463
|
-
);
|
|
464
|
-
}, [playerManager.isRegistered()]);
|
|
465
|
-
|
|
466
459
|
const disableMiniPlayer = React.useMemo(() => {
|
|
467
460
|
return pluginConfiguration?.disable_mini_player_when_inline;
|
|
468
461
|
}, [pluginConfiguration]);
|
|
@@ -662,39 +655,39 @@ const PlayerContainerComponent = (props: Props) => {
|
|
|
662
655
|
<PlayerFocusableWrapperView
|
|
663
656
|
nextFocusDown={context.bottomFocusableId}
|
|
664
657
|
>
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
setNextVideoPreloadThresholdPercentage
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
658
|
+
{!Player ? null : (
|
|
659
|
+
<Player
|
|
660
|
+
source={{
|
|
661
|
+
uri,
|
|
662
|
+
entry: item,
|
|
663
|
+
}}
|
|
664
|
+
focused={isInlineTV ? true : undefined}
|
|
665
|
+
autoplay={true}
|
|
666
|
+
controls={false}
|
|
667
|
+
disableCastAction={disableCastAction}
|
|
668
|
+
docked={navigator.isVideoModalDocked()}
|
|
669
|
+
entry={item}
|
|
670
|
+
fullscreen={mode === VideoModalMode.FULLSCREEN}
|
|
671
|
+
inline={inline}
|
|
672
|
+
isModal={isModal}
|
|
673
|
+
isTabletPortrait={isTabletPortrait}
|
|
674
|
+
muted={false}
|
|
675
|
+
playableItem={item}
|
|
676
|
+
playerEvent={playerEvent}
|
|
677
|
+
playerId={state.playerId}
|
|
678
|
+
pluginConfiguration={pluginConfiguration}
|
|
679
|
+
ref={playerRef}
|
|
680
|
+
toggleFullscreen={toggleFullscreen}
|
|
681
|
+
style={videoStyle}
|
|
682
|
+
playNextData={playNextData}
|
|
683
|
+
setNextVideoPreloadThresholdPercentage={
|
|
684
|
+
setNextVideoPreloadThresholdPercentage
|
|
685
|
+
}
|
|
686
|
+
>
|
|
687
|
+
{renderApplePlayer(applePlayerProps)}
|
|
688
|
+
</Player>
|
|
689
|
+
)}
|
|
695
690
|
</PlayerFocusableWrapperView>
|
|
696
|
-
|
|
697
|
-
{state.error ? <ErrorDisplay error={state.error} /> : null}
|
|
698
691
|
</View>
|
|
699
692
|
{/* Components container */}
|
|
700
693
|
{isInlineTV && context.showComponentsContainer ? (
|
|
@@ -23,6 +23,7 @@ import { isLast } from "@applicaster/zapp-react-native-utils/arrayUtils";
|
|
|
23
23
|
import { withComponentsMapProvider } from "@applicaster/zapp-react-native-ui-components/Decorators/ComponentsMapWrapper";
|
|
24
24
|
import { useScreenContextV2 } from "@applicaster/zapp-react-native-utils/reactHooks/screen/useScreenContext";
|
|
25
25
|
import { useShallow } from "zustand/react/shallow";
|
|
26
|
+
import { emitScrollEndReached } from "@applicaster/zapp-react-native-ui-components/events";
|
|
26
27
|
|
|
27
28
|
import { isAndroidPlatform } from "@applicaster/zapp-react-native-utils/reactUtils";
|
|
28
29
|
import { ComponentsMapHeightContext } from "./ContextProviders/ComponentsMapHeightContext";
|
|
@@ -73,6 +74,7 @@ function ComponentsMapComponent(props: Props) {
|
|
|
73
74
|
|
|
74
75
|
const flatListRef = React.useRef<FlatList | null>(null);
|
|
75
76
|
const flatListWrapperRef = React.useRef<View | null>(null);
|
|
77
|
+
const hasUserScrolledRef = React.useRef(false);
|
|
76
78
|
const screenConfig = useScreenConfiguration(riverId);
|
|
77
79
|
const screenData = useScreenData(riverId);
|
|
78
80
|
const pullToRefreshEnabled = screenData?.rules?.pull_to_refresh_enabled;
|
|
@@ -236,6 +238,8 @@ function ComponentsMapComponent(props: Props) {
|
|
|
236
238
|
}, []);
|
|
237
239
|
|
|
238
240
|
const onScroll = React.useCallback((event) => {
|
|
241
|
+
hasUserScrolledRef.current = true;
|
|
242
|
+
|
|
239
243
|
const {
|
|
240
244
|
nativeEvent: {
|
|
241
245
|
contentOffset: { y },
|
|
@@ -277,6 +281,7 @@ function ComponentsMapComponent(props: Props) {
|
|
|
277
281
|
>
|
|
278
282
|
<ViewportTracker>
|
|
279
283
|
<FlatList
|
|
284
|
+
testID="components-map-flat-list"
|
|
280
285
|
ref={(ref) => {
|
|
281
286
|
flatListRef.current = ref;
|
|
282
287
|
}}
|
|
@@ -308,6 +313,17 @@ function ComponentsMapComponent(props: Props) {
|
|
|
308
313
|
onScrollEndDrag={_onScrollEndDrag}
|
|
309
314
|
scrollEventThrottle={16}
|
|
310
315
|
{...scrollViewExtraProps}
|
|
316
|
+
onEndReached={
|
|
317
|
+
/* When wrapped in a parent ScrollView (e.g. tabs),
|
|
318
|
+
this FlatList doesn't scroll so onEndReached can fire repeatedly;
|
|
319
|
+
skip it here and let the parent ScrollView emit scroll-end instead. */
|
|
320
|
+
isScreenWrappedInContainer
|
|
321
|
+
? undefined
|
|
322
|
+
: () => {
|
|
323
|
+
if (!hasUserScrolledRef.current) return;
|
|
324
|
+
emitScrollEndReached();
|
|
325
|
+
}
|
|
326
|
+
}
|
|
311
327
|
/>
|
|
312
328
|
</ViewportTracker>
|
|
313
329
|
</ScreenLoadingMeasurements>
|
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
StyleSheet,
|
|
6
6
|
} from "react-native";
|
|
7
7
|
import * as R from "ramda";
|
|
8
|
+
import { path } from "@applicaster/zapp-react-native-utils/utils";
|
|
9
|
+
import { isNilOrEmpty } from "@applicaster/zapp-react-native-utils/reactUtils/helpers";
|
|
8
10
|
import { useTheme } from "@applicaster/zapp-react-native-utils/theme";
|
|
9
11
|
import { useLocalizedStrings } from "@applicaster/zapp-react-native-utils/localizationUtils";
|
|
10
12
|
import { useAnalytics } from "@applicaster/zapp-react-native-utils/analyticsUtils";
|
|
@@ -62,9 +64,13 @@ export const usePullToRefresh = (
|
|
|
62
64
|
|
|
63
65
|
const [refreshing, setRefreshing] = React.useState(false);
|
|
64
66
|
|
|
65
|
-
const feeds: string[] =
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
const feeds: string[] = React.useMemo(
|
|
68
|
+
() =>
|
|
69
|
+
(riverComponents || [])
|
|
70
|
+
.map((riverComponent) => path(["data", "source"], riverComponent))
|
|
71
|
+
.filter((feed) => !isNilOrEmpty(feed)),
|
|
72
|
+
[riverComponents]
|
|
73
|
+
);
|
|
68
74
|
|
|
69
75
|
const feedsLength = feeds.length;
|
|
70
76
|
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { Text } from "react-native";
|
|
5
|
-
|
|
5
|
+
|
|
6
|
+
import { mergeRight } from "@applicaster/zapp-react-native-utils/utils";
|
|
6
7
|
|
|
7
8
|
import { GeneralContentScreen } from "../../GeneralContentScreen";
|
|
8
9
|
import { ScreenResolver } from "@applicaster/zapp-react-native-ui-components/Components/ScreenResolver";
|
|
@@ -13,6 +14,8 @@ import {
|
|
|
13
14
|
} from "@applicaster/zapp-react-native-utils/reactHooks/screen/useScreenContext";
|
|
14
15
|
import { useRivers } from "@applicaster/zapp-react-native-utils/reactHooks/state";
|
|
15
16
|
|
|
17
|
+
import { toStringOrEmpty } from "./utils";
|
|
18
|
+
|
|
16
19
|
type Props = {
|
|
17
20
|
screenId: string;
|
|
18
21
|
screenData: ZappRiver | ZappEntry;
|
|
@@ -24,6 +27,7 @@ type Props = {
|
|
|
24
27
|
isInsideContainer?: boolean;
|
|
25
28
|
extraAnchorPointYOffset: number;
|
|
26
29
|
river?: ZappRiver | ZappEntry;
|
|
30
|
+
groupId: string;
|
|
27
31
|
};
|
|
28
32
|
|
|
29
33
|
export const River = (props: Props) => {
|
|
@@ -35,6 +39,7 @@ export const River = (props: Props) => {
|
|
|
35
39
|
componentsMapExtraProps,
|
|
36
40
|
isInsideContainer,
|
|
37
41
|
extraAnchorPointYOffset,
|
|
42
|
+
groupId,
|
|
38
43
|
} = props;
|
|
39
44
|
|
|
40
45
|
const { title: screenTitle, summary: screenSummary } = useNavbarState();
|
|
@@ -51,28 +56,41 @@ export const River = (props: Props) => {
|
|
|
51
56
|
[screenId]
|
|
52
57
|
);
|
|
53
58
|
|
|
54
|
-
const
|
|
55
|
-
|
|
59
|
+
const screenResolverData = React.useMemo(() => {
|
|
60
|
+
const extraData = mergeRight(extraProps, screenResolverExtraProps);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
extraData,
|
|
64
|
+
screenData: mergeRight(river, { groupId: extraData?.groupId }),
|
|
65
|
+
componentsMapExtraProps: mergeRight(componentsMapExtraProps, { groupId }),
|
|
66
|
+
};
|
|
67
|
+
}, [
|
|
68
|
+
extraProps,
|
|
69
|
+
screenResolverExtraProps,
|
|
70
|
+
river,
|
|
71
|
+
componentsMapExtraProps,
|
|
72
|
+
groupId,
|
|
73
|
+
]);
|
|
56
74
|
|
|
57
75
|
React.useEffect(() => {
|
|
58
76
|
if (!isInsideContainer) {
|
|
59
|
-
setScreenTitle(
|
|
60
|
-
setScreenSummary(
|
|
77
|
+
setScreenTitle(toStringOrEmpty(screenData?.title));
|
|
78
|
+
setScreenSummary(toStringOrEmpty(screenData?.summary));
|
|
61
79
|
}
|
|
62
80
|
}, [screenData.id]);
|
|
63
81
|
|
|
64
82
|
React.useEffect(() => {
|
|
65
83
|
if (feedData && !isInsideContainer) {
|
|
66
84
|
if (feedData.title && feedData.title !== screenTitle) {
|
|
67
|
-
setScreenTitle(
|
|
85
|
+
setScreenTitle(toStringOrEmpty(feedData.title));
|
|
68
86
|
}
|
|
69
87
|
|
|
70
88
|
if (feedData.summary && feedData.summary !== screenSummary) {
|
|
71
|
-
setScreenSummary(
|
|
89
|
+
setScreenSummary(toStringOrEmpty(feedData.summary));
|
|
72
90
|
}
|
|
73
91
|
} else {
|
|
74
|
-
setScreenTitle(
|
|
75
|
-
setScreenSummary(
|
|
92
|
+
setScreenTitle(toStringOrEmpty(screenData?.title));
|
|
93
|
+
setScreenSummary(toStringOrEmpty(screenData?.summary));
|
|
76
94
|
}
|
|
77
95
|
}, [feedData, screenData, screenTitle, screenSummary]);
|
|
78
96
|
|
|
@@ -86,15 +104,13 @@ export const River = (props: Props) => {
|
|
|
86
104
|
}
|
|
87
105
|
|
|
88
106
|
if (river.type !== "general_content") {
|
|
89
|
-
const extraData = { ...R.mergeRight(extraProps, screenResolverExtraProps) };
|
|
90
|
-
|
|
91
107
|
return (
|
|
92
108
|
<ScreenResolver
|
|
93
109
|
screenType={river.type}
|
|
94
110
|
screenId={screenId}
|
|
95
|
-
screenData={
|
|
96
|
-
componentsMapExtraProps={componentsMapExtraProps}
|
|
97
|
-
{...extraData}
|
|
111
|
+
screenData={screenResolverData.screenData}
|
|
112
|
+
componentsMapExtraProps={screenResolverData.componentsMapExtraProps}
|
|
113
|
+
{...screenResolverData.extraData}
|
|
98
114
|
/>
|
|
99
115
|
);
|
|
100
116
|
}
|
|
@@ -106,6 +122,7 @@ export const River = (props: Props) => {
|
|
|
106
122
|
isScreenWrappedInContainer={isInsideContainer}
|
|
107
123
|
extraAnchorPointYOffset={extraAnchorPointYOffset}
|
|
108
124
|
componentsMapExtraProps={componentsMapExtraProps}
|
|
125
|
+
groupId={groupId}
|
|
109
126
|
/>
|
|
110
127
|
);
|
|
111
128
|
};
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
import { compose } from "
|
|
1
|
+
import { compose, identity } from "@applicaster/zapp-react-native-utils/utils";
|
|
2
|
+
import { isTvOSPlatform } from "@applicaster/zapp-react-native-utils/reactUtils";
|
|
3
|
+
|
|
2
4
|
import { River as RiverComponent } from "./River";
|
|
3
|
-
import { withTvEventHandler } from "./withTVEventHandler";
|
|
4
5
|
import { withComponentsMapOffsetContext } from "../../../Contexts/ComponentsMapOffsetContext";
|
|
5
6
|
import { withRiverDataLoader } from "./withRiverDataLoader";
|
|
7
|
+
import { withFocusableGroupForContent } from "./withFocusableGroupForContent";
|
|
8
|
+
|
|
9
|
+
const isTVOS = isTvOSPlatform();
|
|
6
10
|
|
|
7
11
|
export const River = compose(
|
|
8
|
-
withTvEventHandler,
|
|
9
12
|
withComponentsMapOffsetContext,
|
|
10
|
-
withRiverDataLoader
|
|
13
|
+
withRiverDataLoader,
|
|
14
|
+
isTVOS ? withFocusableGroupForContent : identity
|
|
11
15
|
)(RiverComponent);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { toStringOrEmpty } from "..";
|
|
2
|
+
|
|
3
|
+
describe("toStringOrEmpty", () => {
|
|
4
|
+
test("returns empty string for undefined", () => {
|
|
5
|
+
expect(toStringOrEmpty(undefined)).toBe("");
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
test("returns empty string for null", () => {
|
|
9
|
+
expect(toStringOrEmpty(null)).toBe("");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("converts number to string", () => {
|
|
13
|
+
expect(toStringOrEmpty(0)).toBe("0");
|
|
14
|
+
expect(toStringOrEmpty(123)).toBe("123");
|
|
15
|
+
expect(toStringOrEmpty(-42)).toBe("-42");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("returns string as is", () => {
|
|
19
|
+
expect(toStringOrEmpty("hello")).toBe("hello");
|
|
20
|
+
expect(toStringOrEmpty("")).toBe("");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("works with numeric strings", () => {
|
|
24
|
+
expect(toStringOrEmpty("123")).toBe("123");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("does not throw on falsy values like 0", () => {
|
|
28
|
+
expect(toStringOrEmpty(0)).toBe("0");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { View, StyleSheet } from "react-native";
|
|
3
|
+
|
|
4
|
+
import { FocusableGroup } from "@applicaster/zapp-react-native-ui-components/Components/FocusableGroup";
|
|
5
|
+
import { riverFocusManager } from "@applicaster/zapp-react-native-utils/appUtils/RiverFocusManager";
|
|
6
|
+
|
|
7
|
+
import { topMenuLayoutChange$ } from "@applicaster/zapp-react-native-tvos-app/Layout/topMenu";
|
|
8
|
+
|
|
9
|
+
const styles = StyleSheet.create({
|
|
10
|
+
flexOne: {
|
|
11
|
+
flex: 1,
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const withFocusableGroupForContent = (Component) => {
|
|
16
|
+
return function WithFocusableGroupForContent(props) {
|
|
17
|
+
const { screenId, isInsideContainer } = props;
|
|
18
|
+
|
|
19
|
+
const [topMenuHeight, setTopMenuHeight] = React.useState(0);
|
|
20
|
+
|
|
21
|
+
React.useEffect(() => {
|
|
22
|
+
const subscription = topMenuLayoutChange$.subscribe((layout) => {
|
|
23
|
+
setTopMenuHeight(layout.height);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
return () => {
|
|
27
|
+
subscription.unsubscribe();
|
|
28
|
+
};
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
const focusableId = React.useMemo(
|
|
32
|
+
() =>
|
|
33
|
+
riverFocusManager.screenFocusableGroupId({
|
|
34
|
+
screenId,
|
|
35
|
+
isInsideContainer,
|
|
36
|
+
}),
|
|
37
|
+
[screenId, isInsideContainer]
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
if (isInsideContainer) {
|
|
41
|
+
return <Component {...props} />;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<FocusableGroup
|
|
46
|
+
key={focusableId}
|
|
47
|
+
id={focusableId}
|
|
48
|
+
// The top menu is rendered in its own FocusableGroup, anchored at the top of the screen.
|
|
49
|
+
// When the "content" FocusableGroup starts at y = 0 as well, the two groups visually overlap.
|
|
50
|
+
// On TvOS platform this overlap can confuse the focus engine, because the focusable bounds of
|
|
51
|
+
// the top-menu group and the content group intersect, leading to erratic navigation between
|
|
52
|
+
// the menu and the content (e.g. unexpected jumps or focus getting "stuck").
|
|
53
|
+
//
|
|
54
|
+
// To avoid this, we shift the entire content FocusableGroup down by the dynamic top menu
|
|
55
|
+
// height (marginTop: topMenuHeight). This separates the focus regions of the two groups in
|
|
56
|
+
// focus space, so they no longer intersect.
|
|
57
|
+
//
|
|
58
|
+
// The inner <View> below then applies the inverse margin (marginTop: -topMenuHeight) so that
|
|
59
|
+
// the actual visual position of the content on screen does not change; only the focusable
|
|
60
|
+
// bounds of the outer group are offset.
|
|
61
|
+
style={[styles.flexOne, { marginTop: topMenuHeight }]}
|
|
62
|
+
// this group does not have parent
|
|
63
|
+
groupId={undefined}
|
|
64
|
+
>
|
|
65
|
+
<View style={[styles.flexOne, { marginTop: -1 * topMenuHeight }]}>
|
|
66
|
+
<Component {...props} groupId={focusableId} />
|
|
67
|
+
</View>
|
|
68
|
+
</FocusableGroup>
|
|
69
|
+
);
|
|
70
|
+
};
|
|
71
|
+
};
|
|
@@ -137,6 +137,7 @@ exports[`componentsMap renders renders components map correctly 1`] = `
|
|
|
137
137
|
keyExtractor={[Function]}
|
|
138
138
|
maxToRenderPerBatch={10}
|
|
139
139
|
onContentSizeChange={[Function]}
|
|
140
|
+
onEndReached={[Function]}
|
|
140
141
|
onLayout={[Function]}
|
|
141
142
|
onMomentumScrollBegin={[Function]}
|
|
142
143
|
onMomentumScrollEnd={[Function]}
|
|
@@ -154,6 +155,7 @@ exports[`componentsMap renders renders components map correctly 1`] = `
|
|
|
154
155
|
}
|
|
155
156
|
}
|
|
156
157
|
stickyHeaderIndices={[]}
|
|
158
|
+
testID="components-map-flat-list"
|
|
157
159
|
viewabilityConfigCallbackPairs={[]}
|
|
158
160
|
windowSize={12}
|
|
159
161
|
>
|
|
@@ -139,7 +139,13 @@ jest.mock(
|
|
|
139
139
|
})
|
|
140
140
|
);
|
|
141
141
|
|
|
142
|
+
jest.mock("@applicaster/zapp-react-native-ui-components/events", () => ({
|
|
143
|
+
...jest.requireActual("@applicaster/zapp-react-native-ui-components/events"),
|
|
144
|
+
emitScrollEndReached: jest.fn(),
|
|
145
|
+
}));
|
|
146
|
+
|
|
142
147
|
const { View } = require("react-native");
|
|
148
|
+
const events = require("@applicaster/zapp-react-native-ui-components/events");
|
|
143
149
|
const { ComponentsMap } = require("../ComponentsMap/ComponentsMap");
|
|
144
150
|
const theme = require("./theme-mock.json");
|
|
145
151
|
|
|
@@ -190,4 +196,36 @@ describe("componentsMap", () => {
|
|
|
190
196
|
|
|
191
197
|
expect(toJSON()).toMatchSnapshot();
|
|
192
198
|
});
|
|
199
|
+
|
|
200
|
+
it("calls emitScrollEndReached when onScroll was called and isScreenWrappedInContainer is false", () => {
|
|
201
|
+
themeSpy = jest
|
|
202
|
+
.spyOn(themeUtils, "useTheme")
|
|
203
|
+
.mockImplementation(() => () => theme);
|
|
204
|
+
|
|
205
|
+
events.emitScrollEndReached.mockClear();
|
|
206
|
+
|
|
207
|
+
const { getByTestId } = render(
|
|
208
|
+
<Provider store={store}>
|
|
209
|
+
<ComponentsMap
|
|
210
|
+
{...props}
|
|
211
|
+
isScreenWrappedInContainer={false}
|
|
212
|
+
feed={{ entry: [] }}
|
|
213
|
+
/>
|
|
214
|
+
</Provider>
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const flatList = getByTestId("components-map-flat-list");
|
|
218
|
+
|
|
219
|
+
flatList.props.onScroll({
|
|
220
|
+
nativeEvent: {
|
|
221
|
+
contentOffset: { y: 0 },
|
|
222
|
+
layoutMeasurement: { height: 100 },
|
|
223
|
+
contentSize: { height: 200 },
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
flatList.props.onEndReached();
|
|
228
|
+
|
|
229
|
+
expect(events.emitScrollEndReached).toHaveBeenCalledTimes(1);
|
|
230
|
+
});
|
|
193
231
|
});
|
|
@@ -11,6 +11,7 @@ const mockIsOrientationCompatible = jest.fn(() => true);
|
|
|
11
11
|
jest.mock("react-native-safe-area-context", () => ({
|
|
12
12
|
...jest.requireActual("react-native-safe-area-context"),
|
|
13
13
|
useSafeAreaInsets: () => ({ top: 44 }),
|
|
14
|
+
useSafeAreaFrame: () => ({ x: 0, y: 0, width: 375, height: 812 }),
|
|
14
15
|
}));
|
|
15
16
|
|
|
16
17
|
jest.mock("../../RouteManager", () => ({
|
|
@@ -5,15 +5,85 @@ import {
|
|
|
5
5
|
} from "@applicaster/zapp-react-native-utils/appUtils/orientationHelper";
|
|
6
6
|
import {
|
|
7
7
|
useCurrentScreenData,
|
|
8
|
-
useDimensions,
|
|
9
8
|
useRoute,
|
|
10
9
|
useIsTablet,
|
|
10
|
+
useIsScreenActive,
|
|
11
11
|
} from "@applicaster/zapp-react-native-utils/reactHooks";
|
|
12
12
|
import { useMemo, useEffect, useState } from "react";
|
|
13
|
+
import { useSafeAreaFrame } from "react-native-safe-area-context";
|
|
14
|
+
|
|
15
|
+
type MemoizedSafeAreaFrameWithActiveStateOptions = {
|
|
16
|
+
updateForInactiveScreens?: boolean;
|
|
17
|
+
isActive: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Base hook that wraps useSafeAreaFrame with memoization and inactive screen filtering.
|
|
22
|
+
* Requires isActive to be passed explicitly - use useMemoizedSafeAreaFrame for automatic detection.
|
|
23
|
+
*
|
|
24
|
+
* @param options.updateForInactiveScreens - If false, frame won't update when screen is inactive (default: true)
|
|
25
|
+
* @param options.isActive - Whether the screen is currently active
|
|
26
|
+
* @returns The memoized safe area frame { x, y, width, height }
|
|
27
|
+
*/
|
|
28
|
+
export const useMemoizedSafeAreaFrameWithActiveState = (
|
|
29
|
+
options: MemoizedSafeAreaFrameWithActiveStateOptions
|
|
30
|
+
) => {
|
|
31
|
+
const { updateForInactiveScreens = true, isActive } = options;
|
|
32
|
+
const frame = useSafeAreaFrame();
|
|
33
|
+
|
|
34
|
+
const [memoFrame, setMemoFrame] = useState(frame);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const shouldUpdate = isActive || updateForInactiveScreens;
|
|
38
|
+
|
|
39
|
+
const dimensionsChanged =
|
|
40
|
+
frame.width !== memoFrame.width || frame.height !== memoFrame.height;
|
|
41
|
+
|
|
42
|
+
if (shouldUpdate && dimensionsChanged) {
|
|
43
|
+
setMemoFrame(frame);
|
|
44
|
+
}
|
|
45
|
+
}, [
|
|
46
|
+
frame.width,
|
|
47
|
+
frame.height,
|
|
48
|
+
isActive,
|
|
49
|
+
updateForInactiveScreens,
|
|
50
|
+
frame,
|
|
51
|
+
memoFrame.width,
|
|
52
|
+
memoFrame.height,
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
return memoFrame;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type MemoizedSafeAreaFrameOptions = {
|
|
59
|
+
updateForInactiveScreens?: boolean;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Hook that wraps useSafeAreaFrame with memoization and inactive screen filtering.
|
|
64
|
+
* Uses useIsScreenActive() internally to determine active state - use useMemoizedSafeAreaFrameWithActiveState
|
|
65
|
+
* if you need to pass isActive explicitly.
|
|
66
|
+
*
|
|
67
|
+
* @param options.updateForInactiveScreens - If false, frame won't update when screen is inactive (default: true)
|
|
68
|
+
* @returns The memoized safe area frame { x, y, width, height }
|
|
69
|
+
*/
|
|
70
|
+
export const useMemoizedSafeAreaFrame = (
|
|
71
|
+
options: MemoizedSafeAreaFrameOptions = {}
|
|
72
|
+
) => {
|
|
73
|
+
const { updateForInactiveScreens = true } = options;
|
|
74
|
+
const isActive = useIsScreenActive();
|
|
75
|
+
|
|
76
|
+
return useMemoizedSafeAreaFrameWithActiveState({
|
|
77
|
+
updateForInactiveScreens,
|
|
78
|
+
isActive,
|
|
79
|
+
});
|
|
80
|
+
};
|
|
13
81
|
|
|
14
82
|
export const useWaitForValidOrientation = () => {
|
|
15
|
-
|
|
16
|
-
|
|
83
|
+
// Use memoized safe area frame to synchronize with Scene's dimension source
|
|
84
|
+
// This prevents race conditions where the orientation check passes before
|
|
85
|
+
// Scene's memoFrame has updated to the new dimensions
|
|
86
|
+
const { width: screenWidth, height } = useMemoizedSafeAreaFrame({
|
|
17
87
|
updateForInactiveScreens: false,
|
|
18
88
|
});
|
|
19
89
|
|
|
@@ -92,7 +92,13 @@ export function Screen(_props: Props) {
|
|
|
92
92
|
const isActive = useIsScreenActive();
|
|
93
93
|
|
|
94
94
|
const ref = React.useRef(null);
|
|
95
|
-
const
|
|
95
|
+
const isOrientationReady = useWaitForValidOrientation();
|
|
96
|
+
|
|
97
|
+
// Playable screens handle their own orientation via the native player plugin,
|
|
98
|
+
// so we skip the orientation wait gate to avoid a deadlock where the screen
|
|
99
|
+
// waits for landscape but blocks rendering that would trigger the rotation.
|
|
100
|
+
const isPlayableRoute = pathname?.includes("/playable");
|
|
101
|
+
const isReady = isOrientationReady || isPlayableRoute;
|
|
96
102
|
|
|
97
103
|
React.useEffect(() => {
|
|
98
104
|
if (ref.current && isActive && isReady) {
|