@applicaster/zapp-react-native-ui-components 15.1.0-rc.8 → 16.0.0-alpha.1011304583

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 (178) hide show
  1. package/Components/BackgroundImage/BackgroundImage.android.tv.tsx +28 -0
  2. package/Components/BackgroundImage/BackgroundImage.ios.tv.tsx +24 -0
  3. package/Components/BackgroundImage/BackgroundImage.tsx +42 -0
  4. package/Components/BackgroundImage/index.ts +1 -0
  5. package/Components/BaseFocusable/index.ios.ts +12 -2
  6. package/Components/Cell/FocusableWrapper.tsx +3 -0
  7. package/Components/Cell/TvOSCellComponent.tsx +6 -3
  8. package/Components/CellRendererResolver/index.ts +2 -2
  9. package/Components/ComponentResolver/__tests__/componentResolver.test.js +1 -1
  10. package/Components/Focusable/Focusable.tsx +4 -2
  11. package/Components/Focusable/FocusableTvOS.tsx +18 -1
  12. package/Components/Focusable/__tests__/__snapshots__/FocusableTvOS.test.tsx.snap +1 -0
  13. package/Components/FocusableGroup/FocusableTvOS.tsx +41 -8
  14. package/Components/GeneralContentScreen/GeneralContentScreenHookAdapter.tsx +39 -0
  15. package/Components/GeneralContentScreen/__tests__/GeneralContentScreenHookAdapter.test.tsx +64 -0
  16. package/Components/GeneralContentScreen/__tests__/HookContentFocusGroup.web.test.tsx +91 -0
  17. package/Components/GeneralContentScreen/hookAdapter/__tests__/networkService.test.ts +74 -0
  18. package/Components/GeneralContentScreen/hookAdapter/__tests__/runInBackground.test.ts +139 -0
  19. package/Components/GeneralContentScreen/hookAdapter/__tests__/validationHelper.test.ts +124 -0
  20. package/Components/GeneralContentScreen/hookAdapter/logger.ts +6 -0
  21. package/Components/GeneralContentScreen/hookAdapter/networkService.ts +53 -0
  22. package/Components/GeneralContentScreen/hookAdapter/runInBackground.ts +48 -0
  23. package/Components/GeneralContentScreen/hookAdapter/validationHelper.ts +72 -0
  24. package/Components/GeneralContentScreen/hookFocus/index.tsx +13 -0
  25. package/Components/GeneralContentScreen/hookFocus/index.web.tsx +69 -0
  26. package/Components/GeneralContentScreen/index.ts +2 -0
  27. package/Components/GeneralContentScreen/utils/__tests__/useCurationAPI.test.js +1 -1
  28. package/Components/HandlePlayable/HandlePlayable.tsx +13 -8
  29. package/Components/Layout/FullWidthRow.tsx +38 -0
  30. package/Components/Layout/TV/NavBarContainer.tsx +1 -10
  31. package/Components/Layout/TV/ScreenContainer.tsx +2 -6
  32. package/Components/Layout/TV/__tests__/__snapshots__/NavBarContainer.test.tsx.snap +7 -12
  33. package/Components/Layout/TV/__tests__/__snapshots__/ScreenContainer.test.tsx.snap +7 -12
  34. package/Components/Layout/TV/__tests__/__snapshots__/index.test.tsx.snap +0 -1
  35. package/Components/Layout/TV/index.tsx +5 -7
  36. package/Components/Layout/TV/index.web.tsx +5 -7
  37. package/Components/LinkHandler/LinkHandler.tsx +2 -2
  38. package/Components/MasterCell/CONFIG_BUILDER_TO_REACT_COMPONENT.md +144 -0
  39. package/Components/MasterCell/DefaultComponents/ActionButton.tsx +16 -5
  40. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/__tests__/model.test.ts +80 -0
  41. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/__tests__/placement.test.ts +187 -0
  42. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/__tests__/selectors.test.ts +45 -0
  43. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/__tests__/style.test.ts +49 -0
  44. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/components/ActionButtonController.tsx +165 -0
  45. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/components/__tests__/ActionButtonController.test.tsx +405 -0
  46. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/components/index.ts +1 -0
  47. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/model.ts +47 -0
  48. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/placement.ts +170 -0
  49. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/selectors.ts +26 -0
  50. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/style.ts +29 -0
  51. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/types.ts +37 -0
  52. package/Components/MasterCell/DefaultComponents/BorderContainerView/index.tsx +4 -10
  53. package/Components/MasterCell/DefaultComponents/Button.tsx +0 -15
  54. package/Components/MasterCell/DefaultComponents/ButtonContainerView/components/HorizontalSeparator.tsx +8 -0
  55. package/Components/MasterCell/DefaultComponents/ButtonContainerView/index.android.tv.tsx +58 -0
  56. package/Components/MasterCell/DefaultComponents/ButtonContainerView/index.tsx +15 -0
  57. package/Components/MasterCell/DefaultComponents/{tv/ButtonContainerView/index.tsx → ButtonContainerView/index.tv.tsx} +3 -11
  58. package/Components/MasterCell/DefaultComponents/ButtonContainerView/index.web.ts +1 -0
  59. package/Components/MasterCell/DefaultComponents/ButtonContainerView/types.ts +40 -0
  60. package/Components/MasterCell/DefaultComponents/DataProvider/index.tsx +163 -0
  61. package/Components/MasterCell/DefaultComponents/FocusableView/index.android.tsx +2 -23
  62. package/Components/MasterCell/DefaultComponents/FocusableView/index.tsx +4 -22
  63. package/Components/MasterCell/DefaultComponents/Image/Image.android.tsx +8 -2
  64. package/Components/MasterCell/DefaultComponents/Image/Image.ios.tsx +11 -3
  65. package/Components/MasterCell/DefaultComponents/Image/Image.web.tsx +9 -1
  66. package/Components/MasterCell/DefaultComponents/Image/hooks/useImage.ts +15 -14
  67. package/Components/MasterCell/DefaultComponents/LiveImage/index.tsx +1 -2
  68. package/Components/MasterCell/DefaultComponents/PressableView.tsx +34 -0
  69. package/Components/MasterCell/DefaultComponents/SecondaryImage/hooks/__tests__/useGetImageDimensions.test.ts +7 -6
  70. package/Components/MasterCell/DefaultComponents/Text/hooks/useText.ts +11 -0
  71. package/Components/MasterCell/DefaultComponents/__tests__/DataProvider.test.tsx +141 -0
  72. package/Components/MasterCell/DefaultComponents/index.ts +9 -3
  73. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/ActionButton.tsx +135 -0
  74. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/Asset.ts +33 -0
  75. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/AssetComponent.tsx +22 -0
  76. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/Button.ts +125 -0
  77. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/Spacer.ts +16 -0
  78. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/TextLabel.ts +67 -0
  79. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/TextLabelsContainer.ts +37 -0
  80. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/__tests__/PressableView.test.tsx +393 -0
  81. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/__tests__/builders.test.ts +141 -0
  82. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/__tests__/index.test.ts +343 -0
  83. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/helpers.ts +105 -0
  84. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/index.ts +122 -0
  85. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/utils/__tests__/insertButtons.test.ts +118 -0
  86. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/utils/index.ts +238 -0
  87. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/Asset.ts +4 -18
  88. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/Button.ts +24 -73
  89. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/TextLabelsContainer.ts +37 -18
  90. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/TvActionButton.tsx +27 -0
  91. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/__tests__/index.test.ts +89 -0
  92. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/__tests__/renderedTree.test.tsx +231 -0
  93. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/index.ts +47 -48
  94. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/utils/__tests__/getPluginIdentifier.test.ts +115 -29
  95. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/utils/index.ts +101 -144
  96. package/Components/MasterCell/MappingFunctions/index.js +3 -2
  97. package/Components/MasterCell/README.md +4 -0
  98. package/Components/MasterCell/__tests__/__snapshots__/dataAdapter.test.js.snap +24 -0
  99. package/Components/MasterCell/__tests__/configInflater.test.js +1 -0
  100. package/Components/MasterCell/__tests__/elementMapper.test.js +46 -0
  101. package/Components/MasterCell/dataAdapter.ts +19 -6
  102. package/Components/MasterCell/elementMapper.tsx +52 -7
  103. package/Components/MasterCell/index.tsx +30 -5
  104. package/Components/MasterCell/utils/__tests__/cloneChildrenWithIds.test.tsx +43 -0
  105. package/Components/MasterCell/utils/__tests__/resolveColor.test.js +169 -132
  106. package/Components/MasterCell/utils/__tests__/resolveColorForProp.test.js +92 -0
  107. package/Components/MasterCell/utils/__tests__/useFilterChildren.test.tsx +80 -0
  108. package/Components/MasterCell/utils/index.ts +170 -61
  109. package/Components/OfflineHandler/NotificationView/NotificationView.tsx +2 -2
  110. package/Components/OfflineHandler/NotificationView/__tests__/index.test.tsx +17 -18
  111. package/Components/OfflineHandler/__tests__/index.test.tsx +27 -18
  112. package/Components/PlayerContainer/PlayerContainer.tsx +22 -17
  113. package/Components/PlayerContainer/__tests__/PlayerContainer.test.tsx +284 -0
  114. package/Components/River/ComponentsMap/ComponentsMap.tsx +8 -21
  115. package/Components/River/ComponentsMap/hooks/__tests__/useLoadingState.test.ts +1 -1
  116. package/Components/River/RefreshControl.tsx +19 -88
  117. package/Components/River/River.tsx +9 -82
  118. package/Components/River/TV/River.tsx +31 -14
  119. package/Components/River/TV/index.tsx +8 -4
  120. package/Components/River/TV/utils/__tests__/toStringOrEmpty.test.ts +30 -0
  121. package/Components/River/TV/utils/index.ts +4 -0
  122. package/Components/River/TV/withFocusableGroupForContent.tsx +71 -0
  123. package/Components/River/__tests__/__snapshots__/componentsMap.test.js.snap +2 -15
  124. package/Components/River/__tests__/componentsMap.test.js +38 -0
  125. package/Components/River/hooks/__tests__/usePullToRefresh.test.ts +132 -0
  126. package/Components/River/hooks/index.ts +1 -0
  127. package/Components/River/hooks/usePullToRefresh.ts +51 -0
  128. package/Components/Screen/TV/index.web.tsx +4 -2
  129. package/Components/Screen/__tests__/Screen.test.tsx +65 -42
  130. package/Components/Screen/__tests__/__snapshots__/Screen.test.tsx.snap +68 -44
  131. package/Components/Screen/hooks.ts +2 -3
  132. package/Components/Screen/index.tsx +10 -7
  133. package/Components/Screen/orientationHandler.ts +3 -3
  134. package/Components/ScreenResolver/index.tsx +9 -5
  135. package/Components/ScreenRevealManager/Overlay.tsx +34 -0
  136. package/Components/ScreenRevealManager/ScreenRevealManager.ts +40 -8
  137. package/Components/ScreenRevealManager/__tests__/Overlay.test.tsx +88 -0
  138. package/Components/ScreenRevealManager/__tests__/ScreenRevealManager.test.ts +86 -69
  139. package/Components/ScreenRevealManager/withScreenRevealManager.tsx +9 -20
  140. package/Components/Tabs/TabContent.tsx +7 -4
  141. package/Components/TopCutoffOverlay/__tests__/TopCutoffOverlay.test.tsx +201 -0
  142. package/Components/TopCutoffOverlay/hooks/__tests__/useMarginTop.test.ts +130 -0
  143. package/Components/TopCutoffOverlay/hooks/index.ts +1 -0
  144. package/Components/TopCutoffOverlay/hooks/useMarginTop.ts +59 -0
  145. package/Components/TopCutoffOverlay/index.tsx +55 -0
  146. package/Components/Transitioner/index.js +3 -3
  147. package/Components/VideoModal/ModalAnimation/ModalAnimationContext.tsx +5 -5
  148. package/Components/VideoModal/hooks/__tests__/useDelayedPlayerDetails.test.ts +15 -7
  149. package/Components/VideoModal/utils.ts +18 -10
  150. package/Components/Viewport/ViewportAware/__tests__/viewportAware.test.js +0 -2
  151. package/Components/Viewport/ViewportAware/index.tsx +16 -7
  152. package/Components/Viewport/ViewportEvents/__tests__/viewportEvents.test.js +1 -1
  153. package/Components/ZappFrameworkComponents/BarView/BarView.tsx +9 -4
  154. package/Components/default-cell-renderer/viewTrees/mobile/index.ts +0 -3
  155. package/Contexts/CachedDimensionsContext/__tests__/index.test.ts +154 -0
  156. package/Contexts/CachedDimensionsContext/index.ts +17 -2
  157. package/Contexts/ScreenContext/index.tsx +25 -18
  158. package/Contexts/ScreenTrackedViewPositionsContext/__tests__/index.test.tsx +1 -1
  159. package/Decorators/Analytics/index.tsx +6 -5
  160. package/Decorators/ConfigurationWrapper/__tests__/__snapshots__/withConfigurationProvider.test.tsx.snap +1 -0
  161. package/Decorators/ConfigurationWrapper/const.ts +1 -0
  162. package/Decorators/ZappPipesDataConnector/__tests__/UrlFeedResolver.test.tsx +39 -21
  163. package/Decorators/ZappPipesDataConnector/__tests__/zappPipesDataConnector.test.js +1 -1
  164. package/Decorators/ZappPipesDataConnector/index.tsx +2 -2
  165. package/Decorators/ZappPipesDataConnector/resolvers/StaticFeedResolver.tsx +1 -1
  166. package/Decorators/ZappPipesDataConnector/resolvers/UrlFeedResolver.tsx +18 -7
  167. package/Helpers/ComponentCellSelectionHelper/index.js +0 -6
  168. package/Helpers/DataSourceHelper/__tests__/itemLimitForData.test.ts +80 -0
  169. package/Helpers/DataSourceHelper/index.ts +19 -0
  170. package/Helpers/index.js +7 -40
  171. package/package.json +5 -5
  172. package/Components/Layout/TV/LayoutBackground.tsx +0 -28
  173. package/Components/MasterCell/DefaultComponents/tv/ButtonContainerView/index.android.tsx +0 -135
  174. package/Components/MasterCell/DefaultComponents/tv/ButtonContainerView/types.ts +0 -25
  175. package/Components/River/TV/withTVEventHandler.tsx +0 -36
  176. package/Components/Screen/utils.ts +0 -16
  177. package/Helpers/Analytics/index.js +0 -95
  178. package/Helpers/DataSourceHelper/index.js +0 -19
