smartblock_embeds_helper.js (10886B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 /* globals browser exportFunction */ 6 7 "use strict"; 8 9 const SMARTBLOCK_EMBED_OBSERVER_TIMEOUT_MS = 10000; 10 11 /** 12 * Helper library to create shims for Smartblock Embeds 13 * 14 */ 15 const embedHelperLib = (() => { 16 let prevRanShims = new Set(); 17 let originalEmbedContainers = []; 18 let embedPlaceholders = []; 19 let observerTimeout; 20 let newEmbedObserver; 21 22 function sendMessageToAddon(message, shimId) { 23 return browser.runtime.sendMessage({ message, shimId }); 24 } 25 26 function addonMessageHandler(message, SHIM_INFO) { 27 const { topic, shimId: sendingShimId } = message; 28 const { shimId: handlingShimId, scriptURL } = SHIM_INFO; 29 30 // Only react to messages which are targeting this shim. 31 if (sendingShimId != handlingShimId) { 32 return; 33 } 34 35 if (topic === "smartblock:unblock-embed") { 36 if (newEmbedObserver) { 37 newEmbedObserver.disconnect(); 38 newEmbedObserver = null; 39 } 40 41 if (observerTimeout) { 42 clearTimeout(observerTimeout); 43 observerTimeout = null; 44 } 45 46 // remove embed placeholders 47 embedPlaceholders.forEach((p, idx) => { 48 p.replaceWith(originalEmbedContainers[idx]); 49 }); 50 51 // recreate scripts 52 let scriptElement = document.createElement("script"); 53 54 // Set the script element's src with the website's principal instead of 55 // the content script principal to ensure the tracker script is not loaded 56 // via the content script's expanded principal. 57 scriptElement.wrappedJSObject.src = scriptURL; 58 document.body.appendChild(scriptElement); 59 } 60 } 61 62 /** 63 * Replaces embeds with a SmartBlock Embed placeholder. Optionally takes a list 64 * of embeds to replace, otherwise will search for all embeds on the page. 65 * 66 * @param {HTMLElement[]} embedContainers - Array of elements to replace with placeholders. 67 * If the array is empty, this function will search 68 * for and replace all embeds on the page. 69 * 70 * @param {object} SHIM_INFO - Information about the shim wrapped in an object. 71 */ 72 async function createShimPlaceholders(embedContainers, SHIM_INFO) { 73 const { shimId, embedSelector, embedLogoURL, isTestShim } = SHIM_INFO; 74 75 const [titleString, descriptionString, buttonString] = 76 await sendMessageToAddon("smartblockGetFluentString", shimId); 77 78 if (!embedContainers.length) { 79 // No containers were passed in, do own search for containers 80 embedContainers = document.querySelectorAll(embedSelector); 81 } 82 83 embedContainers.forEach(originalContainer => { 84 // this string has to be defined within this function to avoid linting errors 85 // see: https://github.com/mozilla/eslint-plugin-no-unsanitized/issues/259 86 const SMARTBLOCK_PLACEHOLDER_HTML_STRING = ` 87 <style> 88 #smartblock-placeholder-wrapper { 89 min-height: 137px; 90 min-width: 150px; 91 max-height: 225px; 92 max-width: 400px; 93 padding: 32px 24px; 94 95 display: block; 96 align-content: center; 97 text-align: center; 98 99 background-color: light-dark(rgb(255, 255, 255), rgb(28, 27, 34)); 100 color: light-dark(rgb(43, 42, 51), rgb(251, 251, 254)); 101 102 border-radius: 8px; 103 border: 2px dashed #0250bb; 104 105 font-size: 14px; 106 line-height: 1.2; 107 font-family: system-ui; 108 } 109 110 #smartblock-placeholder-button { 111 min-height: 32px; 112 padding: 8px 14px; 113 114 border-radius: 4px; 115 font-weight: 600; 116 border: 0; 117 118 /* Colours match light/dark theme from 119 https://searchfox.org/mozilla-central/source/browser/themes/addons/light/manifest.json 120 https://searchfox.org/mozilla-central/source/browser/themes/addons/dark/manifest.json */ 121 background-color: light-dark(rgb(0, 97, 224), rgb(0, 221, 255)); 122 color: light-dark(rgb(251, 251, 254), rgb(43, 42, 51)); 123 } 124 125 #smartblock-placeholder-button:hover { 126 /* Colours match light/dark theme from 127 https://searchfox.org/mozilla-central/source/browser/themes/addons/light/manifest.json 128 https://searchfox.org/mozilla-central/source/browser/themes/addons/dark/manifest.json */ 129 background-color: light-dark(rgb(2, 80, 187), rgb(128, 235, 255)); 130 } 131 132 #smartblock-placeholder-button:hover:active { 133 /* Colours match light/dark theme from 134 https://searchfox.org/mozilla-central/source/browser/themes/addons/light/manifest.json 135 https://searchfox.org/mozilla-central/source/browser/themes/addons/dark/manifest.json */ 136 background-color: light-dark(rgb(5, 62, 148), rgb(170, 242, 255)); 137 } 138 139 #smartblock-placeholder-title { 140 margin-block: 14px; 141 font-size: 16px; 142 font-weight: bold; 143 } 144 145 #smartblock-placeholder-desc { 146 margin-block: 14px; 147 } 148 </style> 149 <div id="smartblock-placeholder-wrapper"> 150 <img id="smartblock-placeholder-image" width="24" height="24" /> 151 <p id="smartblock-placeholder-title"></p> 152 <p id="smartblock-placeholder-desc"></p> 153 <button id="smartblock-placeholder-button"></button> 154 </div>`; 155 156 // Create the placeholder inside a shadow dom 157 const placeholderDiv = document.createElement("div"); 158 159 // Workaround to make sure clicks reach our placeholder button if the site 160 // uses pointer capture. See Bug 1966696 for an example. 161 disableSetPointerCaptureFor(placeholderDiv); 162 163 if (isTestShim) { 164 // Tag the div with a class to make it easily detectable FOR THE TEST SHIM ONLY 165 placeholderDiv.classList.add("shimmed-embedded-content"); 166 } 167 168 const shadowRoot = placeholderDiv.attachShadow({ mode: "closed" }); 169 170 shadowRoot.innerHTML = SMARTBLOCK_PLACEHOLDER_HTML_STRING; 171 shadowRoot.getElementById("smartblock-placeholder-image").src = 172 embedLogoURL; 173 shadowRoot.getElementById("smartblock-placeholder-title").textContent = 174 titleString; 175 shadowRoot.getElementById("smartblock-placeholder-desc").textContent = 176 descriptionString; 177 shadowRoot.getElementById("smartblock-placeholder-button").textContent = 178 buttonString; 179 180 // Wait for user to opt-in. 181 shadowRoot 182 .getElementById("smartblock-placeholder-button") 183 .addEventListener("click", ({ isTrusted }) => { 184 if (!isTrusted) { 185 return; 186 } 187 // Send a message to the addon to allow loading tracking resources 188 // needed by the embed. 189 sendMessageToAddon("embedClicked", shimId); 190 }); 191 192 // Save the original embed element and the newly created placeholder 193 embedPlaceholders.push(placeholderDiv); 194 originalEmbedContainers.push(originalContainer); 195 196 // Replace the embed with the placeholder 197 originalContainer.replaceWith(placeholderDiv); 198 199 sendMessageToAddon("smartblockEmbedReplaced", shimId); 200 }); 201 202 if (isTestShim) { 203 // Dispatch event to signal that the script is done replacing FOR TEST SHIM ONLY 204 const finishedEvent = new CustomEvent("smartblockEmbedScriptFinished", { 205 bubbles: true, 206 composed: true, 207 }); 208 window.dispatchEvent(finishedEvent); 209 } 210 } 211 212 /** 213 * Creates a mutation observer to observe new changes after page load to monitor for 214 * new embeds. 215 * 216 * @param {object} SHIM_INFO - Information about the shim wrapped in an object. 217 */ 218 function createEmbedMutationObserver(SHIM_INFO) { 219 const { embedSelector } = SHIM_INFO; 220 221 // Monitor for new embeds being added after page load so we can replace them 222 // with placeholders. 223 newEmbedObserver = new MutationObserver(mutations => { 224 for (let { addedNodes, target, type } of mutations) { 225 const nodes = type === "attributes" ? [target] : addedNodes; 226 for (const node of nodes) { 227 if (node.nodeType !== Node.ELEMENT_NODE) { 228 // node is not an element, skip 229 continue; 230 } 231 if (node.matches(embedSelector)) { 232 // If element is an embed, replace with placeholder 233 createShimPlaceholders([node], SHIM_INFO); 234 } else { 235 // If element is not an embed, check if any children are 236 // and replace if needed 237 let maybeEmbedNodeList = node.querySelectorAll?.(embedSelector); 238 if (maybeEmbedNodeList) { 239 createShimPlaceholders(maybeEmbedNodeList, SHIM_INFO); 240 } 241 } 242 } 243 } 244 }); 245 246 newEmbedObserver.observe(document.documentElement, { 247 childList: true, 248 subtree: true, 249 attributes: true, 250 attributeFilter: ["id", "class"], 251 }); 252 253 // Disconnect the mutation observer after a fixed (long) timeout to conserve resources. 254 observerTimeout = setTimeout(() => { 255 if (newEmbedObserver) { 256 newEmbedObserver.disconnect(); 257 } 258 }, SMARTBLOCK_EMBED_OBSERVER_TIMEOUT_MS); 259 } 260 261 /** 262 * Disables the setPointerCapture method for a given element to prevent 263 * pointer capture issues. 264 * 265 * @param {HTMLElement} el - The element to disable setPointerCapture for. 266 */ 267 function disableSetPointerCaptureFor(el) { 268 const pageEl = el.wrappedJSObject; 269 270 Object.defineProperty(pageEl, "setPointerCapture", { 271 configurable: true, 272 writable: true, 273 enumerable: false, 274 // no-op ONLY for this element 275 value: exportFunction(function (_pointerId) { 276 console.warn( 277 "Blocked setPointerCapture on SmartBlock embed placeholder.", 278 this, 279 _pointerId 280 ); 281 // swallow 282 }, window), 283 }); 284 } 285 286 /** 287 * Initializes a smartblock embeds shim on the page. 288 * 289 * @param {object} SHIM_INFO - Information about the shim wrapped in an object. 290 */ 291 function initEmbedShim(SHIM_INFO) { 292 let { shimId } = SHIM_INFO; 293 294 if (prevRanShims.has(shimId)) { 295 // we should not init shims twice 296 return; 297 } 298 299 prevRanShims.add(shimId); 300 301 // Listen for messages from the background script. 302 browser.runtime.onMessage.addListener(request => { 303 addonMessageHandler(request, SHIM_INFO); 304 }); 305 306 // Listen for page changes in case of new embeds 307 createEmbedMutationObserver(SHIM_INFO); 308 309 // Run placeholder creation 310 createShimPlaceholders([], SHIM_INFO); 311 } 312 313 return { 314 initEmbedShim, 315 }; 316 })();