diff --git a/src/YoutubeAtom.stories.tsx b/src/YoutubeAtom.stories.tsx index b5c4c7f9..e8f400c9 100644 --- a/src/YoutubeAtom.stories.tsx +++ b/src/YoutubeAtom.stories.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { css } from '@emotion/react'; import { YoutubeAtom } from './YoutubeAtom'; @@ -9,7 +9,7 @@ export default { component: YoutubeAtom, }; -export const DefaultStory = (): JSX.Element => { +export const NoConsent = (): JSX.Element => { return (
{ ]} duration={252} pillar={ArticlePillar.Culture} + height={450} + width={800} + /> +
+ ); +}; + +export const NoOverlay = (): JSX.Element => { + return ( +
+ console.log(`analytics event ${e} called`), + ]} + consentState={{}} + duration={252} + pillar={ArticlePillar.Culture} + height={450} + width={800} />
); @@ -47,6 +74,7 @@ export const WithOverrideImage = (): JSX.Element => { (e) => console.log(`analytics event ${e} called`), ]} duration={252} + consentState={{}} pillar={ArticlePillar.News} overrideImage={[ { @@ -81,6 +109,7 @@ export const WithPosterImage = (): JSX.Element => { ]} pillar={ArticlePillar.Sport} duration={252} + consentState={{}} posterImage={[ { srcSet: [ @@ -107,6 +136,8 @@ export const WithPosterImage = (): JSX.Element => { ], }, ]} + height={450} + width={800} /> ); @@ -140,6 +171,7 @@ export const WithOverlayAndPosterImage = (): JSX.Element => { ], }, ]} + consentState={{}} posterImage={[ { srcSet: [ @@ -166,7 +198,49 @@ export const WithOverlayAndPosterImage = (): JSX.Element => { ], }, ]} + height={450} + width={800} /> ); }; + +export const GiveConsent = (): JSX.Element => { + const [consented, setConsented] = useState(false); + return ( + <> + +
+ console.log(`analytics event ${e} called`), + ]} + consentState={consented ? {} : undefined} + duration={252} + pillar={ArticlePillar.News} + overrideImage={[ + { + srcSet: [ + { + width: 500, + src: + 'https://i.guim.co.uk/img/media/4b3808707ec341629932a9d443ff5a812cf4df14/0_309_1800_1081/master/1800.jpg?width=1200&height=630&quality=85&auto=format&fit=crop&overlay-align=bottom%2Cleft&overlay-width=100p&overlay-base64=L2ltZy9zdGF0aWMvb3ZlcmxheXMvdGctZGVmYXVsdC5wbmc&enable=upscale&s=aff4b8255693eb449f13070df88e9cac', + }, + ], + }, + ]} + height={450} + width={800} + /> +
+ + ); +}; diff --git a/src/YoutubeAtom.test.tsx b/src/YoutubeAtom.test.tsx index 6a1c7a03..904e7da7 100644 --- a/src/YoutubeAtom.test.tsx +++ b/src/YoutubeAtom.test.tsx @@ -3,12 +3,25 @@ import '@testing-library/jest-dom/extend-expect'; import { render } from '@testing-library/react'; import { YoutubeAtom } from './YoutubeAtom'; +import { AdTargeting } from './types'; const disabledAdsEmbedConfig = 'embed_config=%7B%22adsConfig%22%3A%7B%22disableAds%22%3Atrue%7D%7D'; +const overlayImage = [ + { + srcSet: [ + { + width: 500, + src: + 'https://i.guim.co.uk/img/media/4b3808707ec341629932a9d443ff5a812cf4df14/0_309_1800_1081/master/1800.jpg?width=1200&height=630&quality=85&auto=format&fit=crop&overlay-align=bottom%2Cleft&overlay-width=100p&overlay-base64=L2ltZy9zdGF0aWMvb3ZlcmxheXMvdGctZGVmYXVsdC5wbmc&enable=upscale&s=aff4b8255693eb449f13070df88e9cac', + }, + ], + }, +]; + describe('YoutubeAtom', () => { - it('should render', async () => { + it('should directly render the youtube iframe if no overlay provided', async () => { const atom = ( { alt="" role="inline" eventEmitters={[]} + consentState={{}} pillar={0} /> ); @@ -25,92 +39,157 @@ describe('YoutubeAtom', () => { expect(iframe).toBeInTheDocument(); }); - it.each([ - { - msg: - 'should render an iframe with ads disabled when passed no ad targeting', - adTargeting: undefined, - consentState: undefined, - expectedPartialEmbedStrings: [disabledAdsEmbedConfig], - }, - { - msg: - 'should render an iframe with ads disabled when passed ad targeting with disabledAds flag true', - adTargeting: { - disableAds: true, - adUnit: 'someAdUnit', - customParams: { - param1: 'param1', - param2: 'param2', - }, - }, - consentState: undefined, - expectedPartialEmbedStrings: [disabledAdsEmbedConfig], - }, - { - msg: - 'should render an iframe with ads disabled when passed ad targeting but no consent state', - adTargeting: { - disableAds: false, - adUnit: 'someAdUnit', - customParams: { - param1: 'param1', - param2: 'param2', - }, - }, - consentState: undefined, - expectedPartialEmbedStrings: [disabledAdsEmbedConfig], - }, - { - msg: - 'should render an iframe with ad targeting when passed ad targeting and consent state', - adTargeting: { - disableAds: false, - adUnit: 'someAdUnit', - customParams: { - param1: 'param1', - param2: 'param2', - }, - }, - consentState: { - aus: { - personalisedAdvertising: true, - }, - }, - expectedPartialEmbedStrings: [ - // assert cust_params value is double uriComponent encoded e.g. % => %25 - // as per existing Frontend behaviour + it('should not render the youtube iframe if no consent state is given', async () => { + const atom = ( + + ); + const { queryByTitle } = render(atom); + + const iframe = (await queryByTitle( + 'My Youtube video!', + )) as HTMLIFrameElement; + expect(iframe).not.toBeInTheDocument(); + }); + + it('should not render the youtube iframe if an overlay is provided', async () => { + const atom = ( + + ); + const { queryByTitle } = render(atom); + + const iframe = queryByTitle('My Youtube video!'); + expect(iframe).not.toBeInTheDocument(); + }); + + it('should show a placeholder if both overlay and consent are missing', async () => { + const atom = ( + + ); + const { queryByTitle, getByTestId } = render(atom); + + const iframe = queryByTitle('My Youtube video!'); + expect(iframe).not.toBeInTheDocument(); + const placeholder = getByTestId('placeholder'); + expect(placeholder).toBeInTheDocument(); + }); + + it('should render an iframe with ads disabled when passed no ad targeting', async () => { + const atom = ( + + ); + const { findByTitle } = render(atom); + + const iframe = (await findByTitle( + 'My Youtube video!', + )) as HTMLIFrameElement; + expect(iframe).toBeInTheDocument(); + expect(iframe.src.includes(disabledAdsEmbedConfig)).toBe(true); + }); + + it('should render an iframe with ads disabled when passed ad targeting with disabledAds flag true', async () => { + const atom = ( + + ); + const { findByTitle } = render(atom); + + const iframe = (await findByTitle( + 'My Youtube video!', + )) as HTMLIFrameElement; + expect(iframe).toBeInTheDocument(); + expect(iframe.src.includes(disabledAdsEmbedConfig)).toBe(true); + }); + + it('should render an iframe with ad targeting when passed ad targeting and consent state', async () => { + const atom = ( + + ); + const { findByTitle } = render(atom); + + const iframe = (await findByTitle( + 'My Youtube video!', + )) as HTMLIFrameElement; + expect(iframe).toBeInTheDocument(); + // assert cust_params value is double uriComponent encoded e.g. % => %25 + // as per existing Frontend behaviour + expect( + iframe.src.includes( '%22cust_params%22%3A%22param1%253Dparam1%2526param2%253Dparam2', - // encoded consent state - '%22restrictedDataProcessor%22%3Afalse', - ], - }, - ])( - '$msg', - async ({ adTargeting, consentState, expectedPartialEmbedStrings }) => { - const atom = ( - - ); - const { findByTitle } = render(atom); - - const iframe = (await findByTitle( - 'My Youtube video!', - )) as HTMLIFrameElement; - expect(iframe).toBeInTheDocument(); - expectedPartialEmbedStrings.forEach((expectedPartialEmbedString) => - expect(iframe.src.includes(expectedPartialEmbedString)).toBe( - true, - ), - ); - }, - ); + ), + ).toBe(true); + // encoded consent state + expect( + iframe.src.includes('%22restrictedDataProcessor%22%3Afalse'), + ).toBe(true); + }); }); diff --git a/src/YoutubeAtom.tsx b/src/YoutubeAtom.tsx index de120e07..35b26aaa 100644 --- a/src/YoutubeAtom.tsx +++ b/src/YoutubeAtom.tsx @@ -12,6 +12,7 @@ import { import { SvgPlay } from '@guardian/source-react-components'; import { MaintainAspectRatio } from './common/MaintainAspectRatio'; +import { Placeholder } from './common/Placeholder'; import { formatTime } from './lib/formatTime'; import { Picture } from './Picture'; import { AdTargeting, ImageSource, RoleType } from './types'; @@ -164,11 +165,60 @@ export const YoutubeAtom = ({ const [hasUserLaunchedPlay, setHasUserLaunchedPlay] = useState( false, ); + const [hasUserHovered, setHasUserHovered] = useState(false); const player = useRef(); + const hasOverlay = overrideImage || posterImage; + + /** + * Show the overlay if: + * - It exists + * + * and + * + * - It hasn't been clicked upon + */ + const showOverlay = hasOverlay && !hasUserLaunchedPlay; + /** + * Show a placeholder if: + * + * - We don't have an iframe source yet (probably because we don't have consent) + * + * and + * + * - There's no overlay to replace it with or the reader clicked to play but we're + * still waiting on consent + * + */ + const showPlaceholder = !iframeSrc && (!hasOverlay || hasUserLaunchedPlay); + /** + * Load the you tube iframe if: + * + * - We have a source string defined (i.e. We have consent) + * + * and + * + * - One of these 3 things are true + * - We don't have an overlay - so we have to load the video straight away + * - The user has clicked on the overlay, so load the video iframe! + * - The user has moved their mouse over the overlay so lets pre load + * the content + */ + const loadIframe = + iframeSrc && (!hasOverlay || hasUserHovered || hasUserLaunchedPlay); + useEffect(() => { - // Set the iframe client side after hydration - // This is so we can dynamically build adsConfig using client side data (primarily consent) + /** + * Build the iframe source url + * + * We do this on the client following hydration so we can dynamically build + * adsConfig using client side data (primarily consent) + * + */ + + // We don't want to ever load the iframe until we know the reader's consent preferences + if (!consentState) return; + const adsConfig: AdsConfig = !adTargeting || adTargeting.disableAds ? disabledAds @@ -176,19 +226,26 @@ export const YoutubeAtom = ({ false, adTargeting.adUnit, adTargeting.customParams, - consentState || {}, + consentState, ); const embedConfig = encodeURIComponent(JSON.stringify({ adsConfig })); const originString = origin ? `&origin=${encodeURIComponent(origin)}` : ''; + // `autoplay`? + // We don't typically autoplay videos but in this case, where we know the reader has + // already clicked to play, we use this param to ensure the video plays. Why would it + // not play? Because when a reader clicks, we call player.current.playVideo() but at + // that point the video may not have loaded and the click event won't work. Autoplay + // is a failsafe for this scenario. + const autoplay = hasUserLaunchedPlay ? '&autoplay=1' : ''; setIframeSrc( - `https://www.youtube.com/embed/${assetId}?embed_config=${embedConfig}&enablejsapi=1&widgetid=1&modestbranding=1${originString}`, + `https://www.youtube.com/embed/${assetId}?embed_config=${embedConfig}&enablejsapi=1&widgetid=1&modestbranding=1${originString}${autoplay}`, ); - }, []); + }, [consentState, hasUserLaunchedPlay]); useEffect(() => { - if (iframeSrc) { + if (loadIframe) { if (!player.current) { player.current = YouTubePlayer(`youtube-video-${assetId}`); } @@ -279,11 +336,29 @@ export const YoutubeAtom = ({ listener && player.current && player.current.off(listener); }; } - }, [eventEmitters, iframeSrc]); + }, [eventEmitters, loadIframe]); return ( - {iframeSrc && ( + {showPlaceholder && ( +
+ +
+ )} + + {loadIframe && (