@applicaster/zapp-react-native-ui-components 15.0.0-alpha.5170277721 → 15.0.0-alpha.5197372201

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.
Files changed (119) hide show
  1. package/Components/BaseFocusable/index.ios.ts +12 -2
  2. package/Components/Cell/Cell.tsx +14 -3
  3. package/Components/Cell/CellWithFocusable.tsx +9 -0
  4. package/Components/Cell/FocusableWrapper.tsx +3 -0
  5. package/Components/Cell/TvOSCellComponent.tsx +26 -5
  6. package/Components/Focusable/Focusable.tsx +4 -2
  7. package/Components/Focusable/FocusableTvOS.tsx +18 -1
  8. package/Components/Focusable/__tests__/__snapshots__/FocusableTvOS.test.tsx.snap +1 -0
  9. package/Components/FocusableGroup/FocusableTvOS.tsx +32 -1
  10. package/Components/GeneralContentScreen/GeneralContentScreen.tsx +3 -8
  11. package/Components/GeneralContentScreen/utils/__tests__/useCurationAPI.test.js +1 -1
  12. package/Components/GeneralContentScreen/utils/useCurationAPI.ts +22 -6
  13. package/Components/HandlePlayable/HandlePlayable.tsx +26 -36
  14. package/Components/HandlePlayable/utils.ts +31 -0
  15. package/Components/Layout/TV/LayoutBackground.tsx +5 -2
  16. package/Components/Layout/TV/ScreenContainer.tsx +2 -6
  17. package/Components/Layout/TV/index.tsx +3 -4
  18. package/Components/Layout/TV/index.web.tsx +3 -4
  19. package/Components/LinkHandler/LinkHandler.tsx +2 -2
  20. package/Components/MasterCell/DefaultComponents/BorderContainerView/__tests__/index.test.tsx +16 -1
  21. package/Components/MasterCell/DefaultComponents/BorderContainerView/index.tsx +30 -2
  22. package/Components/MasterCell/DefaultComponents/Image/Image.android.tsx +5 -1
  23. package/Components/MasterCell/DefaultComponents/Image/Image.ios.tsx +11 -3
  24. package/Components/MasterCell/DefaultComponents/Image/Image.web.tsx +9 -1
  25. package/Components/MasterCell/DefaultComponents/Image/hooks/useImage.ts +15 -14
  26. package/Components/MasterCell/DefaultComponents/LiveImage/index.tsx +10 -6
  27. package/Components/MasterCell/DefaultComponents/SecondaryImage/Image.tsx +40 -39
  28. package/Components/MasterCell/DefaultComponents/SecondaryImage/__tests__/Image.test.tsx +95 -0
  29. package/Components/MasterCell/DefaultComponents/SecondaryImage/__tests__/__snapshots__/Image.test.tsx.snap +86 -0
  30. package/Components/MasterCell/DefaultComponents/SecondaryImage/__tests__/index.test.ts +141 -0
  31. package/Components/MasterCell/DefaultComponents/SecondaryImage/hooks/__tests__/useGetImageDimensions.test.ts +7 -6
  32. package/Components/MasterCell/DefaultComponents/SecondaryImage/index.ts +1 -1
  33. package/Components/MasterCell/DefaultComponents/Text/index.tsx +8 -8
  34. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/index.ts +6 -2
  35. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/utils/__tests__/getPluginIdentifier.test.ts +233 -11
  36. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/utils/index.ts +19 -15
  37. package/Components/MasterCell/hoc/__tests__/withAsyncRender.test.tsx +219 -0
  38. package/Components/MasterCell/hoc/withAsyncRender.tsx +9 -7
  39. package/Components/MasterCell/index.tsx +2 -0
  40. package/Components/MasterCell/utils/__tests__/resolveColor.test.js +82 -3
  41. package/Components/MasterCell/utils/index.ts +61 -31
  42. package/Components/MeasurmentsPortal/MeasurementsPortal.tsx +102 -87
  43. package/Components/MeasurmentsPortal/__tests__/MeasurementsPortal.test.tsx +355 -0
  44. package/Components/OfflineHandler/NotificationView/NotificationView.lg.tsx +17 -9
  45. package/Components/OfflineHandler/NotificationView/NotificationView.samsung.tsx +16 -8
  46. package/Components/OfflineHandler/NotificationView/NotificationView.tsx +2 -2
  47. package/Components/OfflineHandler/NotificationView/__tests__/index.test.tsx +17 -18
  48. package/Components/OfflineHandler/NotificationView/utils.ts +34 -0
  49. package/Components/OfflineHandler/__tests__/index.test.tsx +27 -18
  50. package/Components/PlayerContainer/PlayerContainer.tsx +43 -55
  51. package/Components/PlayerImageBackground/index.tsx +3 -22
  52. package/Components/River/ComponentsMap/ComponentsMap.tsx +16 -0
  53. package/Components/River/ComponentsMap/hooks/__tests__/useLoadingState.test.ts +1 -1
  54. package/Components/River/RefreshControl.tsx +9 -3
  55. package/Components/River/TV/River.tsx +31 -14
  56. package/Components/River/TV/index.tsx +8 -4
  57. package/Components/River/TV/utils/__tests__/toStringOrEmpty.test.ts +30 -0
  58. package/Components/River/TV/utils/index.ts +4 -0
  59. package/Components/River/TV/withFocusableGroupForContent.tsx +71 -0
  60. package/Components/River/__tests__/__snapshots__/componentsMap.test.js.snap +2 -0
  61. package/Components/River/__tests__/componentsMap.test.js +38 -0
  62. package/Components/Screen/TV/index.web.tsx +4 -2
  63. package/Components/Screen/__tests__/Screen.test.tsx +66 -42
  64. package/Components/Screen/__tests__/__snapshots__/Screen.test.tsx.snap +68 -44
  65. package/Components/Screen/hooks.ts +75 -6
  66. package/Components/Screen/index.tsx +9 -4
  67. package/Components/Screen/navigationHandler.ts +49 -24
  68. package/Components/Screen/orientationHandler.ts +10 -13
  69. package/Components/ScreenResolver/index.tsx +26 -16
  70. package/Components/ScreenRevealManager/ScreenRevealManager.ts +40 -8
  71. package/Components/ScreenRevealManager/__tests__/ScreenRevealManager.test.ts +86 -69
  72. package/Components/Tabs/TV/Tabs.tsx +20 -3
  73. package/Components/Tabs/TabContent.tsx +7 -4
  74. package/Components/Transitioner/Scene.tsx +10 -3
  75. package/Components/Transitioner/index.js +3 -3
  76. package/Components/VideoModal/ModalAnimation/ModalAnimationContext.tsx +118 -171
  77. package/Components/VideoModal/ModalAnimation/index.ts +2 -13
  78. package/Components/VideoModal/ModalAnimation/utils.ts +1 -327
  79. package/Components/VideoModal/PlayerWrapper.tsx +14 -52
  80. package/Components/VideoModal/VideoModal.tsx +1 -5
  81. package/Components/VideoModal/__tests__/PlayerWrapper.test.tsx +1 -0
  82. package/Components/VideoModal/hooks/__tests__/useDelayedPlayerDetails.test.ts +15 -7
  83. package/Components/VideoModal/hooks/useModalSize.ts +5 -1
  84. package/Components/VideoModal/playerWrapperStyle.ts +70 -0
  85. package/Components/VideoModal/playerWrapperUtils.ts +16 -12
  86. package/Components/VideoModal/utils.ts +19 -9
  87. package/Components/Viewport/ViewportAware/__tests__/viewportAware.test.js +0 -2
  88. package/Components/Viewport/ViewportAware/index.tsx +16 -7
  89. package/Components/Viewport/ViewportEvents/__tests__/viewportEvents.test.js +1 -1
  90. package/Contexts/ScreenContext/index.tsx +54 -19
  91. package/Contexts/ScreenTrackedViewPositionsContext/__tests__/index.test.tsx +1 -1
  92. package/Contexts/ZappHookModalContext/index.tsx +37 -61
  93. package/Contexts/ZappPipesContext/ZappPipesContextFactory.tsx +18 -7
  94. package/Contexts/index.ts +0 -2
  95. package/Decorators/Analytics/index.tsx +6 -5
  96. package/Decorators/ConfigurationWrapper/__tests__/__snapshots__/withConfigurationProvider.test.tsx.snap +1 -0
  97. package/Decorators/ConfigurationWrapper/const.ts +1 -0
  98. package/Decorators/ZappPipesDataConnector/ResolverSelector.tsx +25 -7
  99. package/Decorators/ZappPipesDataConnector/__tests__/ResolverSelector.test.tsx +212 -5
  100. package/Decorators/ZappPipesDataConnector/__tests__/UrlFeedResolver.test.tsx +39 -21
  101. package/Decorators/ZappPipesDataConnector/__tests__/zappPipesDataConnector.test.js +1 -1
  102. package/Decorators/ZappPipesDataConnector/index.tsx +2 -2
  103. package/Decorators/ZappPipesDataConnector/resolvers/StaticFeedResolver.tsx +1 -1
  104. package/Decorators/ZappPipesDataConnector/resolvers/UrlFeedResolver.tsx +18 -7
  105. package/Helpers/DataSourceHelper/__tests__/itemLimitForData.test.ts +80 -0
  106. package/Helpers/DataSourceHelper/index.ts +19 -0
  107. package/events/index.ts +3 -0
  108. package/events/scrollEndReached.ts +15 -0
  109. package/index.d.ts +7 -0
  110. package/package.json +6 -5
  111. package/Components/River/TV/withTVEventHandler.tsx +0 -27
  112. package/Components/VideoModal/ModalAnimation/AnimatedPlayerModalWrapper.tsx +0 -60
  113. package/Components/VideoModal/ModalAnimation/AnimatedScrollModal.tsx +0 -417
  114. package/Components/VideoModal/ModalAnimation/AnimatedScrollModal.web.tsx +0 -294
  115. package/Components/VideoModal/ModalAnimation/AnimatedVideoPlayerComponent.tsx +0 -176
  116. package/Components/VideoModal/ModalAnimation/AnimatedVideoPlayerComponent.web.tsx +0 -93
  117. package/Components/VideoModal/ModalAnimation/AnimationComponent.tsx +0 -500
  118. package/Components/VideoModal/ModalAnimation/__tests__/getMoveUpValue.test.ts +0 -108
  119. package/Helpers/DataSourceHelper/index.js +0 -19
