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"use strict"; 5 6ChromeUtils.defineModuleGetter( 7 this, 8 "FxAccounts", 9 "resource://gre/modules/FxAccounts.jsm" 10); 11ChromeUtils.defineModuleGetter( 12 this, 13 "Services", 14 "resource://gre/modules/Services.jsm" 15); 16ChromeUtils.defineModuleGetter( 17 this, 18 "PrivateBrowsingUtils", 19 "resource://gre/modules/PrivateBrowsingUtils.jsm" 20); 21 22class _BookmarkPanelHub { 23 constructor() { 24 this._id = "BookmarkPanelHub"; 25 this._trigger = { id: "bookmark-panel" }; 26 this._handleMessageRequest = null; 27 this._addImpression = null; 28 this._sendTelemetry = null; 29 this._initialized = false; 30 this._response = null; 31 this._l10n = null; 32 33 this.messageRequest = this.messageRequest.bind(this); 34 this.toggleRecommendation = this.toggleRecommendation.bind(this); 35 this.sendUserEventTelemetry = this.sendUserEventTelemetry.bind(this); 36 this.collapseMessage = this.collapseMessage.bind(this); 37 } 38 39 /** 40 * @param {function} handleMessageRequest 41 * @param {function} addImpression 42 * @param {function} sendTelemetry - Used for sending user telemetry information 43 */ 44 init(handleMessageRequest, addImpression, sendTelemetry) { 45 this._handleMessageRequest = handleMessageRequest; 46 this._addImpression = addImpression; 47 this._sendTelemetry = sendTelemetry; 48 this._l10n = new DOMLocalization([]); 49 this._initialized = true; 50 } 51 52 uninit() { 53 this._l10n = null; 54 this._initialized = false; 55 this._handleMessageRequest = null; 56 this._addImpression = null; 57 this._sendTelemetry = null; 58 this._response = null; 59 } 60 61 /** 62 * Checks if a similar cached requests exists before forwarding the request 63 * to ASRouter. Caches only 1 request, unique identifier is `request.url`. 64 * Caching ensures we don't duplicate requests and telemetry pings. 65 * Return value is important for the caller to know if a message will be 66 * shown. 67 * 68 * @returns {obj|null} response object or null if no messages matched 69 */ 70 async messageRequest(target, win) { 71 if (!this._initialized) { 72 return false; 73 } 74 75 if ( 76 this._response && 77 this._response.win === win && 78 this._response.url === target.url && 79 this._response.content 80 ) { 81 this.showMessage(this._response.content, target, win); 82 return true; 83 } 84 85 // If we didn't match on a previously cached request then make sure 86 // the container is empty 87 this._removeContainer(target); 88 const response = await this._handleMessageRequest({ 89 triggerId: this._trigger.id, 90 }); 91 92 return this.onResponse(response, target, win); 93 } 94 95 /** 96 * If the response contains a message render it and send an impression. 97 * Otherwise we remove the message from the container. 98 */ 99 onResponse(response, target, win) { 100 this._response = { 101 ...response, 102 collapsed: false, 103 target, 104 win, 105 url: target.url, 106 }; 107 108 if (response && response.content) { 109 // Only insert localization files if we need to show a message 110 win.MozXULElement.insertFTLIfNeeded("browser/newtab/asrouter.ftl"); 111 win.MozXULElement.insertFTLIfNeeded("browser/branding/sync-brand.ftl"); 112 this.showMessage(response.content, target, win); 113 this.sendImpression(); 114 this.sendUserEventTelemetry("IMPRESSION", win); 115 } else { 116 this.hideMessage(target); 117 } 118 119 target.infoButton.disabled = !response; 120 121 return !!response; 122 } 123 124 showMessage(message, target, win) { 125 if (this._response && this._response.collapsed) { 126 this.toggleRecommendation(false); 127 return; 128 } 129 130 const createElement = elem => 131 target.document.createElementNS("http://www.w3.org/1999/xhtml", elem); 132 let recommendation = target.container.querySelector("#cfrMessageContainer"); 133 if (!recommendation) { 134 recommendation = createElement("div"); 135 const headerContainer = createElement("div"); 136 headerContainer.classList.add("cfrMessageHeader"); 137 recommendation.setAttribute("id", "cfrMessageContainer"); 138 recommendation.addEventListener("click", async e => { 139 target.hidePopup(); 140 const url = await FxAccounts.config.promiseConnectAccountURI( 141 "bookmark" 142 ); 143 win.ownerGlobal.openLinkIn(url, "tabshifted", { 144 private: false, 145 triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal( 146 {} 147 ), 148 csp: null, 149 }); 150 this.sendUserEventTelemetry("CLICK", win); 151 }); 152 recommendation.style.color = message.color; 153 recommendation.style.background = `linear-gradient(135deg, ${message.background_color_1} 0%, ${message.background_color_2} 70%)`; 154 const close = createElement("button"); 155 close.setAttribute("id", "cfrClose"); 156 close.setAttribute("aria-label", "close"); 157 close.addEventListener("click", e => { 158 this.sendUserEventTelemetry("DISMISS", win); 159 this.collapseMessage(); 160 target.close(e); 161 }); 162 const title = createElement("h1"); 163 title.setAttribute("id", "editBookmarkPanelRecommendationTitle"); 164 const content = createElement("p"); 165 content.setAttribute("id", "editBookmarkPanelRecommendationContent"); 166 const cta = createElement("button"); 167 cta.setAttribute("id", "editBookmarkPanelRecommendationCta"); 168 169 // If `string_id` is present it means we are relying on fluent for translations 170 if (message.text.string_id) { 171 this._l10n.setAttributes( 172 close, 173 message.close_button.tooltiptext.string_id 174 ); 175 this._l10n.setAttributes(title, message.title.string_id); 176 this._l10n.setAttributes(content, message.text.string_id); 177 this._l10n.setAttributes(cta, message.cta.string_id); 178 } else { 179 close.setAttribute("title", message.close_button.tooltiptext); 180 title.textContent = message.title; 181 content.textContent = message.text; 182 cta.textContent = message.cta; 183 } 184 185 headerContainer.appendChild(title); 186 headerContainer.appendChild(close); 187 recommendation.appendChild(headerContainer); 188 recommendation.appendChild(content); 189 recommendation.appendChild(cta); 190 target.container.appendChild(recommendation); 191 } 192 193 this.toggleRecommendation(true); 194 this._adjustPanelHeight(win, recommendation); 195 } 196 197 /** 198 * Adjust the size of the container for locales where the message is 199 * longer than the fixed 150px set for height 200 */ 201 async _adjustPanelHeight(window, messageContainer) { 202 const { document } = window; 203 // Contains the screenshot of the page we are bookmarking 204 const screenshotContainer = document.getElementById( 205 "editBookmarkPanelImage" 206 ); 207 // Wait for strings to be added which can change element height 208 await document.l10n.translateElements([messageContainer]); 209 window.requestAnimationFrame(() => { 210 let { height } = messageContainer.getBoundingClientRect(); 211 if (height > 150) { 212 messageContainer.classList.add("longMessagePadding"); 213 // Get the new value with the added padding 214 height = messageContainer.getBoundingClientRect().height; 215 // Needs to be adjusted to match the message height 216 screenshotContainer.style.height = `${height}px`; 217 } 218 }); 219 } 220 221 /** 222 * Restore the panel back to the original size so the slide in 223 * animation can run again 224 */ 225 _restorePanelHeight(window) { 226 const { document } = window; 227 // Contains the screenshot of the page we are bookmarking 228 document.getElementById("editBookmarkPanelImage").style.height = ""; 229 } 230 231 toggleRecommendation(visible) { 232 if (!this._response) { 233 return; 234 } 235 236 const { target } = this._response; 237 if (visible === undefined) { 238 // When called from the info button of the bookmark panel 239 target.infoButton.checked = !target.infoButton.checked; 240 } else { 241 target.infoButton.checked = visible; 242 } 243 if (target.infoButton.checked) { 244 // If it was ever collapsed we need to cancel the state 245 this._response.collapsed = false; 246 target.container.removeAttribute("disabled"); 247 } else { 248 target.container.setAttribute("disabled", "disabled"); 249 } 250 } 251 252 collapseMessage() { 253 this._response.collapsed = true; 254 this.toggleRecommendation(false); 255 } 256 257 _removeContainer(target) { 258 if (target || (this._response && this._response.target)) { 259 const container = ( 260 target || this._response.target 261 ).container.querySelector("#cfrMessageContainer"); 262 if (container) { 263 this._restorePanelHeight(this._response.win); 264 container.remove(); 265 } 266 } 267 } 268 269 hideMessage(target) { 270 this._removeContainer(target); 271 this.toggleRecommendation(false); 272 this._response = null; 273 } 274 275 forceShowMessage(browser, message) { 276 const doc = browser.ownerGlobal.gBrowser.ownerDocument; 277 const win = browser.ownerGlobal.window; 278 const panelTarget = { 279 container: doc.getElementById("editBookmarkPanelRecommendation"), 280 infoButton: doc.getElementById("editBookmarkPanelInfoButton"), 281 document: doc, 282 close: e => { 283 e.stopPropagation(); 284 this.toggleRecommendation(false); 285 }, 286 }; 287 // Remove any existing message 288 this.hideMessage(panelTarget); 289 // Reset the reference to the panel elements 290 this._response = { target: panelTarget, win }; 291 // Required if we want to preview messages that include fluent strings 292 win.MozXULElement.insertFTLIfNeeded("browser/newtab/asrouter.ftl"); 293 win.MozXULElement.insertFTLIfNeeded("browser/branding/sync-brand.ftl"); 294 this.showMessage(message.content, panelTarget, win); 295 } 296 297 sendImpression() { 298 this._addImpression(this._response); 299 } 300 301 sendUserEventTelemetry(event, win) { 302 // Only send pings for non private browsing windows 303 if ( 304 !PrivateBrowsingUtils.isBrowserPrivate( 305 win.ownerGlobal.gBrowser.selectedBrowser 306 ) 307 ) { 308 this._sendPing({ 309 message_id: this._response.id, 310 bucket_id: this._response.id, 311 event, 312 }); 313 } 314 } 315 316 _sendPing(ping) { 317 this._sendTelemetry({ 318 type: "DOORHANGER_TELEMETRY", 319 data: { action: "cfr_user_event", source: "CFR", ...ping }, 320 }); 321 } 322} 323 324this._BookmarkPanelHub = _BookmarkPanelHub; 325 326/** 327 * BookmarkPanelHub - singleton instance of _BookmarkPanelHub that can initiate 328 * message requests and render messages. 329 */ 330this.BookmarkPanelHub = new _BookmarkPanelHub(); 331 332const EXPORTED_SYMBOLS = ["BookmarkPanelHub", "_BookmarkPanelHub"]; 333