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
10 changes: 7 additions & 3 deletions packages/shared/src/components/Feed.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,9 @@ function renderComponent(
const resolvedUser = arguments.length < 2 ? defaultUser : user;

mocks.forEach(mockGraphQL);
nock('http://localhost:3000').get('/v1/a?active=false').reply(200, [ad]);
nock('http://localhost:3000')
.get('/v1/a?active=false&gdpr=0')
.reply(200, [ad]);
const settingsContext: SettingsContextData = {
...baseSettingsContext,
setTheme: jest.fn(),
Expand Down Expand Up @@ -1856,9 +1858,11 @@ const renderWithHighlightLayout = ({
mockGraphQL(createFeedMock(buildFeedPage(posts)));
// First ad page uses active=false, subsequent pages use active=true. Mock
// both up to a handful of refills so multi-ad scenarios don't run dry.
nock('http://localhost:3000').get('/v1/a?active=false').reply(200, [ad]);
nock('http://localhost:3000')
.get('/v1/a?active=true')
.get('/v1/a?active=false&gdpr=0')
.reply(200, [ad]);
nock('http://localhost:3000')
.get('/v1/a?active=true&gdpr=0')
.times(10)
.reply(200, [ad]);

Expand Down
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
45 changes: 29 additions & 16 deletions packages/shared/src/features/monetization/adConsent.spec.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,40 @@
import { resolveAdConsent } from './adConsent';

const clearCookies = () => {
document.cookie.split(';').forEach((c) => {
const name = c.split('=')[0].trim();
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT`;
});
};

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(false)).toEqual({
gdprApplies: false,
consentString: undefined,
addtlConsent: undefined,
});
expect(resolveAdConsent(undefined).gdprApplies).toBe(false);
});

it('applies GDPR for an in-scope user who has not consented', () => {
expect(resolveAdConsent(true).gdprApplies).toBe(true);
it('applies GDPR without a consent string for an in-scope user before the CMP answers', () => {
expect(resolveAdConsent(true)).toEqual({
gdprApplies: true,
consentString: undefined,
addtlConsent: undefined,
});
});

it('uses the TCF snapshot when available', () => {
expect(
resolveAdConsent(true, {
gdprApplies: true,
tcString: 'tc-string',
addtlConsent: '1~1.2',
}),
).toEqual({
gdprApplies: true,
consentString: 'tc-string',
addtlConsent: '1~1.2',
});
});

it('treats an in-scope user who accepted marketing cookies as consented', () => {
document.cookie = 'ilikecookies_marketing=true';
expect(resolveAdConsent(true).gdprApplies).toBe(false);
it('prefers the TCF gdprApplies over the geo fallback', () => {
expect(resolveAdConsent(true, { gdprApplies: false }).gdprApplies).toBe(
false,
);
});
});
36 changes: 12 additions & 24 deletions packages/shared/src/features/monetization/adConsent.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,17 @@
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),
* mirroring `useConsentCookie`.
* Consent context for ad tracker macros. `gdprApplies` states whether GDPR
* applies to this user (regardless of their choice); the TC string carries
* the actual consent. In scope without a TC string, vendors must treat the
* request as not-consented.
*/
export const hasMarketingConsent = (): boolean => {
if (isExtension) {
return true;
}

if (getIubendaConsent()?.marketing) {
return true;
}

const key = GdprConsentKey.Marketing;
return !!getCookies([key])?.[key];
};

export const resolveAdConsent = (isGdprCovered?: boolean): AdMacroContext => ({
gdprApplies: !!isGdprCovered && !hasMarketingConsent(),
export const resolveAdConsent = (
isGdprCovered?: boolean,
tcf?: TcfConsent,
): AdMacroContext => ({
gdprApplies: tcf?.gdprApplies ?? !!isGdprCovered,
consentString: tcf?.tcString,
addtlConsent: tcf?.addtlConsent,
});
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],
);
};
8 changes: 6 additions & 2 deletions packages/shared/src/features/monetization/useAdQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { Ad } from '../../graphql/posts';
import { fetchAdByPlacement, resolveAdFetchOptions } from '../../lib/ads';
import type { FetchAdByPlacementOptions } from '../../lib/ads';
import { featurePostBoostAds } from '../../lib/featureManagement';
import { useAdMacroContext } from './useAdMacroContext';

interface UseAdQueryOptions {
queryKey: QueryKey;
Expand All @@ -23,18 +24,21 @@ export const useAdQuery = ({
active,
}: UseAdQueryOptions) => {
const boostsEnabled = useFeature(featurePostBoostAds);
const consent = useAdMacroContext(enabled);
const fetchOptions = useMemo(
() =>
resolveAdFetchOptions({
placement,
active,
boostsEnabled,
consent: consent ?? undefined,
}),
[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
5 changes: 4 additions & 1 deletion packages/shared/src/features/monetization/useFetchAd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
resolveAdFetchOptions,
} from '../../lib/ads';
import { featurePostBoostAds } from '../../lib/featureManagement';
import { useAdMacroContext } from './useAdMacroContext';

interface UseFetchAds {
fetchAd: (params: {
Expand All @@ -17,6 +18,7 @@ interface UseFetchAds {

export const useFetchAd = (): UseFetchAds => {
const boostsEnabled = useFeature(featurePostBoostAds);
const consent = useAdMacroContext(true);

const fetchAdQuery: UseFetchAds['fetchAd'] = useCallback(
({ active, placement = AdPlacement.Feed }) => {
Expand All @@ -25,10 +27,11 @@ export const useFetchAd = (): UseFetchAds => {
placement,
active,
boostsEnabled,
consent: consent ?? undefined,
}),
);
},
[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
2 changes: 2 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,7 @@ 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';

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

Expand Down Expand Up @@ -76,6 +77,7 @@ export default function useLogSharedProps(
visit_id: visitId,
device_id: _deviceId,
cookies: cookies === '{}' ? undefined : cookies,
consent_id: getIubendaConsent()?.consentId,
};
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