@osano/osano-cmp-react-native 0.19.1 → 0.19.3

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 (60) hide show
  1. package/README.md +2 -0
  2. package/lib/commonjs/ConsentManager.js +18 -5
  3. package/lib/commonjs/ConsentManager.js.map +1 -1
  4. package/lib/commonjs/ConsentUiBuilder.js +8 -4
  5. package/lib/commonjs/ConsentUiBuilder.js.map +1 -1
  6. package/lib/commonjs/components/Button.js +7 -2
  7. package/lib/commonjs/components/Button.js.map +1 -1
  8. package/lib/commonjs/doNotSell.js +4 -6
  9. package/lib/commonjs/doNotSell.js.map +1 -1
  10. package/lib/commonjs/reducer.js +5 -1
  11. package/lib/commonjs/reducer.js.map +1 -1
  12. package/lib/commonjs/services/consent.js +15 -9
  13. package/lib/commonjs/services/consent.js.map +1 -1
  14. package/lib/commonjs/utils/Translator.js +25 -20
  15. package/lib/commonjs/utils/Translator.js.map +1 -1
  16. package/lib/commonjs/utils/safeArea.js +38 -0
  17. package/lib/commonjs/utils/safeArea.js.map +1 -0
  18. package/lib/commonjs/views/Dialog/index.js +19 -5
  19. package/lib/commonjs/views/Dialog/index.js.map +1 -1
  20. package/lib/module/ConsentManager.js +18 -5
  21. package/lib/module/ConsentManager.js.map +1 -1
  22. package/lib/module/ConsentUiBuilder.js +8 -4
  23. package/lib/module/ConsentUiBuilder.js.map +1 -1
  24. package/lib/module/components/Button.js +7 -2
  25. package/lib/module/components/Button.js.map +1 -1
  26. package/lib/module/doNotSell.js +4 -6
  27. package/lib/module/doNotSell.js.map +1 -1
  28. package/lib/module/reducer.js +5 -1
  29. package/lib/module/reducer.js.map +1 -1
  30. package/lib/module/services/consent.js +15 -9
  31. package/lib/module/services/consent.js.map +1 -1
  32. package/lib/module/utils/Translator.js +25 -20
  33. package/lib/module/utils/Translator.js.map +1 -1
  34. package/lib/module/utils/safeArea.js +33 -0
  35. package/lib/module/utils/safeArea.js.map +1 -0
  36. package/lib/module/views/Dialog/index.js +20 -6
  37. package/lib/module/views/Dialog/index.js.map +1 -1
  38. package/lib/typescript/src/ConsentManager.d.ts +13 -4
  39. package/lib/typescript/src/ConsentManager.d.ts.map +1 -1
  40. package/lib/typescript/src/ConsentUiBuilder.d.ts.map +1 -1
  41. package/lib/typescript/src/components/Button.d.ts.map +1 -1
  42. package/lib/typescript/src/doNotSell.d.ts +4 -1
  43. package/lib/typescript/src/doNotSell.d.ts.map +1 -1
  44. package/lib/typescript/src/reducer.d.ts.map +1 -1
  45. package/lib/typescript/src/services/consent.d.ts.map +1 -1
  46. package/lib/typescript/src/utils/Translator.d.ts +2 -0
  47. package/lib/typescript/src/utils/Translator.d.ts.map +1 -1
  48. package/lib/typescript/src/utils/safeArea.d.ts +12 -0
  49. package/lib/typescript/src/utils/safeArea.d.ts.map +1 -0
  50. package/lib/typescript/src/views/Dialog/index.d.ts.map +1 -1
  51. package/package.json +11 -5
  52. package/src/ConsentManager.ts +19 -5
  53. package/src/ConsentUiBuilder.tsx +8 -3
  54. package/src/components/Button.tsx +10 -2
  55. package/src/doNotSell.ts +4 -6
  56. package/src/reducer.ts +5 -1
  57. package/src/services/consent.ts +14 -9
  58. package/src/utils/Translator.ts +35 -25
  59. package/src/utils/safeArea.ts +39 -0
  60. package/src/views/Dialog/index.tsx +23 -6
