Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ interface FramedProps {
tags: AdMeasurementTag[];
overlay: boolean;
gdprApplies?: boolean;
consentString?: string;
addtlConsent?: string;
}

/**
Expand All @@ -68,6 +70,8 @@ const FramedMeasurement = ({
tags,
overlay,
gdprApplies,
consentString,
addtlConsent,
}: FramedProps): ReactElement => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [frameReady, setFrameReady] = useState(false);
Expand Down Expand Up @@ -106,10 +110,12 @@ const FramedMeasurement = ({
type: 'init',
tags,
gdprApplies,
consentString,
addtlConsent,
},
frameOrigin,
);
}, [frameReady, tags, gdprApplies, frameOrigin]);
}, [frameReady, tags, gdprApplies, consentString, addtlConsent, frameOrigin]);

return (
<iframe
Expand Down Expand Up @@ -159,6 +165,8 @@ export const AdMeasurement = ({ ad }: { ad: Ad }): ReactElement | null => {
tags={tags}
overlay={overlay}
gdprApplies={ctx?.gdprApplies}
consentString={ctx?.consentString}
addtlConsent={ctx?.addtlConsent}
/>
) : (
<InlineMeasurement tags={tags} ctx={ctx} overlay={overlay} />
Expand Down
43 changes: 34 additions & 9 deletions packages/shared/src/features/monetization/adConsent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,42 @@ describe('resolveAdConsent', () => {
beforeEach(clearCookies);
afterEach(clearCookies);

it('does not apply GDPR when the user is outside scope', () => {
expect(resolveAdConsent(false).gdprApplies).toBe(false);
expect(resolveAdConsent(undefined).gdprApplies).toBe(false);
});
describe('without CMP (legacy compatibility)', () => {
it('does not apply GDPR when the user is outside scope', () => {
expect(resolveAdConsent(false).gdprApplies).toBe(false);
expect(resolveAdConsent(undefined).gdprApplies).toBe(false);
});

it('applies GDPR for an in-scope user who has not consented', () => {
expect(resolveAdConsent(true)).toEqual({ gdprApplies: true });
});

it('applies GDPR for an in-scope user who has not consented', () => {
expect(resolveAdConsent(true).gdprApplies).toBe(true);
it('treats an in-scope user who accepted marketing cookies as consented', () => {
document.cookie = 'ilikecookies_marketing=true';
expect(resolveAdConsent(true).gdprApplies).toBe(false);
});
});

it('treats an in-scope user who accepted marketing cookies as consented', () => {
document.cookie = 'ilikecookies_marketing=true';
expect(resolveAdConsent(true).gdprApplies).toBe(false);
describe('with CMP (TCF) data', () => {
it('uses the TCF snapshot, ignoring the legacy cookie', () => {
document.cookie = 'ilikecookies_marketing=true';
expect(
resolveAdConsent(true, {
gdprApplies: true,
tcString: 'tc-string',
addtlConsent: '1~1.2',
}),
).toEqual({
gdprApplies: true,
consentString: 'tc-string',
addtlConsent: '1~1.2',
});
});

it('prefers the TCF gdprApplies over the geo fallback', () => {
expect(resolveAdConsent(true, { gdprApplies: false }).gdprApplies).toBe(
false,
);
});
});
});
33 changes: 26 additions & 7 deletions packages/shared/src/features/monetization/adConsent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@ import { getIubendaConsent } from '../../lib/iubenda';
import { getCookies } from '../../lib/cookie';
import { isExtension } from '../../lib/func';
import { GdprConsentKey } from '../../hooks/useCookieBanner';
import type { TcfConsent } from '../../lib/tcf';
import type { AdMacroContext } from './adMacros';

/**
* Resolves ad-measurement consent from the user's first-party cookie choice.
* Accepted marketing cookies or outside GDPR scope → `gdpr=0` and measurement
* proceeds; in scope without consent → `gdpr=1`, which tags treat as
* not-consented. The extension is treated as consented (accepted on install),
* Whether the user granted marketing consent through the first-party cookie
* flow. The extension is treated as consented (accepted on install),
* mirroring `useConsentCookie`.
*/
export const hasMarketingConsent = (): boolean => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why was this removed, it was added as compatibility layer between our consent (when we did not have iubenda), so it should stay until we deploy it to everyone without feature flag

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — restored in d063964. resolveAdConsent now only uses TCF semantics when actual TCF data exists (CMP loaded and answered); otherwise it falls back to the exact pre-PR behavior via hasMarketingConsent() (first-party marketing cookie or extension → gdpr=0). So flag-off users and the extension keep their current measurement untouched during the transition, and the compatibility layer can be deleted once the flag is rolled out to everyone.

Expand All @@ -24,6 +23,26 @@ export const hasMarketingConsent = (): boolean => {
return !!getCookies([key])?.[key];
};

