diff --git a/media/js/base/consent/utils.es6.js b/media/js/base/consent/utils.es6.js index b5d8f99b9..f2633445c 100644 --- a/media/js/base/consent/utils.es6.js +++ b/media/js/base/consent/utils.es6.js @@ -5,6 +5,7 @@ */ import MozAllowList from './allow-list.es6'; +import DownloadAttribution from '../download-attribution/download-attribution.es6'; const COOKIE_ID = 'moz-consent-pref'; // Cookie name const COOKIE_EXPIRY_DAYS = 182; // 6 months expiry @@ -44,6 +45,13 @@ function setGtagAdsConsentMode(hasConsent, type = 'update') { * @returns {Boolean} */ function setGtagAnalyticsConsentMode(hasConsent, type = 'update') { + if (attributionRefactorEnabled()) { + // This must run before the gtag check to ensure we always update + // download analytics regardless of whether GTM has loaded on the page + // i.e. cookie settings page + setDownloadAttribution(hasConsent); + } + // bail out if GTAG has not been created with GTMSnippet.loadSnippet // this needs to run before GTM snippet loads to set proper defaults if (typeof window.gtag === 'undefined') { @@ -61,6 +69,29 @@ function setGtagAnalyticsConsentMode(hasConsent, type = 'update') { return true; } +/** + * Sets Mozilla Download Attribution analytics + * @param {Boolean} hasConsent - based on GTAG analytics consent + * @returns {Boolean} + */ +function setDownloadAttribution(hasConsent) { + DownloadAttribution.initAnalytics(hasConsent); + + return true; +} + +/** + * Determines if the download attribution refactor is active. + * Looks for a data attribute on the tag. + */ +function attributionRefactorEnabled() { + const attr = document + .getElementsByTagName('html')[0] + .getAttribute('data-attribution-refactor-enabled'); + + return attr ? attr.toLowerCase() === 'true' : false; +} + /** * Determines if the current page requires consent. * Looks for a data attribute on the tag. diff --git a/media/js/base/download-attribution/download-attribution-init.es6.js b/media/js/base/download-attribution/download-attribution-init.es6.js new file mode 100644 index 000000000..f423105da --- /dev/null +++ b/media/js/base/download-attribution/download-attribution-init.es6.js @@ -0,0 +1,14 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import DownloadAttribution from './download-attribution.es6'; + +// Refresh the essential data to avoid an outdated download experience +// - If there's new essential data, update +// - If there's no essential data, remove +DownloadAttribution.initEssential(); + +DownloadAttribution.applyAttributionDataToLinks(); diff --git a/media/js/base/download-attribution/download-attribution.es6.js b/media/js/base/download-attribution/download-attribution.es6.js new file mode 100644 index 000000000..57d43a4cf --- /dev/null +++ b/media/js/base/download-attribution/download-attribution.es6.js @@ -0,0 +1,881 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +window.dataLayer = window.dataLayer || []; + +// Create namespace +if (typeof window.Mozilla === 'undefined') { + window.Mozilla = {}; +} + +/** + * Constructs attribution data based on utm parameters, referrer information, and + * essential product context / install options for relay to the Firefox stub installer. + * Data is first signed and encoded via an XHR request to the `stub_attribution_code` service, + * before being appended to Bouncer download URLs as query parameters. Data returned from the + * service is also stored in a cookie to save multiple requests when navigating pages. + * Original Bug https://bugzilla.mozilla.org/show_bug.cgi?id=1279291 + * + * In 2026, this file was renamed from Stub Attribution to Download Attribution to more + * clearly indicate this is information provided through the download. References to the Stub + * Attribution Service remain as the website code connects to an external service that uses + * this name: https://github.com/mozilla-services/stubattribution. + * Refactor task: https://mozilla-hub.atlassian.net/browse/WT-964 + * + * Essential and analytics attribution are driven by independent triggers and know + * nothing about each other: + * - Essential (rtamo, download_as_default, smart_window) is required for functional + * post-download behavior. It runs without consent gating or sample-rate limiting, + * triggered by a `data-stub-attribution-campaign-force` attribute on the current page. + * - Analytics is consent-gated and sample-rated, triggered by a `gtm-analytics-consent` + * event dispatched from GTM. + * Each trigger reads the other's last-captured raw data from a side cookie so it can + * re-sign the combined payload without the other trigger having fired on this page. + */ +const DownloadAttribution = { + COOKIE_CODE_ID: 'moz-download-attribution-code', + COOKIE_SIGNATURE_ID: 'moz-download-attribution-sig', + COOKIE_ESSENTIAL_RAW_ID: 'moz-download-attribution-essential-raw', + COOKIE_ANALYTICS_RAW_ID: 'moz-download-attribution-analytics-raw', + DLSOURCE: 'fxdotcom', + ESSENTIAL_CAMPAIGNS: ['rtamo', 'SET_AS_DEFAULT', 'smart_window'], + + /** + * Custom event handler callback globals. These can be defined as functions when + * calling DownloadAttribution.initEssential() or .initAnalytics(). + */ + successCallback: undefined, + timeoutCallback: undefined, + requestComplete: false, + + /** + * Determines if session falls within the predefined download attribution sample rate. + * @return {Boolean}. + */ + withinAttributionRate: () => { + return Math.random() < DownloadAttribution.getAttributionRate(); + }, + + /** + * Returns download attribution value used for rate limiting. + * @return {Number} float between 0 and 1. + */ + getAttributionRate: () => { + const rate = document + .getElementsByTagName('html')[0] + .getAttribute('data-stub-attribution-rate'); + return isNaN(rate) || !rate + ? 0 + : Math.min(Math.max(parseFloat(rate), 0), 1); + }, + + /** + * Returns true if both signed cookies exist. + * @return {Boolean} data. + */ + hasSignedCookie: () => { + return ( + Mozilla.Cookies.hasItem(DownloadAttribution.COOKIE_CODE_ID) && + Mozilla.Cookies.hasItem(DownloadAttribution.COOKIE_SIGNATURE_ID) + ); + }, + + /** + * Stores the signed download attribution data values. + * @param {Object} data - attribution_code, attribution_sig. + */ + setSignedCookie: (data) => { + if (!data.attribution_code || !data.attribution_sig) { + return; + } + + // set cookie to expire in 24 hours + const date = new Date(); + date.setTime(date.getTime() + 1 * 24 * 60 * 60 * 1000); + const expires = date.toUTCString(); + + Mozilla.Cookies.setItem( + DownloadAttribution.COOKIE_CODE_ID, + data.attribution_code, + expires, + '/', + undefined, + false, + 'lax' + ); + Mozilla.Cookies.setItem( + DownloadAttribution.COOKIE_SIGNATURE_ID, + data.attribution_sig, + expires, + '/', + undefined, + false, + 'lax' + ); + }, + + /** + * Removes the signed download attribution cookies. + */ + removeSignedCookie: () => { + window.Mozilla.Cookies.removeItem( + DownloadAttribution.COOKIE_CODE_ID, + '/', + undefined, + false, + 'lax' + ); + + window.Mozilla.Cookies.removeItem( + DownloadAttribution.COOKIE_SIGNATURE_ID, + '/', + undefined, + false, + 'lax' + ); + }, + + /** + * Gets signed download attribution data from cookie. + * @return {Object} - attribution_code, attribution_sig. + */ + getSignedCookie: () => { + return { + attribution_code: Mozilla.Cookies.getItem( + DownloadAttribution.COOKIE_CODE_ID + ), + attribution_sig: Mozilla.Cookies.getItem( + DownloadAttribution.COOKIE_SIGNATURE_ID + ) + }; + }, + + /** + * Stores a raw attribution data object as a JSON-encoded cookie. + * Raw cookies preserve the inputs used to build the signed payload so + * either trigger (essential or analytics) can re-sign the combined + * payload without the other having fired on the current page. + * @param {String} id - COOKIE_ESSENTIAL_RAW_ID or COOKIE_ANALYTICS_RAW_ID. + * @param {Object} data - Raw attribution data to preserve. + */ + setRawCookie: (id, data) => { + const date = new Date(); + date.setTime(date.getTime() + 1 * 24 * 60 * 60 * 1000); + const expires = date.toUTCString(); + + Mozilla.Cookies.setItem( + id, + JSON.stringify(data), + expires, + '/', + undefined, + false, + 'lax' + ); + }, + + /** + * Gets a raw attribution data object from cookie. + * @param {String} id - Cookie id. + * @return {Object | null} - Parsed data, or null if missing or unparseable. + */ + getRawCookie: (id) => { + const raw = Mozilla.Cookies.getItem(id); + if (!raw) { + return null; + } + try { + return JSON.parse(raw); + } catch (e) { + return null; + } + }, + + /** + * Removes a raw attribution data cookie. + * @param {String} id - Cookie id. + */ + removeRawCookie: (id) => { + window.Mozilla.Cookies.removeItem(id, '/', undefined, false, 'lax'); + }, + + /** + * Updates all download links on the page with additional query params for + * download attribution. + * @param {Object} data - attribution_code, attribution_sig. + */ + updateBouncerLinks: (data) => { + /** + * If data is missing or the browser does not meet requirements for + * download attribution, then do nothing. + */ + if ( + !data.attribution_code || + !data.attribution_sig || + !DownloadAttribution.meetsFunctionalRequirements() + ) { + return; + } + + // target download buttons and other-platforms modal links. + const downloadLinks = document.querySelectorAll('.download-link'); + + for (const link of downloadLinks) { + let version; + let directLink; + // Append download attribution data to direct download links. + if ( + link.href && + (link.href.indexOf('https://download.mozilla.org') !== -1 || + link.href.indexOf( + 'https://bouncer-bouncer.stage.mozaws.net/' + ) !== -1 || + link.href.indexOf( + 'https://stage.bouncer.nonprod.webservices.mozgcp.net' + ) !== -1 || + link.href.indexOf( + 'https://dev.bouncer.nonprod.webservices.mozgcp.net' + ) !== -1) + ) { + version = link.getAttribute('data-download-version'); + + // Append attribution params to Windows links. + if (version && /win/.test(version)) { + link.href = DownloadAttribution.appendToDownloadURL( + link.href, + data + ); + } + // Append attribution params to macOS links (excluding ESR for now). + if ( + version && + /osx/.test(version) && + !/product=firefox-esr/.test(link.href) + ) { + link.href = DownloadAttribution.appendToDownloadURL( + link.href, + data + ); + } + } else if (link.href && link.href.indexOf('/thanks/') !== -1) { + // Append download data to direct-link data attributes on transitional links for old IE browsers (Issue #9350) + directLink = link.getAttribute('data-direct-link'); + + if (directLink) { + link.setAttribute( + 'data-direct-link', + DownloadAttribution.appendToDownloadURL( + directLink, + data + ) + ); + } + } + } + }, + + removeLinkAttributionParams: (href) => { + if (href.indexOf('?') > 0) { + const params = new window._SearchParams(href.split('?')[1]); + const origin = href.split('?')[0]; + + if ( + params.has('attribution_code') && + params.has('attribution_sig') + ) { + params.remove('attribution_code'); + params.remove('attribution_sig'); + return ( + origin + '?' + window.decodeURIComponent(params.toString()) + ); + } + } + + return href; + }, + + cleanBouncerLinks: () => { + const downloadLinks = document.querySelectorAll('.download-link'); + + for (const link of downloadLinks) { + link.href = DownloadAttribution.removeLinkAttributionParams( + link.href + ); + + if (link.hasAttribute('data-direct-link')) { + const attribute = + DownloadAttribution.removeLinkAttributionParams( + link.getAttribute('data-direct-link') + ); + link.setAttribute('data-direct-link', attribute); + } + } + }, + + removeAttributionData: () => { + DownloadAttribution.removeSignedCookie(); + DownloadAttribution.removeRawCookie( + DownloadAttribution.COOKIE_ESSENTIAL_RAW_ID + ); + DownloadAttribution.removeRawCookie( + DownloadAttribution.COOKIE_ANALYTICS_RAW_ID + ); + DownloadAttribution.cleanBouncerLinks(); + DownloadAttribution.requestComplete = false; + }, + + /** + * Appends download attribution data as URL parameters. + * Note: data is already URI encoded when returned via the service. + * @param {String} url - URL to append data to. + * @param {Object} data - attribution_code, attribution_sig. + * @return {String} url + additional parameters. + */ + appendToDownloadURL: (url, data) => { + if (!data.attribution_code || !data.attribution_sig) { + return url; + } + + // append download attribution query params. + for (const key of Object.keys(data)) { + if (key === 'attribution_code' || key === 'attribution_sig') { + url += + (url.indexOf('?') > -1 ? '&' : '?') + key + '=' + data[key]; + } + } + + return url; + }, + + /** + * Handles XHR request from `stub_attribution_code` service. + * @param {Object} data - attribution_code, attribution_sig. + */ + onRequestSuccess: (data) => { + if ( + data.attribution_code && + data.attribution_sig && + !DownloadAttribution.requestComplete + ) { + // Update download links on the current page. + DownloadAttribution.updateBouncerLinks(data); + // Store attribution data in a cookie should the user navigate. + DownloadAttribution.setSignedCookie(data); + + DownloadAttribution.requestComplete = true; + + if (typeof DownloadAttribution.successCallback === 'function') { + DownloadAttribution.successCallback(); + } + } + }, + + onRequestTimeout: () => { + if (!DownloadAttribution.requestComplete) { + DownloadAttribution.requestComplete = true; + + if (typeof DownloadAttribution.timeoutCallback === 'function') { + DownloadAttribution.timeoutCallback(); + } + } + }, + + /** + * AJAX request to springfield service to authenticate download attribution request. + * @param {Object} data - utm params and referrer. + */ + requestAuthentication: (data) => { + const SERVICE_URL = + window.location.protocol + + '//' + + window.location.host + + '/en-US/stub_attribution_code/'; + const xhr = new window.XMLHttpRequest(); + const timeoutValue = 10000; + const timeout = setTimeout( + DownloadAttribution.onRequestTimeout, + timeoutValue + ); + + xhr.open( + 'GET', + SERVICE_URL + '?' + window._SearchParams.objectToQueryString(data) + ); + xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); + + // use readystate change over onload for IE8 support. + xhr.onreadystatechange = function () { + if (xhr.readyState === 4) { + const status = xhr.status; + if (status && status >= 200 && status < 400) { + try { + const responseData = JSON.parse(xhr.responseText); + clearTimeout(timeout); + DownloadAttribution.onRequestSuccess(responseData); + } catch (e) { + // something went wrong, fallback to the timeout handler. + DownloadAttribution.onRequestTimeout(); + } + } + } + }; + + // must come after open call above for IE 10 & 11 + xhr.timeout = timeoutValue; + xhr.send(); + }, + + /** + * Returns a browser name based on coarse UA string detection for only major browsers. + * Other browsers (or modified UAs) that have strings that look like one of the top default user agent strings are treated as false positives. + * @param {String} ua - Optional user agent string to facilitate testing. + * @returns {String} - Browser name. + */ + getUserAgent: (ua) => { + ua = typeof ua !== 'undefined' ? ua : navigator.userAgent; + + if (/MSIE|Trident/i.test(ua)) { + return 'ie'; + } + + if (/Edg|Edge/i.test(ua)) { + return 'edge'; + } + + if (/Firefox/.test(ua)) { + return 'firefox'; + } + + if (/Chrome/.test(ua)) { + return 'chrome'; + } + + return 'other'; + }, + + /** + * Attempts to retrieve the GA4 client from the dataLayer + * The GTAG GET API tag will write it to the dataLayer once GTM has loaded it + * https://www.simoahava.com/gtmtips/write-client-id-other-gtag-fields-datalayer/ + */ + getGtagClientID: (dataLayer) => { + // need to pass in dataLayer for testing purposes, use global dataLayer if it's not passed + dataLayer = + typeof dataLayer !== 'undefined' ? dataLayer : window.dataLayer; + + let clientID = null; + + const _findAPI = (obj) => { + for (const key in obj) { + if ( + typeof obj[key] === 'object' && + Object.prototype.hasOwnProperty.call(obj, key) + ) { + if (key === 'gtagApiResult') { + if (typeof obj[key].client_id === 'string') { + clientID = obj[key].client_id; + } else { + return clientID; + } + break; + } else { + _findAPI(obj[key]); + } + } + } + }; + + try { + if (typeof dataLayer === 'object') { + dataLayer.forEach((layer) => { + _findAPI(layer); + }); + } + } catch (e) { + // GA4 + window.dataLayer.push({ + event: 'log', + label: 'getGtagClientID error: ' + e + }); + return null; + } + + return clientID; + }, + + /** + * Returns a random identifier that we use to associate a + * visitor's website GA data with their Telemetry attribution + * data. This identifier is sent as a non-interaction event + * to GA, and also to the stub attribution service as session_id. + * @returns {String} session ID. + */ + createSessionID: () => { + return Math.floor(1000000000 + Math.random() * 9000000000).toString(); + }, + + /** + * A crude check to see if Google Analytics has loaded. + * @param {Function} callback + */ + waitForGoogleAnalyticsThen: (callback) => { + let timeout; + let pollRetry = 0; + const interval = 100; + const limit = 20; // (100 x 20) / 1000 = 2 seconds + + // Tries to get client IDs at a set interval + const _checkGA = () => { + clearTimeout(timeout); + const clientIDGA4 = DownloadAttribution.getGtagClientID(); + + if (clientIDGA4) { + callback(true); + } else { + if (pollRetry <= limit) { + pollRetry += 1; + timeout = window.setTimeout(_checkGA, interval); + } else { + if (clientIDGA4) { + callback(true); + } else { + callback(false); + } + } + } + }; + + _checkGA(); + }, + + /** + * Gets the analytics campaign value: utm_campaign from the URL, falling + * back to the page-level default campaign attribute. + * @param {Object} params - URL params. + * @return {String | null} - Campaign value, or null. + */ + getAnalyticsCampaign: (params) => { + const utms = params.utmParams(); + if (utms.utm_campaign !== undefined) { + return utms.utm_campaign; + } + return document.documentElement.getAttribute( + 'data-stub-attribution-campaign' + ); + }, + + /** + * Gets essential data for download. + * Until the stub attribution service is updated to accept dedicated + * essential fields, essential data carries its campaign in utm_campaign; + * essential wins on key collisions with analytics when merged. + * @return {Object} - Essential data object, or {} if the current page + * does not carry a recognized essential campaign. + */ + getEssentialData: (campaign) => { + // NOTE: in future, this will return product context and install + // options fields based on data attributes. + if ( + campaign && + DownloadAttribution.ESSENTIAL_CAMPAIGNS.includes(campaign) + ) { + return { + utm_campaign: campaign + }; + } + + const pageCampaign = document.documentElement.getAttribute( + 'data-stub-attribution-campaign-force' + ); + + if ( + pageCampaign && + DownloadAttribution.ESSENTIAL_CAMPAIGNS.includes(pageCampaign) + ) { + return { + utm_campaign: pageCampaign + }; + } + + return {}; + }, + + /** + * Gets analytics data for download. Requires GA4 wait. + * @param {String} ref - Optional referrer to facilitate testing. + * @param {Object} params - URL params. + * @return {Object} - Analytics download attribution data object. + */ + getAnalyticsData: (ref, params) => { + const utms = params.utmParams(); + return { + utm_source: utms.utm_source, + utm_medium: utms.utm_medium, + utm_campaign: DownloadAttribution.getAnalyticsCampaign(params), + utm_content: utms.utm_content, + referrer: typeof ref === 'string' ? ref : document.referrer, + ua: DownloadAttribution.getUserAgent(), + experiment: params.get('experiment'), + variation: params.get('variation'), + client_id_ga4: DownloadAttribution.getGtagClientID(), + session_id: DownloadAttribution.createSessionID(), + dlsource: DownloadAttribution.DLSOURCE + }; + }, + + hasValidData: (data) => { + if ( + typeof data.utm_content === 'string' && + typeof data.referrer === 'string' + ) { + let content = data.utm_content; + const charLimit = 150; + + // If utm_content is unusually long, return false early. + if (content.length > charLimit) { + return false; + } + + // Attribution data can be double encoded + while (content.indexOf('%') !== -1) { + try { + const result = decodeURIComponent(content); + if (result === content) { + break; + } + content = result; + } catch (e) { + break; + } + } + + // If RTAMO data does not originate from AMO, drop attribution (Issues 10337, 10524). + if ( + /^rta:/.test(content) && + data.referrer.indexOf('https://addons.mozilla.org') === -1 + ) { + return false; + } + } + return true; + }, + + /** + * Merges essential and analytics data and requests an updated signed + * payload from the stub attribution service. Essential keys override + * analytics on collision (today only utm_campaign collides; a pending + * service update will give essential dedicated fields). + * @param {Object | null} essential - Essential data, or null. + * @param {Object | null} analytics - Analytics data, or null. + */ + requestCombinedAuth: (essential, analytics) => { + const combined = Object.assign({}, analytics || {}, essential || {}); + + // Remove undefined / null values. + for (const key of Object.keys(combined)) { + if ( + typeof combined[key] === 'undefined' || + combined[key] === null + ) { + delete combined[key]; + } + } + + if (Object.keys(combined).length === 0) { + return; + } + + if (!DownloadAttribution.hasValidData(combined)) { + return; + } + + DownloadAttribution.requestComplete = false; + DownloadAttribution.requestAuthentication(combined); + }, + + /** + * Determines if requirements for download attribution to work are satisfied. + * Download attribution is only applicable to Windows/macOS users on desktop. + * @return {Boolean}. + */ + meetsFunctionalRequirements: () => { + // NOTE: only site JS bundle is guaranteed to be available for these checks + if ( + typeof window.site === 'undefined' || + typeof Mozilla.Cookies === 'undefined' || + typeof window._SearchParams === 'undefined' + ) { + return false; + } + + if (!Mozilla.Cookies.enabled()) { + return false; + } + + if (!/windows|osx/i.test(window.site.platform)) { + return false; + } + + return true; + }, + + /** + * Applies existing attribution data to download links. Safe to call on + * every page; does nothing if no signed cookie is present. + */ + applyAttributionDataToLinks: () => { + if (!DownloadAttribution.meetsFunctionalRequirements()) { + return; + } + + if (DownloadAttribution.hasSignedCookie()) { + const data = DownloadAttribution.getSignedCookie(); + DownloadAttribution.updateBouncerLinks(data); + } + }, + + /** + * Essential trigger entry point. Runs on pages that carry essential + * download data (functional post-download behavior such as rtamo). + * Does not gate on analytics consent or sample rate: essential data + * must always be carried so the installer can deliver its promised + * functionality after download. + * @param {string} campaign - Optional. + * @param {Function} successCallback - Optional. + * @param {Function} timeoutCallback - Optional. + */ + initEssential: (campaign, successCallback, timeoutCallback) => { + if (!DownloadAttribution.meetsFunctionalRequirements()) { + return; + } + + if (typeof successCallback === 'function') { + DownloadAttribution.successCallback = successCallback; + } + + if (typeof timeoutCallback === 'function') { + DownloadAttribution.timeoutCallback = timeoutCallback; + } + + const essential = DownloadAttribution.getEssentialData(campaign); + + const analytics = DownloadAttribution.getRawCookie( + DownloadAttribution.COOKIE_ANALYTICS_RAW_ID + ); + + // We have last touch essential attribution to avoid a stale download experience + // REMOVE essential data if it is no longer applicable + if (Object.keys(essential).length === 0) { + if (analytics) { + // remove essential only + DownloadAttribution.removeRawCookie( + DownloadAttribution.COOKIE_ESSENTIAL_RAW_ID + ); + DownloadAttribution.requestCombinedAuth(null, analytics); + } else { + DownloadAttribution.removeAttributionData(); + // we didn't make a stub attribution call, but we've completed clean-up + if (typeof DownloadAttribution.successCallback === 'function') { + DownloadAttribution.successCallback(); + } + } + } else { + DownloadAttribution.setRawCookie( + DownloadAttribution.COOKIE_ESSENTIAL_RAW_ID, + essential + ); + + DownloadAttribution.requestCombinedAuth(essential, analytics); + } + }, + + /** + * Analytics trigger entry point. On 'granted', captures analytics data + * from the current URL and re-signs the combined payload. On 'denied', + * clears analytics data; if essential data is also absent, the full + * attribution state is removed. + * @param {Boolean} isConsentGranted - Based on GTM Consent Analytics Storage. + * @param {Function} successCallback - Optional. + * @param {Function} timeoutCallback - Optional. + */ + initAnalytics: (isConsentGranted, successCallback, timeoutCallback) => { + if (!DownloadAttribution.meetsFunctionalRequirements()) { + return; + } + + if (typeof successCallback === 'function') { + DownloadAttribution.successCallback = successCallback; + } + + if (typeof timeoutCallback === 'function') { + DownloadAttribution.timeoutCallback = timeoutCallback; + } + + if (isConsentGranted) { + // We have first touch analytics attribution + // DO NOT UPDATE if we have existing analytics data + if ( + DownloadAttribution.getRawCookie( + DownloadAttribution.COOKIE_ANALYTICS_RAW_ID + ) + ) { + return; + } + + if (!DownloadAttribution.withinAttributionRate()) { + return; + } + + DownloadAttribution.waitForGoogleAnalyticsThen(() => { + const params = new window._SearchParams(); + const analytics = DownloadAttribution.getAnalyticsData( + null, + params + ); + + DownloadAttribution.setRawCookie( + DownloadAttribution.COOKIE_ANALYTICS_RAW_ID, + analytics + ); + + const essential = DownloadAttribution.getRawCookie( + DownloadAttribution.COOKIE_ESSENTIAL_RAW_ID + ); + + DownloadAttribution.requestCombinedAuth(essential, analytics); + + if (analytics.client_id_ga4) { + window.dataLayer.push({ + event: 'stub_session_set', + id: analytics.session_id + }); + } + }); + } else { + const essential = DownloadAttribution.getRawCookie( + DownloadAttribution.COOKIE_ESSENTIAL_RAW_ID + ); + + if (essential) { + // remove analytics only + DownloadAttribution.removeRawCookie( + DownloadAttribution.COOKIE_ANALYTICS_RAW_ID + ); + DownloadAttribution.requestCombinedAuth(essential, null); + } else { + DownloadAttribution.removeAttributionData(); + // we didn't make a stub attribution call, but we've completed clean-up + if (typeof DownloadAttribution.successCallback === 'function') { + DownloadAttribution.successCallback(); + } + } + } + } +}; + +window.Mozilla.DownloadAttribution = DownloadAttribution; + +export default DownloadAttribution; diff --git a/media/js/firefox/all/all-init-v2.es6.js b/media/js/firefox/all/all-init-v2.es6.js new file mode 100644 index 000000000..0641fed67 --- /dev/null +++ b/media/js/firefox/all/all-init-v2.es6.js @@ -0,0 +1,192 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import DownloadAttribution from '../../base/download-attribution/download-attribution.es6'; +import TrackProductDownload from '../../base/datalayer-productdownload.es6'; +import MzpModal from '@mozilla-protocol/core/protocol/js/modal'; + +(function (Mozilla) { + // Getter function since outerHTML replacement in fetchContent breaks existing references + function getPartialTargetElement() { + return document.getElementById('partial-target'); + } + + function setUpPartialContentListeners() { + const browserHelpContent = document.getElementById('browser-help'); + const browserHelpIcon = document.getElementById('icon-browser-help'); + const installerHelpContent = document.getElementById('installer-help'); + const installerHelpIcon = document.querySelectorAll( + '.icon-installer-help' + ); + // We don't want to include nav download button + const downloadButtons = document.querySelectorAll( + '#outer-wrapper .download-link' + ); + + function showHelpModal(modalContent, modalTitle, eventLabel) { + MzpModal.createModal(this, modalContent, { + title: modalTitle, + className: 'help-modal' + }); + + // GA4 + window.dataLayer.push({ + event: 'widget_action', + type: 'modal', + action: 'open', + name: eventLabel + }); + } + + // Browser help modal. + if (browserHelpIcon) { + browserHelpIcon.addEventListener( + 'click', + function (e) { + e.preventDefault(); + showHelpModal.call( + this, + browserHelpContent, + browserHelpIcon.textContent, + 'Get Browser Help' + ); + }, + false + ); + } + + // Installer help modal. + if (installerHelpIcon) { + for (let i = 0; i < installerHelpIcon.length; i++) { + installerHelpIcon[i].addEventListener( + 'click', + function (e) { + e.preventDefault(); + showHelpModal.call( + this, + installerHelpContent, + e.target.textContent, + 'Get Installer Help' + ); + }, + false + ); + } + } + + if ( + window.cms && + window.cms.Flare26 && + window.cms.Flare26.initDialogs && + (installerHelpIcon || browserHelpIcon) + ) { + window.cms.Flare26.initDialogs(); + } + + // Attach existing download attribution to download links + if (downloadButtons && downloadButtons.length > 0) { + if (DownloadAttribution) { + // download-attribution-init will run on page load and set any allowed download attribution data + // But download links may not be rendered on DOM ready as this section is partially fetched + // So we directly call the function that applies data to links (if no data, does nothing) + // This flow is scheduled for refactoring: https://github.com/mozmeao/springfield/issues/258 + DownloadAttribution.applyAttributionDataToLinks(); + } + + for (let i = 0; i < downloadButtons.length; ++i) { + const downloadButton = downloadButtons[i]; + downloadButton.addEventListener( + 'click', + function (event) { + TrackProductDownload.handleLink(event); + }, + false + ); + } + } + + // Override click events for drill-down links. + getPartialTargetElement().addEventListener('click', function (event) { + const anchor = event.target.closest('a'); + if (anchor && anchor.matches('.load-content-partial')) { + event.preventDefault(); + fetchContent(anchor.href, true); + } + }); + } + + // A fetch helper since we use this in both the on click and popstate. + // pushState is a boolean so we avoid pushing state if triggered from popstate. + function fetchContent(url, pushState = false) { + fetch(url, { + // Signifies to backend to return partial HTML. + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + // Ignore what's cached and also don't cache this response. + // This is so we don't get full html pages when we expect partial html, or vice versa. + cache: 'no-store' + }) + .then((response) => { + if (!response.ok) { + throw new Error('Network response was not ok'); + } + return response.text(); + }) + .then((html) => { + const partialTarget = getPartialTargetElement(); + partialTarget.outerHTML = html; + + // Re-attach listeners as we just replaced partialTarget + setUpPartialContentListeners(); + + if (pushState) { + history.pushState({ path: url }, '', url); + } + + const activeHeaders = document.querySelectorAll( + '.c-step-name:not(.t-step-disabled)' + ); + const targetHeader = activeHeaders[activeHeaders.length - 1]; + if (targetHeader) { + // if not already in view, scroll into view + const rect = targetHeader.getBoundingClientRect(); + const isVisible = + rect.top >= 0 && rect.top < window.innerHeight; + if (!isVisible) { + // avoid hiding content beneath fixed nav + targetHeader.scrollIntoView({ block: 'center' }); + } + // .focus() scroll is buggy + targetHeader.focus({ preventScroll: true }); + } + }) + .catch((error) => { + throw new Error( + 'There was a problem with the fetch operation:', + error + ); + }); + } + + function onLoad() { + setUpPartialContentListeners(); + + // Add popstate listener so we return partial HTML with browser back button. + window.addEventListener('popstate', function (event) { + if (!event.state) { + return; + } + fetchContent(event.state.path, false); + }); + + // Ensure initial state is set up when the page loads so root page is in popstate. + window.addEventListener('DOMContentLoaded', () => { + const url = window.location.href; + history.replaceState({ path: url }, '', url); + }); + } + + Mozilla.run(onLoad); +})(window.Mozilla); diff --git a/media/js/firefox/download/auto-download.es6.js b/media/js/firefox/download/auto-download.es6.js new file mode 100644 index 000000000..368cf51b6 --- /dev/null +++ b/media/js/firefox/download/auto-download.es6.js @@ -0,0 +1,184 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +let timeout; +let requestComplete = false; + +/** + * Determine if browser should attempt to download Firefox on page load. + * @param {String} platform + * @param {Boolean} fxSupported + * @returns {Boolean} + */ +function shouldAutoDownload(platform, fxSupported) { + const supportedPlatforms = ['windows', 'osx', 'android', 'ios']; + + if (fxSupported && supportedPlatforms.indexOf(platform) !== -1) { + return true; + } + + return false; +} + +/** + * Get the Firefox download link for the appropriate platform. + * @param {Object} window.site + * @returns {String} download url + */ +function getDownloadURL(site) { + // 'thanks-' prefix here is part of a helper, now unnecessarily specific to /thanks page + // In future, we should decouple this prefix, so the auto-download functionality can be + // available anywhere + const prefix = 'thanks-download-button-'; + let link; + let url; + + switch (site.platform) { + case 'windows': + link = document.getElementById(prefix + 'win'); + break; + case 'osx': + link = document.getElementById(prefix + 'osx'); + break; + case 'linux': + // Linux users get SUMO install instructions. + link = null; + break; + case 'android': + link = document.getElementById(prefix + 'android'); + break; + case 'ios': + link = document.getElementById(prefix + 'ios'); + break; + } + + if (link && link.href) { + url = link.href; + } + + return url; +} + +/** + * Auto-start download + */ +function beginFirefoxDownload() { + const directDownloadLink = document.getElementById('direct-download-link'); + let downloadURL; + + // Only auto-start the download if a supported platform is detected. + if ( + shouldAutoDownload(window.site.platform, window.site.fxSupported) && + typeof Mozilla.Utils !== 'undefined' + ) { + downloadURL = getDownloadURL(window.site); + + if (downloadURL) { + // Pull download link from the download button and add to the 'Try downloading again' link. + // Make sure the 'Try downloading again' link is well formatted! (issue 9615) + if (directDownloadLink && directDownloadLink.href) { + directDownloadLink.href = downloadURL; + directDownloadLink.addEventListener( + 'click', + (event) => { + try { + Mozilla.TrackProductDownload.handleLink(event); + } catch (error) { + return; + } + }, + false + ); + } + + // Start the platform-detected download a second after DOM ready event. + Mozilla.Utils.onDocumentReady(() => { + setTimeout(() => { + try { + Mozilla.TrackProductDownload.sendEventFromURL( + downloadURL + ); + } catch (error) { + return; + } + window.location.href = downloadURL; + }, 1000); + }); + } + } +} + +/** + * On success of new download attribution request + */ +function onSuccess() { + // Make sure we only initiate the download once! + clearTimeout(timeout); + if (requestComplete) { + return; + } + requestComplete = true; + + // Fire GA event to log attribution success + // GA4 + window.dataLayer.push({ + event: 'widget_action', + type: 'direct-attribution', + action: 'success', + non_interaction: true + }); + + beginFirefoxDownload(); +} + +/** + * On timeout of new download attribution request + */ +function onTimeout() { + // Make sure we only initiate the download once! + clearTimeout(timeout); + if (requestComplete) { + return; + } + requestComplete = true; + + // Fire GA event to log attribution timeout + // GA4 + window.dataLayer.push({ + event: 'widget_action', + type: 'direct-attribution', + action: 'timeout', + non_interaction: true + }); + + beginFirefoxDownload(); +} + +// Force cookie update before download if there is essential data to add +if ( + Mozilla.DownloadAttribution !== undefined && + document.documentElement.hasAttribute( + 'data-stub-attribution-campaign-force' + ) +) { + // Custom success and timeout callbacks are only relevant for direct attribution + // (i.e. when we have updated the cookie on the auto-download page) + Mozilla.DownloadAttribution.successCallback = onSuccess; + Mozilla.DownloadAttribution.timeoutCallback = onTimeout; + // Don't wait too long before starting download + // (even if it means we have to leave the essential information out) + timeout = setTimeout(onTimeout, 2000); + Mozilla.DownloadAttribution.initEssential(); +} else { + if (Mozilla.DownloadAttribution !== undefined) { + // Otherwise, we can assume an existing cookie has latest data + Mozilla.DownloadAttribution.applyAttributionDataToLinks(); + } + beginFirefoxDownload(); +} + +// Bug 1354334 - add a hint for test automation that page has loaded. +document.getElementsByTagName('html')[0].classList.add('download-ready'); diff --git a/media/js/firefox/download/desktop/download-as-default-init-v2.es6.js b/media/js/firefox/download/desktop/download-as-default-init-v2.es6.js new file mode 100644 index 000000000..b40bd2aa4 --- /dev/null +++ b/media/js/firefox/download/desktop/download-as-default-init-v2.es6.js @@ -0,0 +1,9 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import DownloadAsDefault from './download-as-default-v2.es6.js'; + +DownloadAsDefault.init(); diff --git a/media/js/firefox/download/desktop/download-as-default-v2.es6.js b/media/js/firefox/download/desktop/download-as-default-v2.es6.js new file mode 100644 index 000000000..18af997de --- /dev/null +++ b/media/js/firefox/download/desktop/download-as-default-v2.es6.js @@ -0,0 +1,162 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +const DownloadAsDefault = {}; +DownloadAsDefault.CAMPAIGN = 'SET_AS_DEFAULT'; + +window.Mozilla.DownloadAsDefault = DownloadAsDefault; + +/** + * Processes attribution changes depending on checkbox state + * @param {Boolean} checked - checkbox target value. + */ +DownloadAsDefault.processAttributionRequest = (checked) => { + /** + * Update essential data based on checkbox state + * bindEvents will be both success and timeout callback + */ + if (!checked) { + window.Mozilla.DownloadAttribution.initEssential( + null, + () => { + DownloadAsDefault.bindEvents(); + }, + () => { + DownloadAsDefault.bindEvents(); + } + ); + } else { + window.Mozilla.DownloadAttribution.initEssential( + DownloadAsDefault.CAMPAIGN, + () => { + DownloadAsDefault.bindEvents(); + }, + () => { + DownloadAsDefault.bindEvents(); + } + ); + } +}; + +/** + * Handles checkbox change event. Because checkbox state must be + * synced between all checkboxes on the page, we must temporarily + * unbind event listeners to avoid triggering multiple change + * events at once. + * @param {Object} e - change event object. + */ +DownloadAsDefault.handleChangeEvent = (e) => { + DownloadAsDefault.unbindEvents(); + DownloadAsDefault.setCheckboxState(e.target.checked); + DownloadAsDefault.processAttributionRequest(e.target.checked); +}; + +/** + * Unbinds checkbox change event listeners and disables + * inputs when unbound. + */ +DownloadAsDefault.unbindEvents = () => { + const checkboxes = document.querySelectorAll('.default-browser-checkbox'); + + for (let i = 0; i < checkboxes.length; i++) { + checkboxes[i].removeEventListener( + 'change', + DownloadAsDefault.handleChangeEvent, + false + ); + checkboxes[i].disabled = true; + } +}; + +/** + * Binds checkbox change event listeners and removes + * disabled states on inputs when bound. + */ +DownloadAsDefault.bindEvents = () => { + const checkboxes = document.querySelectorAll('.default-browser-checkbox'); + + for (let i = 0; i < checkboxes.length; i++) { + checkboxes[i].addEventListener( + 'change', + DownloadAsDefault.handleChangeEvent, + false + ); + checkboxes[i].disabled = false; + } +}; + +/** + * Sets the checked state of all checkbox inputs. + * @param {Boolean} checked state + */ +DownloadAsDefault.setCheckboxState = (checked) => { + const checkboxes = document.querySelectorAll('.default-browser-checkbox'); + + for (let i = 0; i < checkboxes.length; i++) { + checkboxes[i].checked = checked; + } +}; + +/** + * Displays checkboxes via CSS by removing the `hidden` + * class on their corresponding `