@applicaster/zapp-react-native-ui-components 15.0.0-alpha.8680244503 → 15.0.0-alpha.9102699023

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 (53) hide show
  1. package/Components/BaseFocusable/index.ios.ts +12 -2
  2. package/Components/Cell/Cell.tsx +6 -0
  3. package/Components/Cell/CellWithFocusable.tsx +9 -0
  4. package/Components/Cell/FocusableWrapper.tsx +3 -0
  5. package/Components/Cell/TvOSCellComponent.tsx +5 -0
  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/utils/__tests__/useCurationAPI.test.js +1 -1
  11. package/Components/GeneralContentScreen/utils/useCurationAPI.ts +22 -6
  12. package/Components/HandlePlayable/utils.ts +31 -0
  13. package/Components/MasterCell/DefaultComponents/SecondaryImage/Image.tsx +40 -39
  14. package/Components/MasterCell/DefaultComponents/SecondaryImage/__tests__/Image.test.tsx +95 -0
  15. package/Components/MasterCell/DefaultComponents/SecondaryImage/__tests__/__snapshots__/Image.test.tsx.snap +86 -0
  16. package/Components/MasterCell/DefaultComponents/SecondaryImage/__tests__/index.test.ts +141 -0
  17. package/Components/MasterCell/DefaultComponents/SecondaryImage/hooks/__tests__/useGetImageDimensions.test.ts +7 -6
  18. package/Components/MasterCell/DefaultComponents/SecondaryImage/index.ts +1 -1
  19. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/index.ts +6 -2
  20. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/utils/__tests__/getPluginIdentifier.test.ts +233 -11
  21. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/utils/index.ts +19 -15
  22. package/Components/MasterCell/hoc/__tests__/withAsyncRender.test.tsx +219 -0
  23. package/Components/MasterCell/hoc/withAsyncRender.tsx +9 -7
  24. package/Components/OfflineHandler/NotificationView/NotificationView.lg.tsx +17 -9
  25. package/Components/OfflineHandler/NotificationView/NotificationView.samsung.tsx +16 -8
  26. package/Components/OfflineHandler/NotificationView/utils.ts +34 -0
  27. package/Components/PlayerContainer/PlayerContainer.tsx +47 -38
  28. package/Components/PlayerContainer/useRestrictMobilePlayback.tsx +131 -0
  29. package/Components/River/ComponentsMap/ComponentsMap.tsx +16 -0
  30. package/Components/River/ComponentsMap/hooks/__tests__/useLoadingState.test.ts +1 -1
  31. package/Components/River/TV/River.tsx +31 -14
  32. package/Components/River/TV/index.tsx +8 -4
  33. package/Components/River/TV/utils/__tests__/toStringOrEmpty.test.ts +30 -0
  34. package/Components/River/TV/utils/index.ts +4 -0
  35. package/Components/River/TV/withFocusableGroupForContent.tsx +71 -0
  36. package/Components/River/__tests__/__snapshots__/componentsMap.test.js.snap +2 -0
  37. package/Components/River/__tests__/componentsMap.test.js +38 -0
  38. package/Components/Screen/orientationHandler.ts +7 -10
  39. package/Components/Tabs/TabContent.tsx +7 -4
  40. package/Components/VideoModal/hooks/__tests__/useDelayedPlayerDetails.test.ts +15 -7
  41. package/Components/Viewport/ViewportAware/index.tsx +16 -7
  42. package/Components/Viewport/ViewportEvents/__tests__/viewportEvents.test.js +1 -1
  43. package/Contexts/ScreenContext/index.tsx +25 -18
  44. package/Contexts/ScreenTrackedViewPositionsContext/__tests__/index.test.tsx +1 -1
  45. package/Contexts/ZappHookModalContext/index.tsx +37 -61
  46. package/Contexts/index.ts +0 -2
  47. package/Decorators/ConfigurationWrapper/__tests__/__snapshots__/withConfigurationProvider.test.tsx.snap +1 -0
  48. package/Decorators/ConfigurationWrapper/const.ts +1 -0
  49. package/Decorators/ZappPipesDataConnector/__tests__/zappPipesDataConnector.test.js +1 -1
  50. package/events/index.ts +3 -0
  51. package/events/scrollEndReached.ts +15 -0
  52. package/package.json +5 -5
  53. package/Components/River/TV/withTVEventHandler.tsx +0 -27