export const resolveAdConsent = (isGdprCovered?: boolean): AdMacroContext => ({
gdprApplies: !!isGdprCovered && !hasMarketingConsent(),
});
/**
* Consent context for ad tracker macros. With CMP (TCF) data, `gdprApplies`
* states whether GDPR applies to this user and the TC string carries the
* actual consent. Without it (CMP flag off, extension), the pre-TCF
* compatibility behavior applies: first-party marketing consent or being out
* of GDPR scope → `gdpr=0` and measurement proceeds; in scope without
* consent → `gdpr=1`, which tags treat as not-consented. Keep the fallback
* until the CMP is rolled out to everyone.
*/
export const resolveAdConsent = (
isGdprCovered?: boolean,
tcf?: TcfConsent,
): AdMacroContext => {
if (typeof tcf?.gdprApplies !== 'undefined' || tcf?.tcString) {
return {
gdprApplies: tcf.gdprApplies,
consentString: tcf.tcString,
addtlConsent: tcf.addtlConsent,
};
}

return { gdprApplies: !!isGdprCovered && !hasMarketingConsent() };
};
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,11 @@ export interface MeasurementInitMessage {
type: 'init';
tags: AdMeasurementTag[];
// Resolved by the parent from the user's consent state; when true the frame
// emits gdpr=1 and the tags skip measurement.
// emits gdpr=1 and the tags skip measurement unless a consent string grants
// it. The extension parent never sets the strings (no CMP there).
gdprApplies?: boolean;
consentString?: string;
addtlConsent?: string;
}

export const isMeasurementReadyMessage = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,20 @@ import { useMemo } from 'react';
import { useAuthContext } from '../../contexts/AuthContext';
import type { AdMacroContext } from './adMacros';
import { resolveAdConsent } from './adConsent';
import { useTcfConsent } from './useTcfConsent';

/**
* Resolves the consent context used to fill ad tracker macros. Shared by
* `AdPixel` and `AdMeasurement`. Returns null while disabled (i.e. the ad
* isn't near the viewport yet).
* isn't near the viewport yet). Recomputes when the CMP reports a consent
* change.
*/
export const useAdMacroContext = (enabled: boolean): AdMacroContext | null => {
const { isGdprCovered } = useAuthContext();
const tcf = useTcfConsent();

return useMemo(
() => (enabled ? resolveAdConsent(isGdprCovered) : null),
[enabled, isGdprCovered],
() => (enabled ? resolveAdConsent(isGdprCovered, tcf) : null),
[enabled, isGdprCovered, tcf],
);
};
17 changes: 14 additions & 3 deletions packages/shared/src/features/monetization/useAdQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import { useFeature } from '../../components/GrowthBookProvider';
import type { Ad } from '../../graphql/posts';
import { fetchAdByPlacement, resolveAdFetchOptions } from '../../lib/ads';
import type { FetchAdByPlacementOptions } from '../../lib/ads';
import { featurePostBoostAds } from '../../lib/featureManagement';
import {
featureIubendaCmp,
featurePostBoostAds,
} from '../../lib/featureManagement';
import { useAdMacroContext } from './useAdMacroContext';

