@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.
- package/README.md +2 -0
- package/lib/commonjs/ConsentManager.js +18 -5
- package/lib/commonjs/ConsentManager.js.map +1 -1
- package/lib/commonjs/ConsentUiBuilder.js +8 -4
- package/lib/commonjs/ConsentUiBuilder.js.map +1 -1
- package/lib/commonjs/components/Button.js +7 -2
- package/lib/commonjs/components/Button.js.map +1 -1
- package/lib/commonjs/doNotSell.js +4 -6
- package/lib/commonjs/doNotSell.js.map +1 -1
- package/lib/commonjs/reducer.js +5 -1
- package/lib/commonjs/reducer.js.map +1 -1
- package/lib/commonjs/services/consent.js +15 -9
- package/lib/commonjs/services/consent.js.map +1 -1
- package/lib/commonjs/utils/Translator.js +25 -20
- package/lib/commonjs/utils/Translator.js.map +1 -1
- package/lib/commonjs/utils/safeArea.js +38 -0
- package/lib/commonjs/utils/safeArea.js.map +1 -0
- package/lib/commonjs/views/Dialog/index.js +19 -5
- package/lib/commonjs/views/Dialog/index.js.map +1 -1
- package/lib/module/ConsentManager.js +18 -5
- package/lib/module/ConsentManager.js.map +1 -1
- package/lib/module/ConsentUiBuilder.js +8 -4
- package/lib/module/ConsentUiBuilder.js.map +1 -1
- package/lib/module/components/Button.js +7 -2
- package/lib/module/components/Button.js.map +1 -1
- package/lib/module/doNotSell.js +4 -6
- package/lib/module/doNotSell.js.map +1 -1
- package/lib/module/reducer.js +5 -1
- package/lib/module/reducer.js.map +1 -1
- package/lib/module/services/consent.js +15 -9
- package/lib/module/services/consent.js.map +1 -1
- package/lib/module/utils/Translator.js +25 -20
- package/lib/module/utils/Translator.js.map +1 -1
- package/lib/module/utils/safeArea.js +33 -0
- package/lib/module/utils/safeArea.js.map +1 -0
- package/lib/module/views/Dialog/index.js +20 -6
- package/lib/module/views/Dialog/index.js.map +1 -1
- package/lib/typescript/src/ConsentManager.d.ts +13 -4
- package/lib/typescript/src/ConsentManager.d.ts.map +1 -1
- package/lib/typescript/src/ConsentUiBuilder.d.ts.map +1 -1
- package/lib/typescript/src/components/Button.d.ts.map +1 -1
- package/lib/typescript/src/doNotSell.d.ts +4 -1
- package/lib/typescript/src/doNotSell.d.ts.map +1 -1
- package/lib/typescript/src/reducer.d.ts.map +1 -1
- package/lib/typescript/src/services/consent.d.ts.map +1 -1
- package/lib/typescript/src/utils/Translator.d.ts +2 -0
- package/lib/typescript/src/utils/Translator.d.ts.map +1 -1
- package/lib/typescript/src/utils/safeArea.d.ts +12 -0
- package/lib/typescript/src/utils/safeArea.d.ts.map +1 -0
- package/lib/typescript/src/views/Dialog/index.d.ts.map +1 -1
- package/package.json +11 -5
- package/src/ConsentManager.ts +19 -5
- package/src/ConsentUiBuilder.tsx +8 -3
- package/src/components/Button.tsx +10 -2
- package/src/doNotSell.ts +4 -6
- package/src/reducer.ts +5 -1
- package/src/services/consent.ts +14 -9
- package/src/utils/Translator.ts +35 -25
- package/src/utils/safeArea.ts +39 -0
- 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
|
-
*
|
|
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
|
-
//
|
|
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': {
|
package/src/services/consent.ts
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
285
|
-
//
|
|
286
|
-
// The Do-Not-Sell default is then applied, but only
|
|
287
|
-
// actually be visible (tenant flag + CCPA/US), so we
|
|
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) {
|
package/src/utils/Translator.ts
CHANGED
|
@@ -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
|
-
//
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
[
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
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
|
|
196
|
+
<View
|
|
197
|
+
style={[
|
|
198
|
+
styles.modalContainer,
|
|
199
|
+
dialogOverlay,
|
|
200
|
+
{ paddingTop: safePaddingTop, paddingBottom: safePaddingBottom },
|
|
201
|
+
]}
|
|
202
|
+
>
|
|
188
203
|
<View style={dialogStyles}>
|
|
189
|
-
|
|
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
|
-
</
|
|
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: {
|