@@ -0,0 +1,219 @@
1
+ import React from "react";
2
+ import { render } from "@testing-library/react-native";
3
+ import { Text, View } from "react-native";
4
+ import { withAsyncRenderHOC } from "../withAsyncRender";
5
+
6
+ describe("withAsyncRenderHOC", () => {
7
+ // Test component that requires onAsyncRender
8
+ const TestComponent = ({
9
+ onAsyncRender,
10
+ text,
11
+ }: {
12
+ onAsyncRender: () => void;
13
+ text?: string;
14
+ }) => {
15
+ React.useEffect(() => {
16
+ onAsyncRender();
17
+ }, [onAsyncRender]);
18
+
19
+ return (
20
+ <View>
21
+ <Text>{text || "Test"}</Text>
22
+ </View>
23
+ );
24
+ };
25
+
26
+ it("should wrap component and provide onAsyncRender callback", () => {
27
+ const WrappedComponent = withAsyncRenderHOC(TestComponent);
28
+
29
+ const wrapper = render(<WrappedComponent text="Hello" />);
30
+
31
+ expect(wrapper.toJSON()).not.toBeNull();
32
+ expect(wrapper.getByText("Hello")).toBeDefined();
33
+ });
34
+
35
+ it("should call emitAsyncElementRegistrate on mount", () => {
36
+ const mockRegistrate = jest.fn();
37
+ const WrappedComponent = withAsyncRenderHOC(TestComponent);
38
+
39
+ render(<WrappedComponent emitAsyncElementRegistrate={mockRegistrate} />);
40
+
41
+ expect(mockRegistrate).toHaveBeenCalledTimes(1);
42
+ });
43
+
44
+ it("should call emitAsyncElementLayout when onAsyncRender is called", () => {
45
+ const mockLayout = jest.fn();
46
+
47
+ const ComponentWithAsyncCall = ({
48
+ onAsyncRender,
49
+ }: {
50
+ onAsyncRender: () => void;
51
+ }) => {
52
+ return (
53
+ <View onLayout={onAsyncRender}>
54
+ <Text>Async Component</Text>
55
+ </View>
56
+ );
57
+ };
58
+
59
+ const WrappedComponent = withAsyncRenderHOC(ComponentWithAsyncCall);
60
+
61
+ const wrapper = render(
62
+ <WrappedComponent emitAsyncElementLayout={mockLayout} />
63
+ );
64
+
65
+ // Trigger onLayout
66
+ const view = wrapper.UNSAFE_getByType(View);
67
+ view.props.onLayout();
68
+
69
+ expect(mockLayout).toHaveBeenCalled();
70
+ });
71
+
72
+ it("should return unsubscribe function from emitAsyncElementRegistrate", () => {
73
+ const mockUnsubscribe = jest.fn();
74
+ const mockRegistrate = jest.fn(() => mockUnsubscribe);
75
+ const WrappedComponent = withAsyncRenderHOC(TestComponent);
76
+
77
+ const { unmount } = render(
78
+ <WrappedComponent emitAsyncElementRegistrate={mockRegistrate} />
79
+ );
80
+
81
+ expect(mockRegistrate).toHaveBeenCalledTimes(1);
82
+
83
+ // Unmount should trigger cleanup
84
+ unmount();
85
+
86
+ expect(mockUnsubscribe).toHaveBeenCalled();
87
+ });
88
+
89
+ it("should work without emitAsyncElementRegistrate prop", () => {
90
+ const WrappedComponent = withAsyncRenderHOC(TestComponent);
91
+
92
+ const wrapper = render(<WrappedComponent text="No Registration" />);
93
+
94
+ expect(wrapper.toJSON()).not.toBeNull();
95
+ expect(wrapper.getByText("No Registration")).toBeDefined();
96
+ });
97
+
98
+ it("should work without emitAsyncElementLayout prop", () => {
99
+ const ComponentWithLayout = ({
100
+ onAsyncRender,
101
+ }: {
102
+ onAsyncRender: () => void;
103
+ }) => {
104
+ return (
105
+ <View onLayout={onAsyncRender}>
106
+ <Text>Test Layout</Text>
107
+ </View>
108
+ );
109
+ };
110
+
111
+ const WrappedComponent = withAsyncRenderHOC(ComponentWithLayout);
112
+
113
+ const wrapper = render(<WrappedComponent />);
114
+
115
+ // Should not throw when calling onLayout without emitAsyncElementLayout
116
+ const view = wrapper.UNSAFE_getByType(View);
117
+ expect(() => view.props.onLayout()).not.toThrow();
118
+ });
119
+
120
+ it("should pass through all other props to wrapped component", () => {
121
+ const ComponentWithProps = ({
122
+ customProp,
123
+ anotherProp,
124
+ }: {
125
+ onAsyncRender: () => void;
126
+ customProp: string;
127
+ anotherProp: number;
128
+ }) => {
129
+ return (
130
+ <View>
131
+ <Text>{customProp}</Text>
132
+ <Text>{anotherProp}</Text>
133
+ </View>
134
+ );
135
+ };
136
+
137
+ const WrappedComponent = withAsyncRenderHOC(ComponentWithProps);
138
+
139
+ const wrapper = render(
140
+ <WrappedComponent customProp="custom value" anotherProp={42} />
141
+ );
142
+
143
+ expect(wrapper.getByText("custom value")).toBeDefined();
144
+ expect(wrapper.getByText("42")).toBeDefined();
145
+ });
146
+
147
+ it("should call emitAsyncElementLayout multiple times if onAsyncRender is called multiple times", () => {
148
+ const mockLayout = jest.fn();
149
+
150
+ const ComponentWithMultipleCalls = ({
151
+ onAsyncRender,
152
+ }: {
153
+ onAsyncRender: () => void;
154
+ }) => {
155
+ return (
156
+ <View>
157
+ <View onLayout={onAsyncRender} testID="view1">
158
+ <Text>First</Text>
159
+ </View>
160
+ <View onLayout={onAsyncRender} testID="view2">
161
+ <Text>Second</Text>
162
+ </View>
163
+ </View>
164
+ );
165
+ };
166
+
167
+ const WrappedComponent = withAsyncRenderHOC(ComponentWithMultipleCalls);
168
+
169
+ const wrapper = render(
170
+ <WrappedComponent emitAsyncElementLayout={mockLayout} />
171
+ );
172
+
173
+ // Trigger both onLayout callbacks
174
+ const view1 = wrapper.getByTestId("view1");
175
+ const view2 = wrapper.getByTestId("view2");
176
+
177
+ view1.props.onLayout();
178
+ view2.props.onLayout();
179
+
180
+ expect(mockLayout).toHaveBeenCalledTimes(2);
181
+ });
182
+
183
+ it("should handle components that use onAsyncRender in effects", () => {
184
+ const mockLayout = jest.fn();
185
+
186
+ const ComponentWithEffect = ({
187
+ onAsyncRender,
188
+ }: {
189
+ onAsyncRender: () => void;
190
+ }) => {
191
+ React.useEffect(() => {
192
+ // Simulate async operation completing
193
+ const timer = setTimeout(() => {
194
+ onAsyncRender();
195
+ }, 0);
196
+
197
+ return () => clearTimeout(timer);
198
+ }, [onAsyncRender]);
199
+
200
+ return (
201
+ <View>
202
+ <Text>Effect Component</Text>
203
+ </View>
204
+ );
205
+ };
206
+
207
+ const WrappedComponent = withAsyncRenderHOC(ComponentWithEffect);
208
+
209
+ render(<WrappedComponent emitAsyncElementLayout={mockLayout} />);
210
+
211
+ // The effect should have been triggered
212
+ return new Promise((resolve) => {
213
+ setTimeout(() => {
214
+ expect(mockLayout).toHaveBeenCalled();
215
+ resolve(true);
216
+ }, 10);
217
+ });
218
+ });
219
+ });
@@ -6,12 +6,12 @@ type withAsyncRenderHOCProps = {
6
6
  emitAsyncElementLayout?: () => void;
7
7
  };