@@ -8,25 +8,24 @@ import {
8
8
  offlinePhrase,
9
9
  } from "../NotificationView";
10
10
 
11
- jest.mock("@applicaster/zapp-react-native-redux/hooks", () => ({
12
- usePickFromState: () => ({
13
- plugins: [
14
- {
15
- name: "offline experience",
16
- identifier: "offline-experience",
17
- type: "general",
18
- module: {
19
- useOfflineExperienceConfiguration: () => ({
20
- configurationFields: {},
21
- localizations: {
22
- offline_toast_message: "No internet connection",
23
- online_toast_message: "You are back online",
24
- },
25
- }),
26
- },
11
+ jest.mock("@applicaster/zapp-react-native-redux", () => ({
12
+ ...jest.requireActual("@applicaster/zapp-react-native-redux"),
13
+ usePlugins: () => [
14
+ {
15
+ name: "offline experience",
16
+ identifier: "offline-experience",
17
+ type: "general",
18
+ module: {
19
+ useOfflineExperienceConfiguration: () => ({
20
+ configurationFields: {},
21
+ localizations: {
22
+ offline_toast_message: "No internet connection",
23
+ online_toast_message: "You are back online",
24
+ },
25
+ }),
27
26
  },
28
- ],
29
- }),
27
+ },
28
+ ],
30
29
  }));
31
30
 
32
31
  jest.mock("react-native-safe-area-context", () => ({
@@ -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
+ }
@@ -8,36 +8,45 @@ const mockPreviousValue = null;
8
8
 
9
9
  jest.mock("@applicaster/zapp-react-native-utils/reactHooks/connection", () => {
10
10
  return {
11
+ ...jest.requireActual(
12
+ "@applicaster/zapp-react-native-utils/reactHooks/connection"
13
+ ),
11
14
  useConnectionInfo: jest.fn(() => mockConnectionStatus),
12
15
  };
13
16
  });
14
17
 
15
18
  jest.mock("@applicaster/zapp-react-native-utils/reactHooks/utils", () => {
16
19
  return {
20
+ ...jest.requireActual(
21
+ "@applicaster/zapp-react-native-utils/reactHooks/utils"
22
+ ),
17
23
  usePrevious: jest.fn(() => mockPreviousValue),
18
24
  };
19
25
  });
20
26
 
27
+ const mock_storeObj = {
28
+ plugins: [
29
+ {
30
+ name: "offline experience",
31
+ identifier: "offline-experience",
32
+ type: "general",
33
+ module: {
34
+ useOfflineExperienceConfiguration: () => ({
35
+ configurationFields: {},
36
+ localizations: {
37
+ offline_toast_message: "No internet connection",
38
+ online_toast_message: "You are back online",
39
+ },
40
+ }),
41
+ },
42
+ },
43
+ ],
44
+ };
45
+
21
46
  jest.mock("@applicaster/zapp-react-native-redux/hooks", () => ({
22
47
  ...jest.requireActual("@applicaster/zapp-react-native-redux/hooks"),
23
- usePickFromState: () => ({
24
- plugins: [
25
- {
26
- name: "offline experience",
27
- identifier: "offline-experience",
28
- type: "general",
29
- module: {
30
- useOfflineExperienceConfiguration: () => ({
31
- configurationFields: {},
32
- localizations: {
33
- offline_toast_message: "No internet connection",
34
- online_toast_message: "You are back online",
35
- },
36
- }),
37
- },
38
- },
39
- ],
40
- }),
48
+ usePlugins: () => mock_storeObj.plugins,
49
+ usePickFromState: () => ({}),
41
50
  }));
42
51
 
43
52
  jest.mock("react-native-safe-area-context", () => ({
@@ -17,7 +17,7 @@ import {
17
17
 
18
18
  import { TVEventHandlerComponent } from "@applicaster/zapp-react-native-tvos-ui-components/Components/TVEventHandlerComponent";
19
19
  import { usePrevious } from "@applicaster/zapp-react-native-utils/reactHooks/utils";
20
- import { usePickFromState } from "@applicaster/zapp-react-native-redux/hooks";
20
+
21
21
  import {
22
22
  useBackHandler,
23
23
  useNavigation,
@@ -56,15 +56,11 @@ import { toNumber } from "@applicaster/zapp-react-native-utils/numberUtils";
56
56
  import { usePlayNextOverlay } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayNextOverlay";
57
57
  import { PlayNextState } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/OverlayObserver/OverlaysObserver";
58
58
 
59
- import {
60
- PlayerAnimationStateEnum,
61
- useModalAnimationContext,
62
- } from "@applicaster/zapp-react-native-ui-components/Components/VideoModal/ModalAnimation";
63
-
64
59
  import {
65
60
  PlayerNativeCommandTypes,
66
61
  PlayerNativeSendCommand,
67
62
  } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/playerNativeCommand";
63
+ import { useAppData } from "@applicaster/zapp-react-native-redux";
68
64
 
69
65
  type Props = {
70
66
  Player: React.ComponentType<any>;
@@ -243,14 +239,11 @@ const PlayerContainerComponent = (props: Props) => {
243
239
  const [isLoadingNextVideo, setIsLoadingNextVideo] = React.useState(false);
244
240
 
245
241
  const navigator = useNavigation();
246
- const { appData } = usePickFromState(["appData"]);
242
+ const { isTabletPortrait } = useAppData();
247
243
  const prevItemId = usePrevious(item?.id);
248
244
  const screenData = useTargetScreenData(item);
249
245
  const { setVisible: showNavBar } = useSetNavbarState();
250
246
 
251
- const { isActiveGesture, startComponentsAnimation, setPlayerAnimationState } =
252
- useModalAnimationContext();
253
-
254
247
  const playerEvent = (event, ...args) => {
255
248
  playerManager.invokeHandler(event, ...args);
256
249
  };
@@ -271,7 +264,14 @@ const PlayerContainerComponent = (props: Props) => {
271
264
 
272
265
  showNavBar(true);
273
266
  navigator.goBack();
274
- }, [isModal, navigator.goBack, state.playerId, showNavBar]);
267
+ }, [isModal, state.playerId, showNavBar, navigator]);
268
+
269
+ const pluginConfiguration = React.useMemo(() => {
270
+ return (
271
+ playerManager.getPluginConfiguration() ||
272
+ R.prop("__plugin_configuration", Player)
273
+ );
274
+ }, [playerManager.isRegistered()]);
275
275
 
276
276
  const playEntry = (entry) => navigator.replaceTop(entry, { mode });
277
277
 
@@ -463,13 +463,6 @@ const PlayerContainerComponent = (props: Props) => {
463
463
  }
464
464
  }, []);
465
465
 
466
- const pluginConfiguration = React.useMemo(() => {
467
- return (
468
- playerManager.getPluginConfiguration() ||
469
- R.prop("__plugin_configuration", Player)
470
- );
471
- }, [playerManager.isRegistered()]);
472
-
473
466
  const disableMiniPlayer = React.useMemo(() => {
474
467
  return pluginConfiguration?.disable_mini_player_when_inline;
475
468
  }, [pluginConfiguration]);
@@ -482,8 +475,6 @@ const PlayerContainerComponent = (props: Props) => {
482
475
  if (isModal && mode === VideoModalMode.MAXIMIZED) {
483
476
  if (disableMiniPlayer) {
484
477
  navigator.closeVideoModal();
485
- } else {
486
- setPlayerAnimationState(PlayerAnimationStateEnum.minimize);
487
478
  }
488
479
  }
489
480
 
@@ -671,41 +662,38 @@ const PlayerContainerComponent = (props: Props) => {
671
662
  <PlayerFocusableWrapperView
672
663
  nextFocusDown={context.bottomFocusableId}
673
664
  >
674
- <Player
675
- source={{
676
- uri,
677
- entry: item,
678
- }}
679
- focused={isInlineTV ? true : undefined}
680
- autoplay={true}
681
- controls={false}
682
- disableCastAction={disableCastAction}
683
- docked={
684
- navigator.isVideoModalDocked() &&
685
- !startComponentsAnimation &&
686
- !isActiveGesture
687
- }
688
- entry={item}
689
- fullscreen={mode === VideoModalMode.FULLSCREEN}
690
- inline={inline}
691
- isModal={isModal}
692
- isTabletPortrait={appData.isTabletPortrait}
693
- muted={false}
694
- playableItem={item}
695
- playerEvent={playerEvent}
696
- playerId={state.playerId}
697
- pluginConfiguration={pluginConfiguration}
698
- ref={playerRef}
699
- toggleFullscreen={toggleFullscreen}
700
- style={videoStyle}
701
- playNextData={playNextData}
702
- setNextVideoPreloadThresholdPercentage={
703
- setNextVideoPreloadThresholdPercentage
704
- }
705
- startComponentsAnimation={startComponentsAnimation}
706
- >
707
- {renderApplePlayer(applePlayerProps)}
708
- </Player>
665
+ {!Player ? null : (
666
+ <Player
667
+ source={{
668
+ uri,
669
+ entry: item,
670
+ }}
671
+ focused={isInlineTV ? true : undefined}
672
+ autoplay={true}
673
+ controls={false}
674
+ disableCastAction={disableCastAction}
675
+ docked={navigator.isVideoModalDocked()}
676
+ entry={item}
677
+ fullscreen={mode === VideoModalMode.FULLSCREEN}
678
+ inline={inline}
679
+ isModal={isModal}
680
+ isTabletPortrait={isTabletPortrait}
681
+ muted={false}
682
+ playableItem={item}
683
+ playerEvent={playerEvent}
684
+ playerId={state.playerId}
685
+ pluginConfiguration={pluginConfiguration}
686
+ ref={playerRef}
687
+ toggleFullscreen={toggleFullscreen}
688
+ style={videoStyle}
689
+ playNextData={playNextData}
690
+ setNextVideoPreloadThresholdPercentage={
691
+ setNextVideoPreloadThresholdPercentage
692
+ }
693
+ >
694
+ {renderApplePlayer(applePlayerProps)}
695
+ </Player>
696
+ )}
709
697
  </PlayerFocusableWrapperView>
710
698
 
711
699
  {state.error ? <ErrorDisplay error={state.error} /> : null}
@@ -2,12 +2,6 @@ import React, { PropsWithChildren } from "react";
2
2
  import { ImageBackground, View } from "react-native";
3
3
 
4
4
  import { imageSrcFromMediaItem } from "@applicaster/zapp-react-native-utils/configurationUtils";
5
- import {
6
- AnimationComponent,
7
- ComponentAnimationType,
8
- useModalAnimationContext,
9
- PlayerAnimationStateEnum,
10
- } from "@applicaster/zapp-react-native-ui-components/Components/VideoModal/ModalAnimation";
11
5
 
12
6
  type Props = PropsWithChildren<{
13
7
  entry: ZappEntry;
@@ -25,30 +19,17 @@ const PlayerImageBackgroundComponent = ({
25
19
  style,
26
20
  imageStyle,
27
21
  imageKey,
28
- defaultImageDimensions,
29
22
  }: Props) => {
30
23
  const source = React.useMemo(
31
24
  () => ({ uri: imageSrcFromMediaItem(entry, [imageKey]) }),
32
25
  [imageKey, entry]
33
26
  );
34
27
 
35
- const { playerAnimationState } = useModalAnimationContext();
36
-
37
28
  if (!source) return <>{children}</>;
38
29
 
39
30
  return (
40
- <View
41
- style={
42
- playerAnimationState === PlayerAnimationStateEnum.maximize
43
- ? defaultImageDimensions
44
- : style
45
- }
46
- >
47
- <AnimationComponent
48
- style={style}
49
- animationType={ComponentAnimationType.player}
50
- additionalData={defaultImageDimensions}
51
- >
31
+ <View style={style}>
32
+ <View style={style}>
52
33
  <ImageBackground
53
34
  resizeMode="cover"
54
35
  style={imageSize}
@@ -57,7 +38,7 @@ const PlayerImageBackgroundComponent = ({
57
38
  >
58
39
  {children}
59
40
  </ImageBackground>
60
- </AnimationComponent>
41
+ </View>
61
42
  </View>
62
43
  );
63
44
  };
@@ -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>
@@ -1,4 +1,4 @@
1
- import { renderHook, act } from "@testing-library/react-hooks";
1
+ import { renderHook, act } from "@testing-library/react-native";
2
2
  import { BehaviorSubject } from "rxjs";
3
3
  import { useLoadingState } from "../useLoadingState";
4
4
 
@@ -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
- riverComponents?.map(R.path(["data", "source"])).filter((feed) => !!feed) ??
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
- import * as R from "ramda";
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 stringOrEmpty = (value: string | number | undefined): string =>
55
- R.isNil(value) ? "" : String(value);
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(stringOrEmpty(screenData?.title));
60
- setScreenSummary(stringOrEmpty(screenData?.summary));
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(stringOrEmpty(feedData.title));
85
+ setScreenTitle(toStringOrEmpty(feedData.title));
68
86
  }
69
87
 
70
88
  if (feedData.summary && feedData.summary !== screenSummary) {
71
- setScreenSummary(stringOrEmpty(feedData.summary));
89
+ setScreenSummary(toStringOrEmpty(feedData.summary));
72
90
  }
73
91
  } else {
74
- setScreenTitle(stringOrEmpty(screenData?.title));
75
- setScreenSummary(stringOrEmpty(screenData?.summary));
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={R.merge(river, { groupId: extraData?.groupId })}
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 "ramda";
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,4 @@
1
+ import { isNil } from "@applicaster/zapp-react-native-utils/utils";
2
+
3
+ export const toStringOrEmpty = (value: unknown): string =>
4
+ isNil(value) ? "" : String(value);
@@ -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
+ };