@@ -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
+ };
@@ -10,11 +10,7 @@ exports[`componentsMap renders renders components map correctly 1`] = `
10
10
  >
11
11
  <View
12
12
  onLayout={[Function]}
13
- style={
14
- {
15
- "flex": 1,
16
- }
17
- }
13
+ style={{}}
18
14
  >
19
15
  <RCTScrollView
20
16
  ListFooterComponent={
@@ -155,6 +151,7 @@ exports[`componentsMap renders renders components map correctly 1`] = `
155
151
  }
156
152
  }
157
153
  stickyHeaderIndices={[]}
154
+ testID="components-map-flat-list"
158
155
  viewabilityConfigCallbackPairs={[]}
159
156
  windowSize={12}
160
157
  >
@@ -166,11 +163,6 @@ exports[`componentsMap renders renders components map correctly 1`] = `
166
163
  >
167
164
  <View
168
165
  onLayout={[Function]}
169
- style={
170
- {
171
- "flex": 1,
172
- }
173
- }
174
166
  >
175
167
  <View />
176
168
  </View>
@@ -182,11 +174,6 @@ exports[`componentsMap renders renders components map correctly 1`] = `
182
174
  >
183
175
  <View
184
176
  onLayout={[Function]}
185
- style={
186
- {
187
- "flex": 1,
188
- }
189
- }
190
177
  />