interface UseAdQueryOptions {
queryKey: QueryKey;
Expand All @@ -23,18 +27,25 @@ export const useAdQuery = ({
active,
}: UseAdQueryOptions) => {
const boostsEnabled = useFeature(featurePostBoostAds);
const cmpEnabled = useFeature(featureIubendaCmp);
const macroContext = useAdMacroContext(enabled);
// consent params ride on ad requests only for CMP users; with the flag off
// the request is byte-identical to the pre-CMP one
const consent = cmpEnabled ? macroContext ?? undefined : undefined;
const fetchOptions = useMemo(
() =>
resolveAdFetchOptions({
placement,
active,
boostsEnabled,
consent,
}),
[placement, active, boostsEnabled],
[placement, active, boostsEnabled, consent],
);

return useQuery<Ad | null>({
queryKey,
// consent fingerprint so ads refetch when the user answers the CMP banner
queryKey: [...queryKey, consent?.gdprApplies, consent?.consentString ?? ''],
queryFn: () => fetchAdByPlacement(fetchOptions),
enabled,
staleTime,
Expand Down
14 changes: 12 additions & 2 deletions packages/shared/src/features/monetization/useFetchAd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import {
fetchAdByPlacement,
resolveAdFetchOptions,
} from '../../lib/ads';
import { featurePostBoostAds } from '../../lib/featureManagement';
import {
featureIubendaCmp,
featurePostBoostAds,
} from '../../lib/featureManagement';
import { useAdMacroContext } from './useAdMacroContext';

interface UseFetchAds {
fetchAd: (params: {
Expand All @@ -17,6 +21,11 @@ interface UseFetchAds {

export const useFetchAd = (): UseFetchAds => {
const boostsEnabled = useFeature(featurePostBoostAds);
const cmpEnabled = useFeature(featureIubendaCmp);
const macroContext = useAdMacroContext(true);
// consent params ride on ad requests only for CMP users; with the flag off
// the request is byte-identical to the pre-CMP one
const consent = cmpEnabled ? macroContext ?? undefined : undefined;

const fetchAdQuery: UseFetchAds['fetchAd'] = useCallback(
({ active, placement = AdPlacement.Feed }) => {
Expand All @@ -25,10 +34,11 @@ export const useFetchAd = (): UseFetchAds => {
placement,
active,
boostsEnabled,
consent,
}),
);
},
[boostsEnabled],
[boostsEnabled, consent],
);

return {
Expand Down
12 changes: 12 additions & 0 deletions packages/shared/src/features/monetization/useTcfConsent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { useSyncExternalStore } from 'react';
import type { TcfConsent } from '../../lib/tcf';
import { getTcfSnapshot, subscribeTcf } from '../../lib/tcf';

const getServerSnapshot = (): TcfConsent | undefined => undefined;

/**
* Reactive view over the CMP's TCF consent state. Undefined until the CMP
* loads and the user's consent is known (extension and SSR stay undefined).
*/
export const useTcfConsent = (): TcfConsent | undefined =>
useSyncExternalStore(subscribeTcf, getTcfSnapshot, getServerSnapshot);
2 changes: 2 additions & 0 deletions packages/shared/src/hooks/log/useLogQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export interface LogEvent extends Record<string, unknown> {
extra?: string;
device_id?: string;
cookies?: string;
// iubenda consent-record correlator (see getIubendaConsent)
consent_id?: string;
}

export type PushToQueueFunc = (events: LogEvent[]) => void;
Expand Down
5 changes: 5 additions & 0 deletions packages/shared/src/hooks/log/useLogSharedProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import type { LogEvent } from './useLogQueue';
import SettingsContext from '../../contexts/SettingsContext';
import AuthContext from '../../contexts/AuthContext';
import { getCookies } from '../../lib/cookie';
import { getIubendaConsent } from '../../lib/iubenda';
import { useFeature } from '../../components/GrowthBookProvider';
import { featureIubendaCmp } from '../../lib/featureManagement';

const COOKIES = ['_ga', '_fbp', '_fbc', 'gbuuid'];

Expand All @@ -18,6 +21,7 @@ export default function useLogSharedProps(
const { query } = useRouter();
const { themeMode, spaciness, insaneMode } = useContext(SettingsContext);
const { visit, anonymous, tokenRefreshed, user } = useContext(AuthContext);
const iubendaCmp = useFeature(featureIubendaCmp);
const [sharedPropsSet, setSharedPropsSet] = useState(false);

const [visitId, setVisitId] = useState<string>();
Expand Down Expand Up @@ -76,6 +80,7 @@ export default function useLogSharedProps(
visit_id: visitId,
device_id: _deviceId,
cookies: cookies === '{}' ? undefined : cookies,
consent_id: iubendaCmp ? getIubendaConsent()?.consentId : undefined,
};
setSharedPropsSet(true);
});
Expand Down
28 changes: 28 additions & 0 deletions packages/shared/src/hooks/useCookieBanner.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useConsentCookie } from './useCookieConsent';
import { useAuthContext } from '../contexts/AuthContext';
import { getIubendaConsent } from '../lib/iubenda';
import { isIOSNative } from '../lib/func';
import { useFeature } from '../components/GrowthBookProvider';

jest.mock('./useCookieConsent', () => ({
useConsentCookie: jest.fn(),
Expand All @@ -26,6 +27,10 @@ jest.mock('../lib/func', () => ({
isIOSNative: jest.fn(),
}));

jest.mock('../components/GrowthBookProvider', () => ({
useFeature: jest.fn(),
}));

const mockUseConsentCookie = useConsentCookie as jest.MockedFunction<
typeof useConsentCookie
>;
Expand All @@ -36,6 +41,7 @@ const mockGetIubendaConsent = getIubendaConsent as jest.MockedFunction<
typeof getIubendaConsent
>;
const mockIsIOSNative = isIOSNative as jest.MockedFunction<typeof isIOSNative>;
const mockUseFeature = useFeature as jest.MockedFunction<typeof useFeature>;

const saveCookies = jest.fn();

Expand Down Expand Up @@ -65,6 +71,7 @@ describe('useCookieBanner', () => {
beforeEach(() => {
jest.clearAllMocks();
mockIsIOSNative.mockReturnValue(false);
mockUseFeature.mockReturnValue(false);
localStorage.clear();
});

Expand Down Expand Up @@ -134,6 +141,27 @@ describe('useCookieBanner', () => {
expect(localStorage.getItem('cookie_acknowledged')).toBe('true');
});

it('suppresses the homegrown banner for GDPR users when the iubenda CMP is on', () => {
setup({ isGdprCovered: true, user: null });
mockUseFeature.mockReturnValue(true);
mockGetIubendaConsent.mockReturnValue(undefined);

const { result } = renderHook(() => useCookieBanner());

expect(saveCookies).not.toHaveBeenCalled();
expect(result.current.showBanner).toBe(false);
});

it('keeps the homegrown banner for non-GDPR users when the iubenda CMP is on', () => {
setup({ isGdprCovered: false, user: null });
mockUseFeature.mockReturnValue(true);
mockGetIubendaConsent.mockReturnValue(undefined);

const { result } = renderHook(() => useCookieBanner());

expect(result.current.showBanner).toBe(true);
});

it('uses the marketing consent key for the additional cookie', () => {
expect(otherGdprConsents).toEqual([GdprConsentKey.Marketing]);
});
Expand Down
Loading
Loading