@applicaster/zapp-react-dom-app 12.0.0-rc.8 → 12.0.0-rc.80

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.
@@ -31,14 +31,23 @@ import {
31
31
  import { PlayerContentContext } from "@applicaster/zapp-react-dom-ui-components/Contexts";
32
32
 
33
33
  import { withNavigator } from "@applicaster/zapp-react-native-ui-components/Decorators/Navigator";
34
- import { Platform } from "react-native";
35
34
  import { LONG_KEY_PRESS_TIMEOUT } from "@applicaster/quick-brick-core/const";
36
35
  import { getXray } from "@applicaster/zapp-react-native-utils/logger";
37
36
  import { confirmationDialogStore } from "@applicaster/zapp-react-native-ui-components/Contexts/ConfirmDialogState";
37
+ import {
38
+ isVizioPlatform,
39
+ isSamsungPlatform,
40
+ isLgPlatform,
41
+ } from "@applicaster/zapp-react-native-utils/reactUtils";
42
+
43
+ import { OnScreenKeyboard } from "@applicaster/zapp-react-dom-ui-components/Components/OnScreenKeyboard";
38
44
 
39
45
  const { withXray } = getXray();
40
46
  const globalAny: any = global;
41
47
 
48
+ const shouldUseOnScreenKeyboard = () =>
49
+ isVizioPlatform() || window.applicaster.useOnScreenKeyboard;
50
+
42
51
  type Props = {
43
52
  displayState: string;
44
53
  setDisplayState: (state: string) => void;
@@ -56,7 +65,7 @@ type Props = {
56
65
  };
57
66
  };
58
67
 
59
- type State = { isKeyboardVisible: boolean };
68
+ type State = { isKeyboardVisible: boolean; keyboardInput: string };
60
69
 
61
70
  type KeyCode = { code: string; keyCode: number | string };
62
71
 
@@ -76,6 +85,7 @@ export function withInteractionManager(Component: React.FC) {
76
85
  sequenceTimeout: NodeJS.Timeout | null; // timeoutID
77
86
  userSequence: number[] = [];
78
87
  confirmDialog: any;
88
+ isDismissing = false;
79
89
 
80
90
  constructor(props) {
81
91
  super(props);
@@ -84,6 +94,7 @@ export function withInteractionManager(Component: React.FC) {
84
94
 
85
95
  this.state = {
86
96
  isKeyboardVisible: false,
97
+ keyboardInput: "",
87
98
  };
88
99
 
89
100
  this.onMouseMoveListener = debounce({
@@ -116,6 +127,10 @@ export function withInteractionManager(Component: React.FC) {
116
127
  this.onConfirmDialogOpen = this.onConfirmDialogOpen.bind(this);
117
128
  this.onConfirmDialogClose = this.onConfirmDialogClose.bind(this);
118
129
  this.performExit = this.performExit.bind(this);
130
+ this.handleInputFocus = this.handleInputFocus.bind(this);
131
+ this.handleInputBlur = this.handleInputBlur.bind(this);
132
+ this.handleKeyboardInput = this.handleKeyboardInput.bind(this);
133
+ this.handleKeyboardDismiss = this.handleKeyboardDismiss.bind(this);
119
134
 
120
135
  this.confirmDialog.setConfirmAction(this.performExit);
121
136
  this.confirmDialog.setCancelAction(this.onConfirmDialogClose);
@@ -138,6 +153,8 @@ export function withInteractionManager(Component: React.FC) {
138
153
  false
139
154
  );
140
155
 
156
+ document.addEventListener("focusin", this.handleInputFocus);
157
+
141
158
  document.addEventListener(
142
159
  "cursorStateChange",
143
160
  this.cursorVisibilityChange,
@@ -155,28 +172,31 @@ export function withInteractionManager(Component: React.FC) {
155
172
  this.cursorVisibilityChange
156
173
  );
157
174
 
158
- document.removeEventListener(
159
- "keyboardStateChange",
160
- this.onKeyboardStateChange
161
- );
175
+ document.removeEventListener("focusin", this.handleInputFocus);
162
176
 
163
177
  document.removeEventListener("mousedown", this.onMouseDownListener);
164
178
 
165
179
  if (this.timeout) {
166
180
  this.clearTimeout();
167
181
  }
182
+
183
+ this.isDismissing = false;
168
184
  }
169
185
 
170
186
  performExit() {
171
- if (Platform.OS === ("samsung_tv" as QuickBrickPlatforms)) {
187
+ if (isSamsungPlatform()) {
172
188
  this.onConfirmDialogClose();
173
189
  sendQuickBrickEvent(QUICK_BRICK_EVENTS.MOVE_APP_TO_BACKGROUND);
174
190
  }
175
191
 
176
- if (Platform.OS === ("lg_tv" as QuickBrickPlatforms)) {
192
+ if (isLgPlatform()) {
177
193
  // webOS recommend to use this method when implemention a custom exit
178
194
  globalAny.close();
179
195
  }
196
+
197
+ if (isVizioPlatform()) {
198
+ window?.VIZIO?.exitApplication();
199
+ }
180
200
  }
181
201
 
182
202
  handleWebOsBack() {
@@ -205,7 +225,7 @@ export function withInteractionManager(Component: React.FC) {
205
225
  platformBack() {
206
226
  try {
207
227
  // TODO: temporary hack until this code is refactored
208
- if (Platform.OS === ("samsung_tv" as QuickBrickPlatforms)) {
228
+ if (isSamsungPlatform() || isVizioPlatform()) {
209
229
  const { isDialogVisible } = confirmationDialogStore.getState();
210
230
 
211
231
  if (isDialogVisible) {
@@ -218,7 +238,7 @@ export function withInteractionManager(Component: React.FC) {
218
238
  cancelCompletion: this.onConfirmDialogClose,
219
239
  });
220
240
  }
221
- } else if (Platform.OS === ("lg_tv" as QuickBrickPlatforms)) {
241
+ } else if (isLgPlatform()) {
222
242
  this.handleWebOsBack();
223
243
  }
224
244
  } catch (e) {
@@ -441,6 +461,7 @@ export function withInteractionManager(Component: React.FC) {
441
461
 
442
462
  onKeyDown(event) {
443
463
  const { displayState, setDisplayState, navigator } = this.props;
464
+ const { isKeyboardVisible } = this.state;
444
465
 
445
466
  const { isDialogVisible } = confirmationDialogStore.getState();
446
467
 
@@ -451,6 +472,16 @@ export function withInteractionManager(Component: React.FC) {
451
472
  }, LONG_KEY_PRESS_TIMEOUT);
452
473
  }
453
474
 
475
+ if (isKeyboardVisible && shouldUseOnScreenKeyboard()) {
476
+ if (keyCode(event).matchesAny("Escape", "Back")) {
477
+ event.preventDefault();
478
+ event.stopPropagation();
479
+ this.handleKeyboardDismiss();
480
+
481
+ return;
482
+ }
483
+ }
484
+
454
485
  /**
455
486
  When the player is displayed, we listen to Native Play / Pause / Stop
456
487
  buttons. Any other key press will display the transport controls
@@ -567,7 +598,7 @@ export function withInteractionManager(Component: React.FC) {
567
598
  }
568
599
 
569
600
  if (keyCode(event).matches(KEYS.Exit)) {
570
- if (Platform.OS === ("samsung_tv" as QuickBrickPlatforms)) {
601
+ if (isSamsungPlatform() || shouldUseOnScreenKeyboard()) {
571
602
  this.performExit();
572
603
 
573
604
  return;
@@ -592,6 +623,12 @@ export function withInteractionManager(Component: React.FC) {
592
623
  focusManager.moveFocus(direction);
593
624
  }
594
625
 
626
+ if (!isDialogVisible) {
627
+ // run focus recovery on user interaction attempt
628
+ // probably we failed to set initial focus for some reason
629
+ focusManager.recoverFocus();
630
+ }
631
+
595
632
  if (
596
633
  displayState === DISPLAY_STATES.PLAYER ||
597
634
  displayState === DISPLAY_STATES.HUD
@@ -616,11 +653,9 @@ export function withInteractionManager(Component: React.FC) {
616
653
  };
617
654
 
618
655
  if (deltaY > 0) {
619
- DIRECTION.value =
620
- Platform.OS === ("lg_tv" as QuickBrickPlatforms) ? "down" : "up";
656
+ DIRECTION.value = isLgPlatform() ? "down" : "up";
621
657
  } else if (deltaY < 0) {
622
- DIRECTION.value =
623
- Platform.OS === ("lg_tv" as QuickBrickPlatforms) ? "up" : "down";
658
+ DIRECTION.value = isLgPlatform() ? "up" : "down";
624
659
  }
625
660
 
626
661
  if (
@@ -656,10 +691,76 @@ export function withInteractionManager(Component: React.FC) {
656
691
 
657
692
  onConfirmDialogClose() {
658
693
  this.confirmDialog.hideDialog();
694
+
695
+ // restore initial focus after closing dialog
696
+ focusManager.setInitialFocus();
697
+ }
698
+
699
+ handleInputFocus = (event: FocusEvent) => {
700
+ if (!shouldUseOnScreenKeyboard()) return;
701
+ const target = event.target as HTMLElement;
702
+
703
+ if (this.isDismissing) {
704
+ return;
705
+ }
706
+
707
+ if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") {
708
+ this.setState({
709
+ isKeyboardVisible: true,
710
+ keyboardInput: (target as HTMLInputElement).value || "",
711
+ });
712
+ }
713
+ };
714
+
715
+ handleInputBlur(event: FocusEvent) {
716
+ if (!shouldUseOnScreenKeyboard()) return;
717
+
718
+ const target = event.target as HTMLElement;
719
+
720
+ if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") {
721
+ const relatedTarget = event.relatedTarget as HTMLElement;
722
+
723
+ if (
724
+ !relatedTarget ||
725
+ (relatedTarget.tagName !== "INPUT" &&
726
+ relatedTarget.tagName !== "TEXTAREA")
727
+ ) {
728
+ this.setState({ isKeyboardVisible: false });
729
+ }
730
+ }
731
+ }
732
+
733
+ handleKeyboardDismiss = () => {
734
+ this.isDismissing = true;
735
+
736
+ this.setState(
737
+ {
738
+ isKeyboardVisible: false,
739
+ keyboardInput: "",
740
+ },
741
+ () => {
742
+ this.isDismissing = false;
743
+ }
744
+ );
745
+ };
746
+
747
+ handleKeyboardInput(value: string) {
748
+ this.setState({ keyboardInput: value });
659
749
  }
660
750
 
661
751
  render() {
662
- return <Component {...this.props} />;
752
+ return (
753
+ <>
754
+ <Component {...this.props} />
755
+ {shouldUseOnScreenKeyboard() && this.state.isKeyboardVisible && (
756
+ <OnScreenKeyboard
757
+ initialValue={this.state.keyboardInput}
758
+ onInput={this.handleKeyboardInput}
759
+ onDismiss={this.handleKeyboardDismiss}
760
+ />
761
+ )}
762
+ </>
763
+ );
663
764
  }
664
765
  }
665
766
 
@@ -8,11 +8,7 @@ export const appLoaderImpl =
8
8
  ) =>
9
9
  async (dispatch, getState) => {
10
10
  try {
11
- const {
12
- plugins,
13
- pluginConfigurations,
14
- remoteConfigurations: { localizations },
15
- } = getState();
11
+ const { plugins, pluginConfigurations } = getState();
16
12
 
17
13
  // Await crucial tasks
18
14
  await loadLocale();
@@ -20,7 +16,7 @@ export const appLoaderImpl =
20
16
  await loadAnalyticsPlugins(plugins, pluginConfigurations);
21
17
 
22
18
  // Remaining loaders
23
- const loaders = [loadDomPlugins(plugins), setUpRTL(localizations)];
19
+ const loaders = [loadDomPlugins(plugins), setUpRTL()];
24
20
 
25
21
  // Await for the remaining tasks and return when they are all complete
26
22
  await Promise.all(loaders);
@@ -1,7 +1,7 @@
1
- import { getIsRTLFromLocalizations } from "@applicaster/zapp-react-native-utils/localizationUtils";
1
+ import { getIsRTL } from "@applicaster/zapp-react-native-utils/localizationUtils";
2
2
  import { I18nManager } from "react-native";
3
3
 
4
- export async function setUpRTL(localizations) {
5
- const isRTL = getIsRTLFromLocalizations(localizations);
4
+ export async function setUpRTL() {
5
+ const isRTL = getIsRTL();
6
6
  I18nManager.forceRTL(isRTL);
7
7
  }
@@ -26,7 +26,6 @@ describe("getLanguageCode", () => {
26
26
 
27
27
  // verify
28
28
  expect(result).toEqual(locale);
29
- expect(setLanguageCodeToLocalStorage).toHaveBeenCalledWith(locale);
30
29
  });
31
30
 
32
31
  it("return value from local-storage", async () => {
@@ -62,6 +62,9 @@ export const PLATFORMS = {
62
62
  iphone: "iPhone",
63
63
  android: "Android",
64
64
  mobile: "Mobile",
65
+ vizio: "VIZIO",
66
+ smartcast: "SmartCast",
67
+ conjure: "Conjure",
65
68
  };
66
69
 
67
70
  /**
@@ -8,7 +8,7 @@ export const getLanguageCodeFromLocalStorage = () => {
8
8
  return NativeModules.LocalStorage.getItem(UI_LANGUAGE);
9
9
  };
10
10
 
11
- export const setLanguageCodeToLocalStorage = async (languageCode) => {
11
+ export const persistLanguageSelection = async (languageCode) => {
12
12
  await NativeModules.SessionStorage.setItem(UI_LANGUAGE, languageCode);
13
13
 
14
14
  return NativeModules.LocalStorage.setItem(UI_LANGUAGE, languageCode);
@@ -61,32 +61,34 @@ export const getLanguageCodeImpl =
61
61
  (
62
62
  GetLanguageCodeFromLocalStorage,
63
63
  GetAvailableAppLanguages,
64
- PickBestMatchedLanguage,
65
- SetLanguageCodeToLocalStorage
64
+ PickBestMatchedLanguage
66
65
  ) =>
67
66
  async (locale) => {
68
67
  const languageFromLocalStorage = await GetLanguageCodeFromLocalStorage();
69
68
 
70
69
  if (languageFromLocalStorage) {
71
70
  return languageFromLocalStorage;
72
- } else {
73
- const availableAppLanguages = GetAvailableAppLanguages();
71
+ }
74
72
 
75
- const pickedLanguage = PickBestMatchedLanguage(
76
- locale,
77
- availableAppLanguages
78
- );
73
+ const availableAppLanguages = GetAvailableAppLanguages();
79
74
 
80
- await SetLanguageCodeToLocalStorage(pickedLanguage);
75
+ const pickedLanguage = PickBestMatchedLanguage(
76
+ locale,
77
+ availableAppLanguages
78
+ );
81
79
 
82
- return pickedLanguage;
83
- }
80
+ return pickedLanguage;
84
81
  };
85
82
 
86
83
  /**
87
- * Supported platforms: [Samsung] TODO: LG
88
- * This function is adding languageLocale and countryLocale to the QuickBrickCommunicationModule
89
- * @return Promise
84
+ /**
85
+ * Loads and sets locale-related information in the QuickBrickCommunicationModule.
86
+ * This function adds uiLanguage, countryLocale, languageLocale, and languageCode to the module.
87
+ *
88
+ * @param {Function} GetLocale - Function to retrieve the current locale.
89
+ * @param {Function} GetLanguageCode - Function to determine the language code based on available languages.
90
+ * @param {Object} quickBrickCommunicationModule - The module to be updated with locale information.
91
+ * @returns {Promise<Function>} An async function that loads and sets the locale information.
90
92
  * Warning! This function mutates QuickBrickCommunicationModule
91
93
  */
92
94
  export const loadLocaleImpl =
@@ -101,14 +103,13 @@ export const loadLocaleImpl =
101
103
  quickBrickCommunicationModule.countryLocale = R.toUpper(countryLocale);
102
104
  }
103
105
 
104
- // setup languageCode
105
106
  const languageCode = await GetLanguageCode(locale);
106
107
  quickBrickCommunicationModule.languageCode = languageCode;
108
+ quickBrickCommunicationModule.uiLanguage = languageCode;
107
109
  };
108
110
 
109
111
  export const getLanguageCode = getLanguageCodeImpl(
110
112
  getLanguageCodeFromLocalStorage,
111
113
  getAvailableAppLanguages,
112
- pickBestMatchedLanguage,
113
- setLanguageCodeToLocalStorage
114
+ pickBestMatchedLanguage
114
115
  );
@@ -42,6 +42,36 @@ export const isSamsungPlatform = () => {
42
42
  );
43
43
  };
44
44
 
45
+ /**
46
+ * Checks if we are on the Vizio platform by using the userAgent string
47
+ *
48
+ * Use this method when you need to identify the platform on runtime
49
+ * before the VIZIO Companion Library has loaded
50
+ */
51
+ export const isVizioPlatform = () => {
52
+ const userAgent = getUserAgent();
53
+
54
+ const isVizioAgent =
55
+ userAgent.includes(PLATFORMS.vizio) ||
56
+ userAgent.includes(PLATFORMS.smartcast) ||
57
+ userAgent.includes(PLATFORMS.conjure);
58
+
59
+ return isVizioAgent;
60
+ };
61
+
62
+ /**
63
+ * Checks if we are on the Vizio platform by checking for the global VIZIO object
64
+ * and the VIZIO Companion Library. This method does not use the userAgent string,
65
+ * because it mainly checks whether we have access to apis, which may not be available yet
66
+ */
67
+ export const hasVizioAPIs = () => {
68
+ const hasAPIs =
69
+ typeof window.VIZIO !== "undefined" &&
70
+ window?.applicaster?.vizioLibraryDidLoad;
71
+
72
+ return hasAPIs;
73
+ };
74
+
45
75
  /**
46
76
  * platformSelect can't be used, because Platform.OS is overriden
47
77
  * by overloadReactNativePlatform and don't support 'web' value
@@ -253,6 +283,24 @@ export const getDeviceData = async () => {
253
283
  ...tizenConnectionInfo,
254
284
  device_type: getDeviceType(),
255
285
  };
286
+ } else if (isVizioPlatform()) {
287
+ deviceData = {
288
+ ...deviceData,
289
+ platform: "vizio",
290
+ deviceMake: "Vizio",
291
+ deviceType: "tv",
292
+ userAgent: getUserAgent(),
293
+ deviceWidth: window.innerWidth,
294
+ deviceHeight: window.innerHeight,
295
+ };
296
+
297
+ if (hasVizioAPIs()) {
298
+ window.VIZIO.getFirmwareVersion(function (firmwareVersion) {
299
+ deviceData.osVersion = firmwareVersion;
300
+ });
301
+
302
+ deviceData.deviceModel = window.VIZIO.deviceModel;
303
+ }
256
304
  }
257
305
 
258
306
  return deviceData;
@@ -105,7 +105,11 @@ function isInvalidAnalyticsData(eventName, payload = null) {
105
105
 
106
106
  export const AnalyticsBridge = (function () {
107
107
  let registeredAnalyticPlugins: {
108
- module: { sendEvent: Function; initialize?: Function };
108
+ module: {
109
+ sendEvent: Function;
110
+ initialize?: Function;
111
+ getPlayerConfiguration?: Function;
112
+ };
109
113
  configuration?: any;
110
114
  }[] = [];
111
115
 
@@ -8,4 +8,5 @@ export enum STORAGE {
8
8
  export enum STORE_PLATFORM {
9
9
  lg = "lg_content_store",
10
10
  samsung = "samsung_app_store",
11
+ vizio = "vizio_app_store",
11
12
  }
@@ -0,0 +1,225 @@
1
+ const mockPersistLanguageSelection = jest.fn();
2
+
3
+ jest.mock("../../../App/Loader/utils", () => ({
4
+ persistLanguageSelection: mockPersistLanguageSelection,
5
+ }));
6
+
7
+ const mockGetAppData = jest.fn();
8
+
9
+ jest.mock("../../AppData", () => ({
10
+ getAppData: mockGetAppData,
11
+ }));
12
+
13
+ jest.mock("@applicaster/zapp-react-native-bridge/QuickBrick", () => ({
14
+ QUICK_BRICK_EVENTS: {
15
+ QUICK_BRICK_READY: "QUICK_BRICK_READY",
16
+ FORCE_APP_RELOAD: "FORCE_APP_RELOAD",
17
+ },
18
+ }));
19
+
20
+ const mocked_storage = {
21
+ "applicaster.v2_::_uiLanguage": "en-UK",
22
+ };
23
+
24
+ global.window.location = {
25
+ reload: jest.fn(),
26
+ };
27
+
28
+ global.window.localStorage = {
29
+ getItem: jest.fn((key) => mocked_storage[key] || null),
30
+ setItem: jest.fn((key, value) => {
31
+ mocked_storage[key] = value;
32
+ }),
33
+ removeItem: jest.fn((key) => {
34
+ delete mocked_storage[key];
35
+ }),
36
+ clear: jest.fn(),
37
+ length: 1,
38
+ key: jest.fn(),
39
+ };
40
+
41
+ const BASE_MOCK_APP_DATA = {
42
+ accountsAccountId: "abcid123",
43
+ appFamilyId: 1710,
44
+ appName: "App.TV",
45
+ buildVersion: 247,
46
+ bundleIdentifier: "com.bundleIdentifier",
47
+ deviceTarget: "samsung_tv",
48
+ languages: ["en", "en-US", "fr", "fr-FR"],
49
+ locale: "en",
50
+ quickBrickVersion: "11.1.13",
51
+ quickBrickEvent: () => {},
52
+ setAppLanguage: () => {},
53
+ initialProps: {},
54
+ riversConfigurationId: "riverid123",
55
+ sdkVersion: "1.0.0",
56
+ store: "samsung",
57
+ urlSchemePrefix: "app-tv",
58
+ versionId: "version1233",
59
+ versionName: "1.0.8",
60
+ bver: "1.0.8",
61
+ os_type: "web",
62
+ platform: "samsung_tv",
63
+ };
64
+
65
+ const getMockAppData = (overrides = {}) => ({
66
+ ...BASE_MOCK_APP_DATA,
67
+ ...overrides,
68
+ });
69
+
70
+ describe("QuickBrickCommunicationModule Polyfill", () => {
71
+ let originalWindow;
72
+ let QuickBrickModule;
73
+ let mockAppData;
74
+
75
+ beforeEach(() => {
76
+ jest.resetModules();
77
+ originalWindow = global.window;
78
+
79
+ // Get fresh mockAppData for each test
80
+ mockAppData = getMockAppData();
81
+ mockGetAppData.mockReturnValue(mockAppData);
82
+
83
+ window.localStorage.getItem.mockClear();
84
+ window.localStorage.setItem.mockClear();
85
+ window.localStorage.removeItem.mockClear();
86
+ window.localStorage.clear.mockClear();
87
+
88
+ QuickBrickModule = require("../index");
89
+ });
90
+
91
+ afterEach(() => {
92
+ global.window = originalWindow;
93
+ jest.clearAllMocks();
94
+ });
95
+
96
+ describe("getMatchingLanguageFromBuildData", () => {
97
+ it("should return default locale (en) if there are no languages listed in build time parameters", () => {
98
+ window.localStorage.getItem.mockReturnValue(undefined);
99
+ global.window.navigator.language = "ru-RU";
100
+ mockGetAppData.mockReturnValue(getMockAppData({ languages: null }));
101
+
102
+ const result = QuickBrickModule.getMatchingLanguageFromBuildData();
103
+
104
+ expect(result).toEqual({
105
+ uiLanguage: "en",
106
+ languageCode: "en",
107
+ countryLocale: "",
108
+ languageLocale: "en",
109
+ });
110
+ });
111
+
112
+ it("should fallback to default language if browser / device locale (en-UK) does not match list of supported languages", () => {
113
+ // localizations: ["en", "en-US", "fr", "fr-FR"], device_language: en, device_region: UK -> ui: en, language: en, region: UK
114
+
115
+ global.window.navigator.language = "en-UK";
116
+ const result = QuickBrickModule.getMatchingLanguageFromBuildData();
117
+
118
+ expect(result).toEqual({
119
+ uiLanguage: "en",
120
+ languageCode: "en",
121
+ countryLocale: "",
122
+ languageLocale: "en",
123
+ });
124
+ });
125
+
126
+ it("should use browser / device locale (en-US) when it matches one of the supported languages", () => {
127
+ // localizations: ["en", "en-US", "fr", "fr-FR"], device_language: en, device_region: US -> ui: en-US, language: en, region: US
128
+
129
+ global.window.navigator.language = "en-US";
130
+ const result = QuickBrickModule.getMatchingLanguageFromBuildData();
131
+
132
+ expect(result).toEqual({
133
+ uiLanguage: "en-US",
134
+ languageCode: "en",
135
+ countryLocale: "US",
136
+ languageLocale: "en-US",
137
+ });
138
+ });
139
+
140
+ it("should fallback to default language if browser / device locale (ru-RU) does not match list of supported languages", () => {
141
+ // localizations: ["en", "en-US", "fr", "fr-FR"], device_language: ru, device_region: RU -> ui: en, language: en, region: ""
142
+ global.window.navigator.language = "ru-RU";
143
+ const result = QuickBrickModule.getMatchingLanguageFromBuildData();
144
+
145
+ expect(result).toEqual({
146
+ uiLanguage: "en",
147
+ languageCode: "en",
148
+ countryLocale: "",
149
+ languageLocale: "en",
150
+ });
151
+ });
152
+
153
+ it("should use browser / device locale (fr-FR) when it matches one of the supported language", () => {
154
+ // localizations: ["en", "en-US", "fr", "fr-FR"], device_language: fr, device_region: FR -> ui: fr, language: fr, region: FR
155
+ global.window.navigator.language = "fr-FR";
156
+ const result = QuickBrickModule.getMatchingLanguageFromBuildData();
157
+
158
+ expect(result).toEqual({
159
+ uiLanguage: "fr-FR",
160
+ languageCode: "fr",
161
+ countryLocale: "FR",
162
+ languageLocale: "fr-FR",
163
+ });
164
+ });
165
+
166
+ it("should use user-selected language (en-US) if it was persisted in local storage", () => {
167
+ window.localStorage.getItem.mockReturnValue("en-US");
168
+
169
+ const result = QuickBrickModule.getMatchingLanguageFromBuildData();
170
+
171
+ expect(window.localStorage.getItem).toHaveBeenCalled();
172
+
173
+ expect(result).toEqual({
174
+ uiLanguage: "en-US",
175
+ languageCode: "en",
176
+ countryLocale: "US",
177
+ languageLocale: "en-US",
178
+ });
179
+ });
180
+
181
+ it("should use locale (fr-FR) from browser / device if no user selection is found in storage", () => {
182
+ window.localStorage.getItem.mockReturnValue(undefined);
183
+ global.window.navigator.language = "fr-FR";
184
+ const result = QuickBrickModule.getMatchingLanguageFromBuildData();
185
+
186
+ expect(result).toEqual({
187
+ uiLanguage: "fr-FR",
188
+ languageCode: "fr",
189
+ countryLocale: "FR",
190
+ languageLocale: "fr-FR",
191
+ });
192
+ });
193
+ });
194
+
195
+ describe("setAppLanguage", () => {
196
+ it("should persist language selection and reload the page", async () => {
197
+ await QuickBrickModule.setAppLanguage("fr-FR");
198
+
199
+ expect(mockPersistLanguageSelection).toHaveBeenCalledWith("fr-FR");
200
+ expect(window.location.reload).toHaveBeenCalled();
201
+ });
202
+ });
203
+
204
+ describe("QuickBrickCommunicationModule", () => {
205
+ it("should initialize with the required app data", () => {
206
+ expect(QuickBrickModule.QuickBrickCommunicationModule).toMatchObject({
207
+ ...mockAppData,
208
+ platform: mockAppData.deviceTarget,
209
+ setAppLanguage: expect.any(Function),
210
+ });
211
+ });
212
+
213
+ it("should include locale data after calling getMatchingLanguageBuildData", () => {
214
+ const localeData = QuickBrickModule.getMatchingLanguageFromBuildData();
215
+ const result = { ...mockAppData, ...localeData };
216
+
217
+ Object.entries(result).forEach(([key, value]) => {
218
+ expect(QuickBrickModule.QuickBrickCommunicationModule).toHaveProperty(
219
+ key,
220
+ typeof value === "function" ? expect.any(Function) : value
221
+ );
222
+ });
223
+ });
224
+ });
225
+ });
@@ -1,8 +1,15 @@
1
1
  import * as R from "ramda";
2
2
 
3
- import * as AppDataPolyfill from "../AppData";
4
- import { setLanguageCodeToLocalStorage } from "../../App/Loader/utils";
3
+ import { persistLanguageSelection } from "../../App/Loader/utils";
4
+
5
+ import { getAppData } from "../AppData";
5
6
  import { QUICK_BRICK_EVENTS } from "@applicaster/zapp-react-native-bridge/QuickBrick";
7
+ import { createLogger } from "@applicaster/zapp-react-native-utils/logger";
8
+
9
+ const { log_error, log_debug, log_warning } = createLogger({
10
+ category: "QuickBrickCommunicationModule",
11
+ subsystem: "zapp-react-dom-app",
12
+ });
6
13
 
7
14
  const QuickBrickEventEmitter = {};
8
15
 
@@ -63,98 +70,154 @@ try {
63
70
  console.warn("cannot assign QuickBrickEvents to window", e.message);
64
71
  }
65
72
 
66
- function quickBrickEvent(eventName, payload) {
73
+ export function quickBrickEvent(eventName, payload) {
67
74
  QuickBrickEventEmitter.sendEvent(eventName, payload);
68
75
  }
69
76
 
70
77
  const initialProps = {}; // required for legacy plugins;
71
78
 
72
- function getBrowserLocale() {
79
+ /**
80
+ * Retrieves the browser locale, which represents the device locale for SmartTVs.
81
+ *
82
+ * @returns {string|undefined} The browser locale string (e.g., "en-US") or undefined if an error occurs.
83
+ *
84
+ * TODO: We can later get more accurate locale data with device APIs, if needed
85
+ */
86
+ export function getBrowserLocale() {
73
87
  try {
74
88
  return window.navigator.language;
75
89
  } catch (e) {
76
- // eslint-disable-next-line no-console
77
- console.error("Could not determine browser locale", e);
90
+ log_error("Could not determine locale", e);
78
91
  }
79
92
  }
80
93
 
81
- function formatLocale(locale) {
82
- return locale?.toLowerCase()?.split("-");
83
- }
84
-
85
- const browserLocale = getBrowserLocale();
86
- const [languageCode, countryLocale] = formatLocale(browserLocale) || ["", ""];
87
-
88
- const setAppLanguage = async (languageCode) => {
89
- await setLanguageCodeToLocalStorage(languageCode);
94
+ /**
95
+ * Updates the UI language, persists the selection, and reloads the application.
96
+ * This method is used by the QuickBrick Language Selector to change the app's language.
97
+ *
98
+ * @param {string} uiLanguage - en-UK, en-US, etc.
99
+ * @returns {Promise<void>}
100
+ *
101
+ * Usage: plugins/quick-brick-language-selector/src/utils/index.ts
102
+ */
103
+ export const setAppLanguage = async (uiLanguage) => {
104
+ await persistLanguageSelection(uiLanguage);
90
105
 
91
- // reload web page
92
106
  window.location.reload();
93
107
  };
94
108
 
95
- const buildTimeData = AppDataPolyfill.getAppData();
96
-
97
- // TODO - move to a better place
98
- const getMatchingLanguageFromBuildData = () => {
109
+ const buildTimeData = getAppData();
110
+
111
+ /**
112
+ * This method retrieves the all of the language data we need from the build data, and the browser locale.
113
+ * @returns localeData
114
+ * @returns localeData.uiLanguage - The language code to use for the app.
115
+ * @returns localeData.countryLocale - The country locale code.
116
+ * @returns localeData.languageLocale - The language locale code.
117
+ * @returns localeData.languageCode - The language code.
118
+ */
119
+ export const getMatchingLanguageFromBuildData = () => {
120
+ // Locale is a build time value we get from Zapp, so we can use it as a fallback
99
121
  const { locale, languages } = buildTimeData || {};
122
+ const browserLocale = getBrowserLocale();
123
+
124
+ if (!Array.isArray(languages)) {
125
+ log_error("Missing required 'languages' from the build time data.", {
126
+ buildTimeData,
127
+ });
128
+
129
+ return {
130
+ uiLanguage: locale,
131
+ languageCode: locale,
132
+ countryLocale: "",
133
+ languageLocale: locale,
134
+ };
135
+ }
100
136
 
101
- // TODO: refactor: tryAndResolve used in Storage is promise
102
137
  const userSelectedLanguage = window.localStorage.getItem(
103
138
  "applicaster.v2_::_uiLanguage"
104
139
  );
105
140
 
106
- if (!locale && !Array.isArray(languages)) {
107
- // eslint-disable-next-line no-console
108
- console.error("Missing required 'locale' and 'language' build time data.");
109
-
110
- // Default language
111
- return locale;
112
- }
113
-
114
141
  if (userSelectedLanguage) {
115
- if (languages.includes(userSelectedLanguage)) {
116
- return userSelectedLanguage;
142
+ if (languages.find((lang) => lang === userSelectedLanguage)) {
143
+ const [languageCode, countryLocale] = userSelectedLanguage.split("-");
144
+
145
+ return {
146
+ uiLanguage: userSelectedLanguage,
147
+ languageCode,
148
+ countryLocale: countryLocale || "",
149
+ languageLocale: userSelectedLanguage,
150
+ };
117
151
  }
118
152
 
119
- // eslint-disable-next-line no-console
120
- console.log(
121
- "User selected language is not present in available localizations, performing autoselect"
153
+ log_debug(
154
+ "User selected language is not present in available localizations, selecting language using browser or device locale",
155
+
156
+ { userSelectedLanguage, languages }
122
157
  );
123
158
  }
124
159
 
125
160
  if (!browserLocale) {
126
- // eslint-disable-next-line no-console
127
- console.error("Could not determine browser locale.");
161
+ log_warning(
162
+ "Could not use browser or device locale to determine language, falling back to default locale",
163
+ { browserLocale, locale }
164
+ );
128
165
 
129
- return locale;
166
+ return {
167
+ uiLanguage: locale,
168
+ languageCode: locale,
169
+ countryLocale: "",
170
+ languageLocale: locale,
171
+ };
130
172
  }
131
173
 
132
- // Match locale "en-UK"
133
- const localeMatch = languages.find((lang) => lang === browserLocale);
174
+ // Exact match
175
+ const [browserLanguageCode, browserCountryLocale] = browserLocale.split("-");
176
+ const browserLocaleMatch = languages.find((lang) => lang === browserLocale);
134
177
 
135
- if (localeMatch) {
136
- return localeMatch;
178
+ if (browserLocaleMatch) {
179
+ return {
180
+ uiLanguage: browserLocaleMatch,
181
+ languageCode: browserLanguageCode,
182
+ countryLocale: browserCountryLocale || "",
183
+ languageLocale: browserLocaleMatch,
184
+ };
137
185
  }
138
186
 
139
- // Match language "en"
140
- const languageMatch = languages.find((lang) => lang === languageCode);
187
+ // If there is no user-selected language, or the user-selected language is not found in the list of supported languages,
188
+ // and the browser locale is also not found in the list of supported languages,
189
+ // then fallback to the default language from the list of supported languages, while preserving the country locale from the browser.
190
+ const defaultLanguage = languages[0];
141
191
 
142
- if (languageMatch) {
143
- return languageMatch;
144
- }
192
+ if (defaultLanguage) {
193
+ const [languageCode, countryLocale] = defaultLanguage.split("-");
145
194
 
146
- // Default language
147
- return locale;
195
+ const defaultLocale = {
196
+ uiLanguage: defaultLanguage,
197
+ languageCode,
198
+ countryLocale: countryLocale || "",
199
+ languageLocale: defaultLanguage,
200
+ };
201
+
202
+ log_debug(
203
+ "Locale not found in the list of supported languages, falling back to the default language locale",
204
+ { defaultLocale, browserLocale, languages }
205
+ );
206
+
207
+ return defaultLocale;
208
+ }
148
209
  };
149
210
 
211
+ const localeData = getMatchingLanguageFromBuildData();
212
+
213
+ /**
214
+ * QuickBrickCommunicationModule object is a Polyfill that mimicks how we interface with the Native platforms
215
+ */
150
216
  export const QuickBrickCommunicationModule = {
151
217
  quickBrickEvent,
152
218
  initialProps,
153
219
  ...buildTimeData,
220
+ ...localeData,
154
221
  platform: buildTimeData.deviceTarget,
155
- languageLocale: languageCode,
156
- countryLocale,
157
222
  setAppLanguage,
158
- countryCode: countryLocale,
159
- uiLanguage: getMatchingLanguageFromBuildData(),
160
223
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applicaster/zapp-react-dom-app",
3
- "version": "12.0.0-rc.8",
3
+ "version": "12.0.0-rc.80",
4
4
  "description": "Zapp App Component for Applicaster's Quick Brick React Native App",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -22,11 +22,11 @@
22
22
  },
23
23
  "homepage": "https://github.com/applicaster/zapp-react-dom-app#readme",
24
24
  "dependencies": {
25
- "@applicaster/zapp-react-dom-ui-components": "12.0.0-rc.8",
26
- "@applicaster/zapp-react-native-bridge": "12.0.0-rc.8",
27
- "@applicaster/zapp-react-native-redux": "12.0.0-rc.8",
28
- "@applicaster/zapp-react-native-ui-components": "12.0.0-rc.8",
29
- "@applicaster/zapp-react-native-utils": "12.0.0-rc.8",
25
+ "@applicaster/zapp-react-dom-ui-components": "12.0.0-rc.80",
26
+ "@applicaster/zapp-react-native-bridge": "12.0.0-rc.80",
27
+ "@applicaster/zapp-react-native-redux": "12.0.0-rc.80",
28
+ "@applicaster/zapp-react-native-ui-components": "12.0.0-rc.80",
29
+ "@applicaster/zapp-react-native-utils": "12.0.0-rc.80",
30
30
  "abortcontroller-polyfill": "^1.7.5",
31
31
  "history": "^4.7.2",
32
32
  "react-router-dom": "^5.1.2",
@@ -1,35 +0,0 @@
1
- import { getAvailableAppLanguagesImpl as getAvailableAppLanguages } from "..";
2
-
3
- describe("getAvailableAppLanguages", () => {
4
- it("return list of languages", () => {
5
- // setup
6
- const appStore = {
7
- get: () => ({
8
- localizations: {
9
- en: "en_value",
10
- de: "de_value",
11
- fr: "fr_value",
12
- "en-UK": "en-UK_value",
13
- "en-GB": "en-GB_value",
14
- },
15
- }),
16
- };
17
-
18
- // verify
19
- expect(getAvailableAppLanguages(appStore)).toEqual([
20
- "en",
21
- "de",
22
- "fr",
23
- "en-UK",
24
- "en-GB",
25
- ]);
26
- });
27
-
28
- it("return list of languages", () => {
29
- // setup
30
- const appStore = undefined;
31
-
32
- // verify
33
- expect(getAvailableAppLanguages(appStore)).toEqual([]);
34
- });
35
- });