package/src/doNotSell.ts CHANGED
@@ -67,7 +67,10 @@ export const normalizeDoNotSellConsent = <
67
67
  /**
68
68
  * Seeds the Do-Not-Sell default into a starting selection (no-op unless
69
69
  * enabled): default-on opts out and drops the governed categories; default-off
70
- * consents them with no opt-out signal.
70
+ * is a no-op. "Not opted out" is the absence of the opt-out signal, never a
71
+ * grant — the web CMP keeps governed categories denied until the visitor
72
+ * consents, and granting them here leaks pre-choice ad grants to Google
73
+ * Consent Mode.
71
74
  */
72
75
  export const applyDoNotSellDefault = (
73
76
  categories: ConsentCategoryType[],
@@ -90,11 +93,6 @@ export const applyDoNotSellDefault = (
90
93
  ConsentCategory.OPT_OUT,
91
94
  ];
92
95
  }
93
- for (const category of governed) {
94
- if (!result.includes(category)) {
95
- result.push(category);
96
- }
97
- }
98
96
  return result;
99
97
  };
100
98
 
package/src/reducer.ts CHANGED
@@ -66,7 +66,11 @@ const setAllConsent = (acceptOrDeny: boolean) => (state: OsanoState) => {
66
66
  return true;
67
67
  }
68
68
  case 'OPT_OUT': {
69
- // This is inverted because we would assume consent to all would be DENY for OPT_OUT
69
+ // Inverted — consent to all means no opt-out. Only meaningful when
70
+ // Do-Not-Sell is enabled; the web CMP never sets the signal otherwise.
71
+ if (!state.config?.enableDoNotSell) {
72
+ return false;
73
+ }
70
74
  return useGPC || !acceptOrDeny;
71
75
  }