8
8
 
9
- type withAsyncRenderHOCT = (
10
- Component: React.ComponentType<{ onAsyncRender: () => void }>
11
- ) => React.FC<withAsyncRenderHOCProps>;
12
-
13
- export const withAsyncRenderHOC: withAsyncRenderHOCT = (Component) => {
14
- const WithAsyncRender = (props: withAsyncRenderHOCProps) => {
9
+ export const withAsyncRenderHOC = <P extends { onAsyncRender: () => void }>(
10
+ Component: React.ComponentType<P>
11
+ ): React.FC<Omit<P, "onAsyncRender"> & withAsyncRenderHOCProps> => {
12
+ const WithAsyncRender = (
13
+ props: Omit<P, "onAsyncRender"> & withAsyncRenderHOCProps
14
+ ) => {
15
15
  const { emitAsyncElementRegistrate = noop, emitAsyncElementLayout = noop } =
16
16
  props;
17
17
 
@@ -27,7 +27,9 @@ export const withAsyncRenderHOC: withAsyncRenderHOCT = (Component) => {
27
27
  }
28
28
  }, [emitAsyncElementLayout]);
29
29
 
30
- return <Component {...props} onAsyncRender={onAsyncRender} />;
30
+ return (
31
+ <Component {...(props as unknown as P)} onAsyncRender={onAsyncRender} />
32
+ );
31
33
  };
32
34
 
33
35
  return WithAsyncRender;
@@ -1,4 +1,6 @@
1
1
  import * as React from "react";
2
+ import { useTheme } from "@applicaster/zapp-react-native-utils/theme";
3
+ import { useNetworkStatusLocalizations } from "./utils";
2
4
 
3
5
  type Props = {
4
6
  children?: React.ReactNode;
@@ -10,13 +12,6 @@ type Props = {
10
12
 
11
13
  const NOTIF_DURATION = 4500;
12
14
 
13
- export const ONLINE_MSG = "You are back online";
14
-
15
- export const OFFLINE_MSG = "No internet connection";
16
-
17
- const EXTRA_OFFLINE_MSG = "Please check your connection";
18
- const EXTRA_ONLINE_MSG = "Feel free to continue where you left off";
19
-
20
15
  const styles: Record<any, React.CSSProperties> = {
21
16
  body: {
22
17
  position: "absolute",
@@ -47,6 +42,9 @@ const styles: Record<any, React.CSSProperties> = {
47
42
  export const NotificationView = (props: Props) => {
48
43
  const { children, hidden, dismiss, previousOnline, online } = props;
49
44
 
45
+ const theme = useTheme<BaseThemePropertiesTV>();
46
+ const storedLocalizations = useNetworkStatusLocalizations();
47
+
50
48
  const [open, setOpen] = React.useState<boolean | null>(null);
51
49
 
52
50
  const timeout = React.useRef<NodeJS.Timeout>();
@@ -84,8 +82,18 @@ export const NotificationView = (props: Props) => {
84
82
  }, [hidden, online]);
85
83
 
86
84
  const showOnlineMsg = wentOnline || online;
87
- const MSG = showOnlineMsg ? ONLINE_MSG : OFFLINE_MSG;
88
- const EXTRA_MSG = showOnlineMsg ? EXTRA_ONLINE_MSG : EXTRA_OFFLINE_MSG;
85
+
86
+ const MSG = showOnlineMsg
87
+ ? (theme?.online_notification_title ??
88
+ storedLocalizations?.online_notification_title)
89
+ : (theme?.offline_notification_title ??
90
+ storedLocalizations?.offline_notification_title);
91
+
92
+ const EXTRA_MSG = showOnlineMsg
93
+ ? (theme?.online_notification_subtitle ??
94
+ storedLocalizations?.online_notification_subtitle)
95
+ : (theme?.offline_notification_subtitle ??
96
+ storedLocalizations?.offline_notification_subtitle);
89
97
 
90
98
  return (
91
99
  <div onClick={onClose}>
@@ -1,4 +1,6 @@
1
1
  import * as React from "react";
2
+ import { useTheme } from "@applicaster/zapp-react-native-utils/theme";
3
+ import { useNetworkStatusLocalizations } from "./utils";
2
4
 
3
5
  type Props = {
4
6
  children?: React.ReactNode;
@@ -9,12 +11,6 @@ type Props = {
9
11
 
10
12
  const DURATION_TO_HIDE_AFTER_BACK_TO_ONLINE = 4500; // ms
11
13
 
12
- const ONLINE_TITLE = "You are back online";
13
- const ONLINE_SUBTITLE = "Feel free to continue where you left off";
14
-
15
- const OFFLINE_TITLE = "No internet connection";
16
- const OFFLINE_SUBTITLE = "Please check your connection";
17
-
18
14
  const styles: Record<any, React.CSSProperties> = {
19
15
  body: {
20
16
  position: "absolute",
@@ -47,6 +43,9 @@ let timer: NodeJS.Timeout;
47
43
  export const NotificationView = (props: Props) => {
48
44
  const { children, dismiss, previousOnline = true, online } = props;
49
45
 
46
+ const theme = useTheme<BaseThemePropertiesTV>();
47
+ const storedLocalizations = useNetworkStatusLocalizations();
48
+
50
49
  const [shown, setShown] = React.useState<boolean>(false);
51
50
 
52
51
  const onClose = () => {
@@ -79,8 +78,17 @@ export const NotificationView = (props: Props) => {
79
78
  }
80
79
  }, [online, previousOnline]);
81
80
 
82
- const title = online ? ONLINE_TITLE : OFFLINE_TITLE;
83
- const subtitle = online ? ONLINE_SUBTITLE : OFFLINE_SUBTITLE;
81
+ const title = online
82
+ ? (theme?.online_notification_title ??
83
+ storedLocalizations?.online_notification_title)
84
+ : (theme?.offline_notification_title ??
85
+ storedLocalizations?.offline_notification_title);
86
+
87
+ const subtitle = online
88
+ ? (theme?.online_notification_subtitle ??
89
+ storedLocalizations?.online_notification_subtitle)
90
+ : (theme?.offline_notification_subtitle ??
91
+ storedLocalizations?.offline_notification_subtitle);
84
92
 
85
93
  return (
86
94
  <div onClick={onClose}>
@@ -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
+ }
@@ -61,6 +61,7 @@ import {
61
61
  PlayerNativeSendCommand,
62
62
  } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/playerNativeCommand";
63
63
  import { useAppData } from "@applicaster/zapp-react-native-redux";
64
+ import { useRestrictMobilePlayback } from "./useRestrictMobilePlayback";
64
65
 
65
66
  type Props = {
66
67
  Player: React.ComponentType<any>;
@@ -264,7 +265,20 @@ const PlayerContainerComponent = (props: Props) => {
264
265
 
265
266
  showNavBar(true);
266
267
  navigator.goBack();
267
- }, [isModal, navigator.goBack, state.playerId, showNavBar]);
268
+ }, [isModal, state.playerId, showNavBar, navigator]);
269
+
270
+ const pluginConfiguration = React.useMemo(() => {
271
+ return (
272
+ playerManager.getPluginConfiguration() ||
273
+ R.prop("__plugin_configuration", Player)
274
+ );
275
+ }, [playerManager.isRegistered()]);
276
+
277
+ const { isRestricted } = useRestrictMobilePlayback({
278
+ player,
279
+ entry: item,
280
+ close,
281
+ });
268
282
 
269
283
  const playEntry = (entry) => navigator.replaceTop(entry, { mode });
270
284
 
@@ -456,13 +470,6 @@ const PlayerContainerComponent = (props: Props) => {
456
470
  }
457
471
  }, []);
458
472
 
459
- const pluginConfiguration = React.useMemo(() => {
460
- return (
461
- playerManager.getPluginConfiguration() ||
462
- R.prop("__plugin_configuration", Player)
463
- );
464
- }, [playerManager.isRegistered()]);
465
-
466
473
  const disableMiniPlayer = React.useMemo(() => {
467
474
  return pluginConfiguration?.disable_mini_player_when_inline;
468
475
  }, [pluginConfiguration]);
@@ -662,36 +669,38 @@ const PlayerContainerComponent = (props: Props) => {
662
669
  <PlayerFocusableWrapperView
663
670
  nextFocusDown={context.bottomFocusableId}
664
671
  >
665
- <Player
666
- source={{
667
- uri,
668
- entry: item,
669
- }}
670
- focused={isInlineTV ? true : undefined}
671
- autoplay={true}
672
- controls={false}
673
- disableCastAction={disableCastAction}
674
- docked={navigator.isVideoModalDocked()}
675
- entry={item}
676
- fullscreen={mode === VideoModalMode.FULLSCREEN}
677
- inline={inline}
678
- isModal={isModal}
679
- isTabletPortrait={isTabletPortrait}
680
- muted={false}
681
- playableItem={item}
682
- playerEvent={playerEvent}
683
- playerId={state.playerId}
684
- pluginConfiguration={pluginConfiguration}
685
- ref={playerRef}
686
- toggleFullscreen={toggleFullscreen}
687
- style={videoStyle}
688
- playNextData={playNextData}
689
- setNextVideoPreloadThresholdPercentage={
690
- setNextVideoPreloadThresholdPercentage
691
- }
692
- >
693
- {renderApplePlayer(applePlayerProps)}
694
- </Player>
672
+ {!Player || isRestricted ? null : (
673
+ <Player
674
+ source={{
675
+ uri,
676
+ entry: item,
677
+ }}
678
+ focused={isInlineTV ? true : undefined}
679
+ autoplay={true}
680
+ controls={false}
681
+ disableCastAction={disableCastAction}
682
+ docked={navigator.isVideoModalDocked()}
683
+ entry={item}
684
+ fullscreen={mode === VideoModalMode.FULLSCREEN}
685
+ inline={inline}
686
+ isModal={isModal}
687
+ isTabletPortrait={isTabletPortrait}
688
+ muted={false}
689
+ playableItem={item}
690
+ playerEvent={playerEvent}
691
+ playerId={state.playerId}
692
+ pluginConfiguration={pluginConfiguration}
693
+ ref={playerRef}
694
+ toggleFullscreen={toggleFullscreen}
695
+ style={videoStyle}
696
+ playNextData={playNextData}
697
+ setNextVideoPreloadThresholdPercentage={
698
+ setNextVideoPreloadThresholdPercentage
699
+ }
700
+ >
701
+ {renderApplePlayer(applePlayerProps)}
702
+ </Player>
703
+ )}
695
704
  </PlayerFocusableWrapperView>
696
705
 
697
706
  {state.error ? <ErrorDisplay error={state.error} /> : null}
@@ -0,0 +1,131 @@
1
+ import { Player } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/player";
2
+ import NetInfo from "@react-native-community/netinfo";
3
+
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
+ import { showAlertDialog } from "@applicaster/zapp-react-native-utils/alertUtils";
6
+ import { useTheme } from "@applicaster/zapp-react-native-utils/theme";
7
+ import { isTV } from "@applicaster/zapp-react-native-utils/reactUtils";
8
+ import { playerManager } from "@applicaster/zapp-react-native-utils/appUtils/playerManager";
9
+ import { usePlugins } from "@applicaster/zapp-react-native-redux/hooks";
10
+ import { getLocalizations } from "@applicaster/zapp-react-native-utils/localizationUtils";
11
+ import { log_info } from "./logger";
12
+ import { isTrue } from "@applicaster/zapp-react-native-utils/booleanUtils";
13
+
14
+ type RestrictMobilePlaybackProps = {
15
+ player?: Player;
16
+ entry?: ZappEntry;
17
+ close: () => void;
18
+ };
19
+
20
+ export const useRestrictMobilePlayback = ({
21
+ player,
22
+ entry,
23
+ close,
24
+ }: RestrictMobilePlaybackProps): { isRestricted: boolean } => {
25
+ const dialogVisibleRef = useRef<boolean>(false);
26
+ const theme = useTheme();
27
+ const plugins = usePlugins();
28
+
29
+ const restrictMobilePlugin = useMemo(
30
+ () =>
31
+ plugins.find((p) => p.identifier === "quick-brick-hook-restrict-mobile"),
32
+ [plugins]
33
+ );
34
+
35
+ const localizations = useMemo(
36
+ () => restrictMobilePlugin?.configuration?.localizations,
37
+ [restrictMobilePlugin]
38
+ );
39
+
40
+ const localize = useCallback(
41
+ (key: string) => {
42
+ const l = localizations && getLocalizations({ localizations });
43
+
44
+ return (l && l[key]) || "";
45
+ },
46
+ [localizations]
47
+ );
48
+
49
+ useEffect(() => {
50
+ return () => {
51
+ if (isTV()) {
52
+ return;
53
+ }
54
+
55
+ dialogVisibleRef.current = false;
56
+ };
57
+ }, []);
58
+
59
+ const isConnectionRestricted = useMemo(() => {
60
+ if (isTV()) {
61
+ return false;
62
+ }
63
+
64
+ // Only restrict if the plugin exists
65
+ if (!restrictMobilePlugin) {
66
+ return false;
67
+ }
68
+
69
+ return player && isTrue(entry?.extensions?.connection_restricted);
70
+ }, [player, entry, restrictMobilePlugin]);
71
+
72
+ const [isRestricted, setIsRestricted] = useState<boolean>(
73
+ isConnectionRestricted
74
+ );
75
+
76
+ useEffect(() => {
77
+ if (!isConnectionRestricted) {
78
+ return;
79
+ }
80
+
81
+ const stopPlayer = () => {
82
+ log_info(
83
+ "Stopping player due to mobile restriction, connection_restricted: true"
84
+ );
85
+
86
+ player?.closeNativePlayer();
87
+ playerManager?.invokeHandler("close");
88
+
89
+ dialogVisibleRef.current = true;
90
+
91
+ showAlertDialog({
92
+ title:
93
+ localize("restrict_mobile_playback_error_title") ||
94
+ "Restricted Connection Type",
95
+ message:
96
+ localize("restrict_mobile_playback_error_message") ||
97
+ "This content can only be viewed over a Wi-Fi or LAN network.",
98
+ okButtonText: theme.ok_button || "OK",
99
+ completion: () => {
100
+ dialogVisibleRef.current = false;
101
+
102
+ close();
103
+ },
104
+ });
105
+ };
106
+
107
+ return NetInfo.addEventListener((state) => {
108
+ if (state.type === "cellular") {
109
+ setIsRestricted(true);
110
+
111
+ if (dialogVisibleRef.current) {
112
+ return;
113
+ }
114
+
115
+ stopPlayer();
116
+ } else {
117
+ setIsRestricted(false);
118
+ }
119
+ });
120
+ }, [
121
+ close,
122
+ entry?.extensions?.connection_restricted,
123
+ localizations,
124
+ localize,
125
+ player,
126
+ theme.ok_button,
127
+ isConnectionRestricted,
128
+ ]);
129
+
130
+ return { isRestricted };
131
+ };
@@ -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