191
178
  </View>
192
179
  <View
@@ -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
  });
@@ -0,0 +1,132 @@
1
+ import { act, renderHook } from "@testing-library/react-native";
2
+ import { refreshCoordinator } from "@applicaster/zapp-react-native-utils/refreshUtils/RefreshCoordinator";
3
+
4
+ import { usePullToRefresh } from "../usePullToRefresh";
5
+
6
+ jest.mock(
7
+ "@applicaster/zapp-react-native-utils/refreshUtils/RefreshCoordinator",
8
+ () => ({
9
+ refreshCoordinator: {
10
+ triggerRefresh: jest.fn(),
11
+ },
12
+ })
13
+ );
14
+
15
+ jest.useFakeTimers();
16
+
17
+ describe("usePullToRefresh", () => {
18
+ const screenId = "test-screen";
19
+ const components = [{ id: "comp1" }, { id: "comp2" }] as any;
20
+
21
+ beforeEach(() => {
22
+ jest.clearAllMocks();
23
+ });
24
+
25
+ it("should initialize with refreshing=false", () => {
26
+ const { result } = renderHook(() => usePullToRefresh(screenId, components));
27
+
28
+ expect(result.current.refreshing).toBe(false);
29
+ });
30
+
31
+ it("should trigger refresh and set refreshing=true", () => {
32
+ const { result } = renderHook(() => usePullToRefresh(screenId, components));
33
+
34
+ act(() => {
35
+ result.current.onRefresh();
36
+ });
37
+
38
+ expect(result.current.refreshing).toBe(true);
39
+
40
+ expect(refreshCoordinator.triggerRefresh).toHaveBeenCalledWith(
41
+ components,
42
+ screenId
43
+ );
44
+ });
45
+
46
+ it("should set refreshing=false after spinner duration", () => {
47
+ const { result } = renderHook(() => usePullToRefresh(screenId, components));
48
+
49
+ act(() => {
50
+ result.current.onRefresh();
51
+ });
52
+
53
+ expect(result.current.refreshing).toBe(true);
54
+
55
+ act(() => {
56
+ jest.advanceTimersByTime(1500);
57
+ });
58
+
59
+ expect(result.current.refreshing).toBe(false);
60
+ });
61
+
62
+ it("should not stop refreshing before spinner duration", () => {
63
+ const { result } = renderHook(() => usePullToRefresh(screenId, components));
64
+
65
+ act(() => {
66
+ result.current.onRefresh();
67
+ });
68
+
69
+ act(() => {
70
+ jest.advanceTimersByTime(1000);
71
+ });
72
+
73
+ expect(result.current.refreshing).toBe(true);
74
+ });
75
+
76
+ it("should clear timeout on unmount", () => {
77
+ const clearTimeoutSpy = jest.spyOn(global, "clearTimeout");
78
+
79
+ const { result, unmount } = renderHook(() =>
80
+ usePullToRefresh(screenId, components)
81
+ );
82
+
83
+ act(() => {
84
+ result.current.onRefresh();
85
+ });
86
+
87
+ unmount();
88
+
89
+ expect(clearTimeoutSpy).toHaveBeenCalled();
90
+ });
91
+
92
+ it("should handle multiple refresh calls correctly", () => {
93
+ const { result } = renderHook(() => usePullToRefresh(screenId, components));
94
+
95
+ act(() => {
96
+ result.current.onRefresh();
97
+ result.current.onRefresh();
98
+ });
99
+
100
+ expect(refreshCoordinator.triggerRefresh).toHaveBeenCalledTimes(2);
101
+ expect(result.current.refreshing).toBe(true);
102
+
103
+ act(() => {
104
+ jest.advanceTimersByTime(1500);
105
+ });
106
+
107
+ expect(result.current.refreshing).toBe(false);
108
+ });
109
+
110
+ it("should use latest props in callback", () => {
111
+ const { result, rerender } = renderHook(
112
+ ({ screenId, components }) => usePullToRefresh(screenId, components),
113
+ {
114
+ initialProps: { screenId, components },
115
+ }
116
+ );
117
+
118
+ const newComponents = [{ id: "new-comp" }] as any;
119
+ const newScreenId = "new-screen";
120
+
121
+ rerender({ screenId: newScreenId, components: newComponents });
122
+
123
+ act(() => {
124
+ result.current.onRefresh();
125
+ });
126
+
127
+ expect(refreshCoordinator.triggerRefresh).toHaveBeenCalledWith(
128
+ newComponents,
129
+ newScreenId
130
+ );
131
+ });
132
+ });
@@ -0,0 +1 @@
1
+ export { usePullToRefresh } from "./usePullToRefresh";
@@ -0,0 +1,51 @@
1
+ import { useState, useCallback, useRef, useEffect } from "react";
2
+ import { refreshCoordinator } from "@applicaster/zapp-react-native-utils/refreshUtils/RefreshCoordinator";
3
+
4
+ const SPINNER_DURATION_MS = 1500;
5
+
6
+ /**
7
+ * Pull-to-refresh hook.
8
+ *
9
+ * Triggers a refresh for all screen components via RefreshCoordinator.
10
+ * Each component's UrlFeedResolver already subscribes to refresh$ events
11
+ * and calls reloadData() when triggered — so this hook only needs to:
12
+ * 1. Push events into the refresh bus
13
+ * 2. Show a fixed-duration spinner as UX feedback
14
+ *
15
+ * Data updates arrive reactively via Redux (silentRefresh: true keeps
16
+ * old data visible while loading).
17
+ */
18
+ export const usePullToRefresh = (
19
+ screenId: string,
20
+ components: ZappUIComponent[] = []
21
+ ) => {
22
+ const [refreshing, setRefreshing] = useState(false);
23
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
24
+
25
+ // Cleanup timer on unmount
26
+ useEffect(() => {
27
+ return () => {
28
+ if (timerRef.current) {
29
+ clearTimeout(timerRef.current);
30
+ }
31
+ };
32
+ }, []);
33
+
34
+ const onRefresh = useCallback(() => {
35
+ setRefreshing(true);
36
+ refreshCoordinator.triggerRefresh(components, screenId);
37
+
38
+ // Spinner is UX feedback for the gesture.
39
+ // Data updates arrive reactively via Redux (silentRefresh: true).
40
+ if (timerRef.current) {
41
+ clearTimeout(timerRef.current);
42
+ }
43
+
44
+ timerRef.current = setTimeout(
45
+ () => setRefreshing(false),
46
+ SPINNER_DURATION_MS
47
+ );
48
+ }, [components, screenId]);
49
+
50
+ return { refreshing, onRefresh };
51
+ };
@@ -9,9 +9,10 @@ import {
9
9
  import {
10
10
  useIsScreenActive,
11
11
  useNavigation,
12
+ useRivers,
12
13
  } from "@applicaster/zapp-react-native-utils/reactHooks";
13
14
  import { noop } from "@applicaster/zapp-react-native-utils/functionUtils";
14
- import { usePickFromState } from "@applicaster/zapp-react-native-redux/hooks";
15
+ import { usePlugins } from "@applicaster/zapp-react-native-redux/hooks";
15
16
  import {
16
17
  useNavbarState,
17
18
  useScreenBackgroundColor,
@@ -102,7 +103,8 @@ export const Screen = ({ route, Components }: Props) => {
102
103
  }
103
104
 
104
105
  const navigator = useNavigation();
105
- const { plugins = [], rivers = [] } = usePickFromState(["plugins", "rivers"]);
106
+ const plugins = usePlugins();
107
+ const rivers = useRivers();
106
108
 
107
109
  const pathAttributes = getPathAttributes({ pathname: route });
108
110
  const routeState = navigator.getStackForPathname(route);