72
76
  case 'MARKETING': {
@@ -29,10 +29,9 @@ const CONSENTING_PLATFORM_TAG = IS_OFFICIAL_SAMPLE
29
29
  ? `${Platform.OS}.sample-react-native`
30
30
  : `${Platform.OS}.react-native`;
31
31
 
32
- const overrideConfig: Partial<Config> = {
33
- // Override config here
34
- variant: 'two',
35
- };
32
+ // Local-testing hook only — any key set here clobbers the fetched config
33
+ // (including the x-osano-* headers), so it must ship empty.
34
+ const overrideConfig: Partial<Config> = {};
36
35
 
37
36
  export type ConsentPayload = {
38
37
  customerId: string;
@@ -153,7 +152,11 @@ export const storeConsent = async (
153
152
  ): Promise<ConsentCategoryType[]> => {
154
153
  cmp.consent = consent ?? cmp.consent;
155
154
  return new Promise<ConsentCategoryType[]>((resolve, reject) => {
155
+ // Only once both writes land: `consentSaved` mirrors web's isSaved (a
156
+ // stored record exists), and a failed save must leave the implied-consent
157
+ // close path able to record a choice later.
156
158
  const resConsent = () => {
159
+ cmp.consentSaved = true;
157
160
  resolve(cmp.consent ?? []);
158
161
  return cmp.consent;
159
162
  };
@@ -281,11 +284,11 @@ export const migrateConsentStorage = async (
281
284
  }
282
285
  };
283
286
 
284
- // The consent to fall back to when a visitor has no stored consent yet. Flavor 7
285
- // shows analytics enabled on first load; every other flavor stays essential-only.
286
- // The Do-Not-Sell default is then applied, but only when the opt-out would
287
- // actually be visible (tenant flag + CCPA/US), so we never seed an OPT_OUT the
288
- // user cannot see or control.
287
+ // The consent to fall back to when a visitor has no stored consent yet:
288
+ // essential-only, plus analytics for analytics-always flavors, matching the web
289
+ // CMP's pre-choice state. The Do-Not-Sell default is then applied, but only
290
+ // when the opt-out would actually be visible (tenant flag + CCPA/US), so we
291
+ // never seed an OPT_OUT the user cannot see or control.
289
292
  const firstLoadConsent = (cmp: ConsentManager): ConsentCategoryType[] => {
290
293
  let categories: ConsentCategoryType[] = [ConsentCategory.ESSENTIAL];
291
294
  if (cmp.config?.analyticsDefaultsOnAtFirstLoad) {
@@ -310,6 +313,7 @@ const clearToFirstLoadDefault = async (
310
313
  await clearConsent(cmp);
311
314
  const seed = firstLoadConsent(cmp);
312
315
  cmp.consent = seed;
316
+ cmp.consentSaved = false;
313
317
  return seed;
314
318
  };
315
319
 
@@ -389,6 +393,7 @@ const retrieveConsent = async (cmp: ConsentManager) => {
389
393
  const consent = JSON.parse(consentJson) as ConsentCategoryType[];
390
394
  if (consent.length > 0) {
391
395
  cmp.consent = consent;
396
+ cmp.consentSaved = true;
392
397
  }
393
398
  }
394
399
  if (cmp.consent && cmp.consent.length > 0) {
@@ -100,6 +100,8 @@ export default class Translator extends EventEmitter {
100
100
  private _langLoadFailureSubscription?: EventSubscription;
101
101
 
102
102
  private _loading: Record<string, Promise<string | undefined>> = {};
103
+ private _loadedCustomerId?: string;
104
+ private _loadedConfigId?: string;
103
105
  load(
104
106
  desiredLocale?: string,
105
107
  onLangLoadSuccess?: () => void,
@@ -114,6 +116,16 @@ export default class Translator extends EventEmitter {
114
116
  const locale = allLocales.shift();
115
117
  const customerId = this._consentManager.customerId;
116
118
  const configId = this._consentManager.configId;
119
+ // Overrides are keyed only by locale — stale copy from a previous
120
+ // customer/config would survive the switch and skip the refetch below.
121
+ if (
122
+ customerId !== this._loadedCustomerId ||
123
+ configId !== this._loadedConfigId
124
+ ) {
125
+ this._overrides = {};
126
+ this._loadedCustomerId = customerId;
127
+ this._loadedConfigId = configId;
128
+ }
117
129
  this._langLoadFailureSubscription =
118
130
  onLangLoadFailure && this.on('langLoadFailure', onLangLoadFailure);
119
131
  this._langLoadSuccessSubscription =
@@ -284,40 +296,38 @@ export default class Translator extends EventEmitter {
284
296
  const pathStr = Array.isArray(path) ? path.join('.') : path;
285
297
  const fallback =
286
298
  typeof defaultPhrase === 'string' ? defaultPhrase : `${pathStr}`;
287
- const phrases = locales[`${locale}`];
288
- const override = this._overrides[`${locale}`];
289
299
 
290
300
  let translation: string | number | LocalizedStrings | undefined;
291
301
 
292
- // Priority-based translation lookup
293
- const searchPaths = [
294
- // Mobile Customer-specific
295
- [override, `mobile.${pathStr}`],
296
- // Web Customer-specific
297
- [override, pathStr],
298
- // Mobile Global
299
- [phrases, `mobile.${pathStr}`],
300
- // Web Global
301
- [phrases, pathStr],
302
- ] as [Record<string, string>, string][];
303
- // Search in customer-specific overrides first, then global phrases
302
+ // load() stores phrases under whatever locale it managed to fetch (e.g.
303
+ // en-us.json may 403 while en.json exists), so search every fallback
304
+ // bucket. Overrides beat built-in phrases only within the active language
305
+ // — customer copy loaded via the English fallback must not repaint a
306
+ // non-English UI.
307
+ const normalized = locale.toLowerCase().replace(/_/g, '-');
308
+ const lookupLocales = [
309
+ ...new Set([locale, ...Translator.fallbackLanguages(normalized)]),
310
+ ].filter((lookupLocale): lookupLocale is string => !!lookupLocale);
311
+ const baseLanguage = normalized.split('-')[0];
312
+ const overrideLocales = lookupLocales.filter(
313
+ (lookupLocale) =>
314
+ lookupLocale === locale || lookupLocale.split('-')[0] === baseLanguage,
315
+ );
316
+ const searchPaths: [LocalizedStrings | undefined, string][] = [];
317
+ for (const lookupLocale of overrideLocales) {
318
+ const override = this._overrides[`${lookupLocale}`];
319
+ searchPaths.push([override, `mobile.${pathStr}`], [override, pathStr]);
320
+ }
321
+ for (const lookupLocale of lookupLocales) {
322
+ const phrases = locales[`${lookupLocale}`];
323
+ searchPaths.push([phrases, `mobile.${pathStr}`], [phrases, pathStr]);
324
+ }
304
325
  for (const [source, searchPath] of searchPaths) {
305
326
  if (source) {
306
327
  translation = get(source, searchPath);
307
328
  if (translation) break;
308
329
  }
309
330
  }
310
- // Base translatedResourceBundle (fallback to default locale)
311
- if (translation == null && locale !== Translator.DEFAULT_LANGUAGE) {
312
- const defaultPhrases = locales[Translator.DEFAULT_LANGUAGE];
313
- if (defaultPhrases) {
314
- translation = get(
315
- defaultPhrases,
316
- `mobile.${pathStr}`,
317
- get(defaultPhrases, pathStr),
318
- );
319
- }
320
- }
321
331
  // Use fallback if nothing found
322
332
  if (!translation) {
323
333
  translation = fallback;
@@ -0,0 +1,39 @@
1
+ export type SafeAreaInsets = {
2
+ top: number;
3
+ right: number;
4
+ bottom: number;
5
+ left: number;
6
+ };
7
+
8
+ const FALLBACK_INSETS: SafeAreaInsets = {
9
+ top: 0,
10
+ right: 0,
11
+ bottom: 0,
12
+ left: 0,
13
+ };
14
+
15
+ // react-native-safe-area-context is an optional peer: it is a native module,
16
+ // so a hard dependency would force every host app to relink on upgrade.
17
+ // Resolved once at module load, so the hook below branches on a constant.
18
+ let useLibInsets: (() => SafeAreaInsets) | undefined;
19
+ try {
20
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
21
+ const lib = require('react-native-safe-area-context') as {
22
+ useSafeAreaInsets: () => SafeAreaInsets;
23
+ };
24
+ useLibInsets = lib.useSafeAreaInsets;
25
+ } catch {
26
+ useLibInsets = undefined;
27
+ }
28
+
29
+ /**
30
+ * Device safe-area insets, or zeros when react-native-safe-area-context is not
31
+ * installed or no SafeAreaProvider is mounted (its hook throws in that case).
32
+ */
33
+ export const useSafeAreaInsetsSafe = (): SafeAreaInsets => {
34
+ try {
35
+ return useLibInsets ? useLibInsets() : FALLBACK_INSETS;
36
+ } catch {
37
+ return FALLBACK_INSETS;
38
+ }
39
+ };
@@ -10,6 +10,7 @@ import {
10
10
  Linking,
11
11
  Modal,
12
12
  Pressable,
13
+ ScrollView,
13
14
  StyleSheet,
14
15
  Text,
15
16
  useWindowDimensions,
@@ -21,6 +22,7 @@ import {
21
22
  } from 'react-native';
22
23
  import { acceptAll, close, toggleCategory } from '../../actions';
23
24
  import { saveConsent } from '../../commands';
25
+ import { useSafeAreaInsetsSafe } from '../../utils/safeArea';
24
26
  import { getConsent, isCategoryDisabled, isDialogOpen } from '../../selectors';
25
27
 
26
28
  import { CloseButton } from '../../components/Button';
@@ -60,7 +62,10 @@ export const Dialog = (): ReactElement<Modal> => {
60
62
  const categories = useTcf
61
63
  ? config.iabCategories
62
64
  : config?.nonEssentialCategories || [];
63
- const { width: windowWidth } = useWindowDimensions();
65
+ const { width: windowWidth, height: windowHeight } = useWindowDimensions();
66
+ const insets = useSafeAreaInsetsSafe();
67
+ const safePaddingTop = Math.max(insets.top, 20);
68
+ const safePaddingBottom = Math.max(insets.bottom, 20);
64
69
 
65
70
  const clearImpliedConsentTimeout = useCallback(() => {
66
71
  if (!impliedConsentTimeout.current) return;
@@ -70,9 +75,10 @@ export const Dialog = (): ReactElement<Modal> => {
70
75
 
71
76
  const handleClose = useCallback(() => {
72
77
  clearImpliedConsentTimeout();
73
- if (config?.isConsentImplied && !cmp?.consent) {
78
+ // `consentSaved`, not `consent` — the first-load seed populates `consent`
79
+ // and would otherwise suppress the implied accept-all forever.
80
+ if (config?.isConsentImplied && !cmp?.consentSaved) {
74
81
  dispatch(acceptAll());
75
- // Only save here if consent is implied and no consent has been given yet
76
82
  cmp && dispatch(saveConsent(cmp));
77
83
  }
78
84
  dispatch(close());
@@ -142,6 +148,9 @@ export const Dialog = (): ReactElement<Modal> => {
142
148
  {
143
149
  backgroundColor: dialogBackgroundColor,
144
150
  maxWidth: 1024,
151
+ // Content-heavy flavors overflow small screens; without a cap the
152
+ // buttons are pushed off-screen and the banner can't be acted on.
153
+ maxHeight: Math.max(0, windowHeight - safePaddingTop - safePaddingBottom),
145
154
  width: '100%',
146
155
  borderRadius: dialogType === 'box' ? 8 : 0,
147
156
  padding: 28,
@@ -184,9 +193,16 @@ export const Dialog = (): ReactElement<Modal> => {
184
193
  presentationStyle="overFullScreen"
185
194
  supportedOrientations={['portrait', 'landscape']}
186
195
  >
187
- <View style={[styles.modalContainer, dialogOverlay]}>
196
+ <View
197
+ style={[
198
+ styles.modalContainer,
199
+ dialogOverlay,
200
+ { paddingTop: safePaddingTop, paddingBottom: safePaddingBottom },
201
+ ]}
202
+ >
188
203
  <View style={dialogStyles}>
189
- <View style={styles.dialogContent}>
204
+ {/* Buttons stay outside the scroll area so they are always reachable. */}
205
+ <ScrollView style={styles.dialogContent} nestedScrollEnabled>
190
206
  <View style={[styles.row, styles.fitContent]}>
191
207
  <Text style={dialogForeground} testID="dialog-message">
192
208
  <Message />
@@ -256,7 +272,7 @@ export const Dialog = (): ReactElement<Modal> => {
256
272
  ))}
257
273
  </View>
258
274
  ) : null}
259
- </View>
275
+ </ScrollView>
260
276
  <Buttons
261
277
  style={windowWidth >= 768 ? styles.dialogRow : styles.dialogCol}
262
278
  buttonStyle={
@@ -325,6 +341,7 @@ const styles = StyleSheet.create({
325
341
  justifyContent: 'center',
326
342
  },
327
343
  dialogContent: {
344
+ flexShrink: 1,
328
345
  marginBottom: 16,
329
346
  },
330
347
  bannerLogo: {