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 6const { XPCOMUtils } = ChromeUtils.import( 7 "resource://gre/modules/XPCOMUtils.jsm" 8); 9const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); 10 11XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]); 12 13XPCOMUtils.defineLazyModuleGetters(this, { 14 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm", 15 RemoteL10n: "resource://activity-stream/lib/RemoteL10n.jsm", 16}); 17 18XPCOMUtils.defineLazyServiceGetter( 19 this, 20 "TrackingDBService", 21 "@mozilla.org/tracking-db-service;1", 22 "nsITrackingDBService" 23); 24XPCOMUtils.defineLazyPreferenceGetter( 25 this, 26 "milestones", 27 "browser.contentblocking.cfr-milestone.milestones", 28 "[]", 29 null, 30 JSON.parse 31); 32 33const POPUP_NOTIFICATION_ID = "contextual-feature-recommendation"; 34const ANIMATION_BUTTON_ID = "cfr-notification-footer-animation-button"; 35const ANIMATION_LABEL_ID = "cfr-notification-footer-animation-label"; 36const SUMO_BASE_URL = Services.urlFormatter.formatURLPref( 37 "app.support.baseURL" 38); 39const ADDONS_API_URL = 40 "https://services.addons.mozilla.org/api/v4/addons/addon"; 41 42const DELAY_BEFORE_EXPAND_MS = 1000; 43const CATEGORY_ICONS = { 44 cfrAddons: "webextensions-icon", 45 cfrFeatures: "recommendations-icon", 46 cfrHeartbeat: "highlights-icon", 47}; 48 49/** 50 * A WeakMap from browsers to {host, recommendation} pairs. Recommendations are 51 * defined in the ExtensionDoorhanger.schema.json. 52 * 53 * A recommendation is specific to a browser and host and is active until the 54 * given browser is closed or the user navigates (within that browser) away from 55 * the host. 56 */ 57let RecommendationMap = new WeakMap(); 58 59/** 60 * A WeakMap from windows to their CFR PageAction. 61 */ 62let PageActionMap = new WeakMap(); 63 64/** 65 * We need one PageAction for each window 66 */ 67class PageAction { 68 constructor(win, dispatchToASRouter) { 69 this.window = win; 70 71 this.urlbar = win.gURLBar; // The global URLBar object 72 this.urlbarinput = win.gURLBar.textbox; // The URLBar DOM node 73 74 this.container = win.document.getElementById( 75 "contextual-feature-recommendation" 76 ); 77 this.button = win.document.getElementById("cfr-button"); 78 this.label = win.document.getElementById("cfr-label"); 79 80 // This should NOT be use directly to dispatch message-defined actions attached to buttons. 81 // Please use dispatchUserAction instead. 82 this._dispatchToASRouter = dispatchToASRouter; 83 84 this._popupStateChange = this._popupStateChange.bind(this); 85 this._collapse = this._collapse.bind(this); 86 this._cfrUrlbarButtonClick = this._cfrUrlbarButtonClick.bind(this); 87 this._executeNotifierAction = this._executeNotifierAction.bind(this); 88 this.dispatchUserAction = this.dispatchUserAction.bind(this); 89 90 // Saved timeout IDs for scheduled state changes, so they can be cancelled 91 this.stateTransitionTimeoutIDs = []; 92 93 XPCOMUtils.defineLazyGetter(this, "isDarkTheme", () => { 94 try { 95 return this.window.document.documentElement.hasAttribute( 96 "lwt-toolbar-field-brighttext" 97 ); 98 } catch (e) { 99 return false; 100 } 101 }); 102 } 103 104 addImpression(recommendation) { 105 this._dispatchImpression(recommendation); 106 // Only send an impression ping upon the first expansion. 107 // Note that when the user clicks on the "show" button on the asrouter admin 108 // page (both `bucket_id` and `id` will be set as null), we don't want to send 109 // the impression ping in that case. 110 if (!!recommendation.id && !!recommendation.content.bucket_id) { 111 this._sendTelemetry({ 112 message_id: recommendation.id, 113 bucket_id: recommendation.content.bucket_id, 114 event: "IMPRESSION", 115 ...(recommendation.personalizedModelVersion 116 ? { 117 event_context: { 118 modelVersion: recommendation.personalizedModelVersion, 119 }, 120 } 121 : {}), 122 }); 123 } 124 } 125 126 reloadL10n() { 127 RemoteL10n.reloadL10n(); 128 } 129 130 async showAddressBarNotifier(recommendation, shouldExpand = false) { 131 this.container.hidden = false; 132 133 let notificationText = await this.getStrings( 134 recommendation.content.notification_text 135 ); 136 this.label.value = notificationText; 137 if (notificationText.attributes) { 138 this.button.setAttribute( 139 "tooltiptext", 140 notificationText.attributes.tooltiptext 141 ); 142 // For a11y, we want the more descriptive text. 143 this.container.setAttribute( 144 "aria-label", 145 notificationText.attributes.tooltiptext 146 ); 147 } 148 this.container.setAttribute( 149 "data-cfr-icon", 150 CATEGORY_ICONS[recommendation.content.category] 151 ); 152 if (recommendation.content.active_color) { 153 this.container.style.setProperty( 154 "--cfr-active-color", 155 recommendation.content.active_color 156 ); 157 } 158 159 // Wait for layout to flush to avoid a synchronous reflow then calculate the 160 // label width. We can safely get the width even though the recommendation is 161 // collapsed; the label itself remains full width (with its overflow hidden) 162 let [{ width }] = await this.window.promiseDocumentFlushed(() => 163 this.label.getClientRects() 164 ); 165 this.urlbarinput.style.setProperty("--cfr-label-width", `${width}px`); 166 167 this.container.addEventListener("click", this._cfrUrlbarButtonClick); 168 // Collapse the recommendation on url bar focus in order to free up more 169 // space to display and edit the url 170 this.urlbar.addEventListener("focus", this._collapse); 171 172 if (shouldExpand) { 173 this._clearScheduledStateChanges(); 174 175 // After one second, expand 176 this._expand(DELAY_BEFORE_EXPAND_MS); 177 178 this.addImpression(recommendation); 179 } 180 181 if (notificationText.attributes) { 182 this.window.A11yUtils.announce({ 183 raw: notificationText.attributes["a11y-announcement"], 184 source: this.container, 185 }); 186 } 187 } 188 189 hideAddressBarNotifier() { 190 this.container.hidden = true; 191 this._clearScheduledStateChanges(); 192 this.urlbarinput.removeAttribute("cfr-recommendation-state"); 193 this.container.removeEventListener("click", this._cfrUrlbarButtonClick); 194 this.urlbar.removeEventListener("focus", this._collapse); 195 if (this.currentNotification) { 196 this.window.PopupNotifications.remove(this.currentNotification); 197 this.currentNotification = null; 198 } 199 } 200 201 _expand(delay) { 202 if (delay > 0) { 203 this.stateTransitionTimeoutIDs.push( 204 this.window.setTimeout(() => { 205 this.urlbarinput.setAttribute("cfr-recommendation-state", "expanded"); 206 }, delay) 207 ); 208 } else { 209 // Non-delayed state change overrides any scheduled state changes 210 this._clearScheduledStateChanges(); 211 this.urlbarinput.setAttribute("cfr-recommendation-state", "expanded"); 212 } 213 } 214 215 _collapse(delay) { 216 if (delay > 0) { 217 this.stateTransitionTimeoutIDs.push( 218 this.window.setTimeout(() => { 219 if ( 220 this.urlbarinput.getAttribute("cfr-recommendation-state") === 221 "expanded" 222 ) { 223 this.urlbarinput.setAttribute( 224 "cfr-recommendation-state", 225 "collapsed" 226 ); 227 } 228 }, delay) 229 ); 230 } else { 231 // Non-delayed state change overrides any scheduled state changes 232 this._clearScheduledStateChanges(); 233 if ( 234 this.urlbarinput.getAttribute("cfr-recommendation-state") === "expanded" 235 ) { 236 this.urlbarinput.setAttribute("cfr-recommendation-state", "collapsed"); 237 } 238 } 239 240 // TODO: FIXME: find a nicer way of cleaning this up. Maybe listening to "popuphidden"? 241 // Remove click listener on pause button; 242 if (this.onAnimationButtonClick) { 243 this.window.document 244 .getElementById(ANIMATION_BUTTON_ID) 245 .removeEventListener("click", this.onAnimationButtonClick); 246 delete this.onAnimationButtonClick; 247 } 248 } 249 250 _clearScheduledStateChanges() { 251 while (this.stateTransitionTimeoutIDs.length) { 252 // clearTimeout is safe even with invalid/expired IDs 253 this.window.clearTimeout(this.stateTransitionTimeoutIDs.pop()); 254 } 255 } 256 257 // This is called when the popup closes as a result of interaction _outside_ 258 // the popup, e.g. by hitting <esc> 259 _popupStateChange(state) { 260 if (state === "shown") { 261 if (this._autoFocus) { 262 this.window.document.commandDispatcher.advanceFocusIntoSubtree( 263 this.currentNotification.owner.panel 264 ); 265 this._autoFocus = false; 266 } 267 } else if (state === "removed") { 268 if (this.currentNotification) { 269 this.window.PopupNotifications.remove(this.currentNotification); 270 this.currentNotification = null; 271 } 272 } else if (state === "dismissed") { 273 this._collapse(); 274 } 275 } 276 277 shouldShowDoorhanger(recommendation) { 278 if (recommendation.content.layout === "chiclet_open_url") { 279 return false; 280 } 281 282 return true; 283 } 284 285 dispatchUserAction(action) { 286 this._dispatchToASRouter( 287 { type: "USER_ACTION", data: action }, 288 { browser: this.window.gBrowser.selectedBrowser } 289 ); 290 } 291 292 _dispatchImpression(message) { 293 this._dispatchToASRouter({ type: "IMPRESSION", data: message }); 294 } 295 296 _sendTelemetry(ping) { 297 this._dispatchToASRouter({ 298 type: "DOORHANGER_TELEMETRY", 299 data: { action: "cfr_user_event", source: "CFR", ...ping }, 300 }); 301 } 302 303 _blockMessage(messageID) { 304 this._dispatchToASRouter({ 305 type: "BLOCK_MESSAGE_BY_ID", 306 data: { id: messageID }, 307 }); 308 } 309 310 /** 311 * getStrings - Handles getting the localized strings vs message overrides. 312 * If string_id is not defined it assumes you passed in an override 313 * message and it just returns it. 314 * If subAttribute is provided, the string for it is returned. 315 * @return A string. One of 1) passed in string 2) a String object with 316 * attributes property if there are attributes 3) the sub attribute. 317 */ 318 async getStrings(string, subAttribute = "") { 319 if (!string.string_id) { 320 if (subAttribute) { 321 if (string.attributes) { 322 return string.attributes[subAttribute]; 323 } 324 325 Cu.reportError( 326 `String ${string.value} does not contain any attributes` 327 ); 328 return subAttribute; 329 } 330 331 if (typeof string.value === "string") { 332 const stringWithAttributes = new String(string.value); // eslint-disable-line no-new-wrappers 333 stringWithAttributes.attributes = string.attributes; 334 return stringWithAttributes; 335 } 336 337 return string; 338 } 339 340 const [localeStrings] = await RemoteL10n.l10n.formatMessages([ 341 { 342 id: string.string_id, 343 args: string.args, 344 }, 345 ]); 346 347 const mainString = new String(localeStrings.value); // eslint-disable-line no-new-wrappers 348 if (localeStrings.attributes) { 349 const attributes = localeStrings.attributes.reduce((acc, attribute) => { 350 acc[attribute.name] = attribute.value; 351 return acc; 352 }, {}); 353 mainString.attributes = attributes; 354 } 355 356 return subAttribute ? mainString.attributes[subAttribute] : mainString; 357 } 358 359 async _setAddonAuthorAndRating(document, content) { 360 const author = this.window.document.getElementById( 361 "cfr-notification-author" 362 ); 363 const footerFilledStars = this.window.document.getElementById( 364 "cfr-notification-footer-filled-stars" 365 ); 366 const footerEmptyStars = this.window.document.getElementById( 367 "cfr-notification-footer-empty-stars" 368 ); 369 const footerUsers = this.window.document.getElementById( 370 "cfr-notification-footer-users" 371 ); 372 const footerSpacer = this.window.document.getElementById( 373 "cfr-notification-footer-spacer" 374 ); 375 376 author.textContent = await this.getStrings({ 377 string_id: "cfr-doorhanger-extension-author", 378 args: { name: content.addon.author }, 379 }); 380 381 const { rating } = content.addon; 382 if (rating) { 383 const MAX_RATING = 5; 384 const STARS_WIDTH = 17 * MAX_RATING; 385 const calcWidth = stars => `${(stars / MAX_RATING) * STARS_WIDTH}px`; 386 footerFilledStars.style.width = calcWidth(rating); 387 footerEmptyStars.style.width = calcWidth(MAX_RATING - rating); 388 389 const ratingString = await this.getStrings( 390 { 391 string_id: "cfr-doorhanger-extension-rating", 392 args: { total: rating }, 393 }, 394 "tooltiptext" 395 ); 396 footerFilledStars.setAttribute("tooltiptext", ratingString); 397 footerEmptyStars.setAttribute("tooltiptext", ratingString); 398 } else { 399 footerFilledStars.style.width = ""; 400 footerEmptyStars.style.width = ""; 401 footerFilledStars.removeAttribute("tooltiptext"); 402 footerEmptyStars.removeAttribute("tooltiptext"); 403 } 404 405 const { users } = content.addon; 406 if (users) { 407 footerUsers.setAttribute( 408 "value", 409 await this.getStrings({ 410 string_id: "cfr-doorhanger-extension-total-users", 411 args: { total: users }, 412 }) 413 ); 414 footerUsers.removeAttribute("hidden"); 415 } else { 416 // Prevent whitespace around empty label from affecting other spacing 417 footerUsers.setAttribute("hidden", true); 418 footerUsers.removeAttribute("value"); 419 } 420 421 // Spacer pushes the link to the opposite end when there's other content 422 if (rating || users) { 423 footerSpacer.removeAttribute("hidden"); 424 } else { 425 footerSpacer.setAttribute("hidden", true); 426 } 427 } 428 429 _createElementAndAppend({ type, id }, parent) { 430 let element = this.window.document.createXULElement(type); 431 if (id) { 432 element.setAttribute("id", id); 433 } 434 parent.appendChild(element); 435 return element; 436 } 437 438 async _renderPinTabAnimation() { 439 const ANIMATION_CONTAINER_ID = 440 "cfr-notification-footer-pintab-animation-container"; 441 const footer = this.window.document.getElementById( 442 "cfr-notification-footer" 443 ); 444 let animationContainer = this.window.document.getElementById( 445 ANIMATION_CONTAINER_ID 446 ); 447 if (!animationContainer) { 448 animationContainer = this._createElementAndAppend( 449 { type: "vbox", id: ANIMATION_CONTAINER_ID }, 450 footer 451 ); 452 453 let controlsContainer = this._createElementAndAppend( 454 { type: "hbox", id: "cfr-notification-footer-animation-controls" }, 455 animationContainer 456 ); 457 458 // spacer 459 this._createElementAndAppend( 460 { type: "vbox" }, 461 controlsContainer 462 ).setAttribute("flex", 1); 463 464 let animationButton = this._createElementAndAppend( 465 { type: "hbox", id: ANIMATION_BUTTON_ID }, 466 controlsContainer 467 ); 468 469 // animation button label 470 this._createElementAndAppend( 471 { type: "label", id: ANIMATION_LABEL_ID }, 472 animationButton 473 ); 474 } 475 476 animationContainer.toggleAttribute( 477 "animate", 478 !this.window.matchMedia("(prefers-reduced-motion: reduce)").matches 479 ); 480 animationContainer.removeAttribute("paused"); 481 482 this.window.document.getElementById( 483 ANIMATION_LABEL_ID 484 ).textContent = await this.getStrings({ 485 string_id: "cfr-doorhanger-pintab-animation-pause", 486 }); 487 488 if (!this.onAnimationButtonClick) { 489 let animationButton = this.window.document.getElementById( 490 ANIMATION_BUTTON_ID 491 ); 492 this.onAnimationButtonClick = async () => { 493 let animationLabel = this.window.document.getElementById( 494 ANIMATION_LABEL_ID 495 ); 496 if (animationContainer.toggleAttribute("paused")) { 497 animationLabel.textContent = await this.getStrings({ 498 string_id: "cfr-doorhanger-pintab-animation-resume", 499 }); 500 } else { 501 animationLabel.textContent = await this.getStrings({ 502 string_id: "cfr-doorhanger-pintab-animation-pause", 503 }); 504 } 505 }; 506 animationButton.addEventListener("click", this.onAnimationButtonClick); 507 } 508 } 509 510 async _renderMilestonePopup(message, browser) { 511 let { content, id } = message; 512 let { primary } = content.buttons; 513 514 let dateFormat = new Services.intl.DateTimeFormat( 515 this.window.gBrowser.ownerGlobal.navigator.language, 516 { 517 month: "long", 518 year: "numeric", 519 } 520 ).format; 521 522 let earliestDate = await TrackingDBService.getEarliestRecordedDate(); 523 let monthName = dateFormat(new Date(earliestDate)); 524 let panelTitle = ""; 525 let headerLabel = this.window.document.getElementById( 526 "cfr-notification-header-label" 527 ); 528 let reachedMilestone = 0; 529 let totalSaved = await TrackingDBService.sumAllEvents(); 530 for (let milestone of milestones) { 531 if (totalSaved >= milestone) { 532 reachedMilestone = milestone; 533 } 534 } 535 if (typeof message.content.heading_text === "string") { 536 // This is a test environment. 537 panelTitle = message.content.heading_text; 538 headerLabel.value = panelTitle; 539 } else { 540 RemoteL10n.l10n.setAttributes( 541 headerLabel, 542 content.heading_text.string_id, 543 { 544 blockedCount: reachedMilestone, 545 date: monthName, 546 } 547 ); 548 await RemoteL10n.l10n.translateElements([headerLabel]); 549 } 550 551 // Use the message layout as a CSS selector to hide different parts of the 552 // notification template markup 553 this.window.document 554 .getElementById("contextual-feature-recommendation-notification") 555 .setAttribute("data-notification-category", content.layout); 556 this.window.document 557 .getElementById("contextual-feature-recommendation-notification") 558 .setAttribute("data-notification-bucket", content.bucket_id); 559 let notification = this.window.document.getElementById( 560 "notification-popup" 561 ); 562 563 let primaryBtnString = await this.getStrings(primary.label); 564 let primaryActionCallback = () => { 565 this.dispatchUserAction(primary.action); 566 this._sendTelemetry({ 567 message_id: id, 568 bucket_id: content.bucket_id, 569 event: "CLICK_BUTTON", 570 }); 571 572 RecommendationMap.delete(browser); 573 // Invalidate the pref after the user interacts with the button. 574 // We don't need to show the illustration in the privacy panel. 575 Services.prefs.clearUserPref( 576 "browser.contentblocking.cfr-milestone.milestone-shown-time" 577 ); 578 }; 579 580 let mainAction = { 581 label: primaryBtnString, 582 accessKey: primaryBtnString.attributes.accesskey, 583 callback: primaryActionCallback, 584 }; 585 586 let style = this.window.document.createElement("style"); 587 style.textContent = ` 588 .cfr-notification-milestone .panel-arrow { 589 fill: #0250BB !important; 590 } 591 `; 592 style.classList.add("milestone-style"); 593 594 let arrow; 595 let manageClass = event => { 596 if (event === "dismissed" || event === "removed") { 597 style = notification.shadowRoot.querySelector(".milestone-style"); 598 if (style) { 599 notification.shadowRoot.removeChild(style); 600 } 601 arrow.classList.remove("cfr-notification-milestone"); 602 } else if (event === "showing") { 603 notification.shadowRoot.appendChild(style); 604 arrow = notification.shadowRoot.querySelector(".panel-arrowcontainer"); 605 arrow.classList.add("cfr-notification-milestone"); 606 } 607 }; 608 609 // Actually show the notification 610 this.currentNotification = this.window.PopupNotifications.show( 611 browser, 612 POPUP_NOTIFICATION_ID, 613 panelTitle, 614 "cfr", 615 mainAction, 616 null, 617 { 618 hideClose: true, 619 eventCallback: manageClass, 620 } 621 ); 622 Services.prefs.setIntPref( 623 "browser.contentblocking.cfr-milestone.milestone-achieved", 624 reachedMilestone 625 ); 626 Services.prefs.setStringPref( 627 "browser.contentblocking.cfr-milestone.milestone-shown-time", 628 Date.now().toString() 629 ); 630 } 631 632 // eslint-disable-next-line max-statements 633 async _renderPopup(message, browser) { 634 const { id, content, modelVersion } = message; 635 636 const headerLabel = this.window.document.getElementById( 637 "cfr-notification-header-label" 638 ); 639 const headerLink = this.window.document.getElementById( 640 "cfr-notification-header-link" 641 ); 642 const headerImage = this.window.document.getElementById( 643 "cfr-notification-header-image" 644 ); 645 const footerText = this.window.document.getElementById( 646 "cfr-notification-footer-text" 647 ); 648 const footerLink = this.window.document.getElementById( 649 "cfr-notification-footer-learn-more-link" 650 ); 651 const { primary, secondary } = content.buttons; 652 let primaryActionCallback; 653 let options = {}; 654 let panelTitle; 655 656 headerLabel.value = await this.getStrings(content.heading_text); 657 headerLink.setAttribute( 658 "href", 659 SUMO_BASE_URL + content.info_icon.sumo_path 660 ); 661 headerImage.setAttribute( 662 "tooltiptext", 663 await this.getStrings(content.info_icon.label, "tooltiptext") 664 ); 665 headerLink.onclick = () => 666 this._sendTelemetry({ 667 message_id: id, 668 bucket_id: content.bucket_id, 669 event: "RATIONALE", 670 ...(modelVersion ? { event_context: { modelVersion } } : {}), 671 }); 672 // Use the message layout as a CSS selector to hide different parts of the 673 // notification template markup 674 this.window.document 675 .getElementById("contextual-feature-recommendation-notification") 676 .setAttribute("data-notification-category", content.layout); 677 this.window.document 678 .getElementById("contextual-feature-recommendation-notification") 679 .setAttribute("data-notification-bucket", content.bucket_id); 680 681 switch (content.layout) { 682 case "icon_and_message": 683 const author = this.window.document.getElementById( 684 "cfr-notification-author" 685 ); 686 author.textContent = await this.getStrings(content.text); 687 primaryActionCallback = () => { 688 this._blockMessage(id); 689 this.dispatchUserAction(primary.action); 690 this.hideAddressBarNotifier(); 691 this._sendTelemetry({ 692 message_id: id, 693 bucket_id: content.bucket_id, 694 event: "ENABLE", 695 ...(modelVersion ? { event_context: { modelVersion } } : {}), 696 }); 697 RecommendationMap.delete(browser); 698 }; 699 700 let getIcon = () => { 701 if (content.icon_dark_theme && this.isDarkTheme) { 702 return content.icon_dark_theme; 703 } 704 return content.icon; 705 }; 706 707 let learnMoreURL = content.learn_more 708 ? SUMO_BASE_URL + content.learn_more 709 : null; 710 711 panelTitle = await this.getStrings(content.heading_text); 712 options = { 713 popupIconURL: getIcon(), 714 popupIconClass: content.icon_class, 715 learnMoreURL, 716 }; 717 break; 718 case "message_and_animation": 719 footerText.textContent = await this.getStrings(content.text); 720 const stepsContainerId = "cfr-notification-feature-steps"; 721 let stepsContainer = this.window.document.getElementById( 722 stepsContainerId 723 ); 724 primaryActionCallback = () => { 725 this._blockMessage(id); 726 this.dispatchUserAction(primary.action); 727 this.hideAddressBarNotifier(); 728 this._sendTelemetry({ 729 message_id: id, 730 bucket_id: content.bucket_id, 731 event: "PIN", 732 ...(modelVersion ? { event_context: { modelVersion } } : {}), 733 }); 734 RecommendationMap.delete(browser); 735 }; 736 panelTitle = await this.getStrings(content.heading_text); 737 738 if (content.descriptionDetails) { 739 if (stepsContainer) { 740 // If it exists we need to empty it 741 stepsContainer.remove(); 742 stepsContainer = stepsContainer.cloneNode(false); 743 } else { 744 stepsContainer = this.window.document.createXULElement("vbox"); 745 stepsContainer.setAttribute("id", stepsContainerId); 746 } 747 footerText.parentNode.appendChild(stepsContainer); 748 for (let step of content.descriptionDetails.steps) { 749 // This li is a generic xul element with custom styling 750 const li = this.window.document.createXULElement("li"); 751 RemoteL10n.l10n.setAttributes(li, step.string_id); 752 stepsContainer.appendChild(li); 753 } 754 await RemoteL10n.l10n.translateElements([...stepsContainer.children]); 755 } 756 757 await this._renderPinTabAnimation(); 758 break; 759 default: 760 panelTitle = await this.getStrings(content.addon.title); 761 await this._setAddonAuthorAndRating(this.window.document, content); 762 // Main body content of the dropdown 763 footerText.textContent = await this.getStrings(content.text); 764 options = { popupIconURL: content.addon.icon }; 765 766 footerLink.value = await this.getStrings({ 767 string_id: "cfr-doorhanger-extension-learn-more-link", 768 }); 769 footerLink.setAttribute("href", content.addon.amo_url); 770 footerLink.onclick = () => 771 this._sendTelemetry({ 772 message_id: id, 773 bucket_id: content.bucket_id, 774 event: "LEARN_MORE", 775 ...(modelVersion ? { event_context: { modelVersion } } : {}), 776 }); 777 778 primaryActionCallback = async () => { 779 // eslint-disable-next-line no-use-before-define 780 primary.action.data.url = await CFRPageActions._fetchLatestAddonVersion( 781 content.addon.id 782 ); 783 this._blockMessage(id); 784 this.dispatchUserAction(primary.action); 785 this.hideAddressBarNotifier(); 786 this._sendTelemetry({ 787 message_id: id, 788 bucket_id: content.bucket_id, 789 event: "INSTALL", 790 ...(modelVersion ? { event_context: { modelVersion } } : {}), 791 }); 792 RecommendationMap.delete(browser); 793 }; 794 } 795 796 const primaryBtnStrings = await this.getStrings(primary.label); 797 const mainAction = { 798 label: primaryBtnStrings, 799 accessKey: primaryBtnStrings.attributes.accesskey, 800 callback: primaryActionCallback, 801 }; 802 803 let _renderSecondaryButtonAction = async (event, button) => { 804 let label = await this.getStrings(button.label); 805 let { attributes } = label; 806 807 return { 808 label, 809 accessKey: attributes.accesskey, 810 callback: () => { 811 if (button.action) { 812 this.dispatchUserAction(button.action); 813 } else { 814 this._blockMessage(id); 815 this.hideAddressBarNotifier(); 816 RecommendationMap.delete(browser); 817 } 818 819 this._sendTelemetry({ 820 message_id: id, 821 bucket_id: content.bucket_id, 822 event, 823 ...(modelVersion ? { event_context: { modelVersion } } : {}), 824 }); 825 // We want to collapse if needed when we dismiss 826 this._collapse(); 827 }, 828 }; 829 }; 830 831 // For each secondary action, define default telemetry event 832 const defaultSecondaryEvent = ["DISMISS", "BLOCK", "MANAGE"]; 833 const secondaryActions = await Promise.all( 834 secondary.map((button, i) => { 835 return _renderSecondaryButtonAction( 836 button.event || defaultSecondaryEvent[i], 837 button 838 ); 839 }) 840 ); 841 842 // If the recommendation button is focused, it was probably activated via 843 // the keyboard. Therefore, focus the first element in the notification when 844 // it appears. 845 // We don't use the autofocus option provided by PopupNotifications.show 846 // because it doesn't focus the first element; i.e. the user still has to 847 // press tab once. That's not good enough, especially for screen reader 848 // users. Instead, we handle this ourselves in _popupStateChange. 849 this._autoFocus = this.window.document.activeElement === this.container; 850 851 // Actually show the notification 852 this.currentNotification = this.window.PopupNotifications.show( 853 browser, 854 POPUP_NOTIFICATION_ID, 855 panelTitle, 856 "cfr", 857 mainAction, 858 secondaryActions, 859 { 860 ...options, 861 hideClose: true, 862 eventCallback: this._popupStateChange, 863 } 864 ); 865 } 866 867 _executeNotifierAction(browser, message) { 868 switch (message.content.layout) { 869 case "chiclet_open_url": 870 this._dispatchToASRouter( 871 { 872 type: "USER_ACTION", 873 data: { 874 type: "OPEN_URL", 875 data: { 876 args: message.content.action.url, 877 where: message.content.action.where, 878 }, 879 }, 880 }, 881 this.window 882 ); 883 break; 884 } 885 886 this._blockMessage(message.id); 887 this.hideAddressBarNotifier(); 888 RecommendationMap.delete(browser); 889 } 890 891 /** 892 * Respond to a user click on the recommendation by showing a doorhanger/ 893 * popup notification or running the action defined in the message 894 */ 895 async _cfrUrlbarButtonClick(event) { 896 const browser = this.window.gBrowser.selectedBrowser; 897 if (!RecommendationMap.has(browser)) { 898 // There's no recommendation for this browser, so the user shouldn't have 899 // been able to click 900 this.hideAddressBarNotifier(); 901 return; 902 } 903 const message = RecommendationMap.get(browser); 904 const { id, content, modelVersion } = message; 905 906 this._sendTelemetry({ 907 message_id: id, 908 bucket_id: content.bucket_id, 909 event: "CLICK_DOORHANGER", 910 ...(modelVersion ? { event_context: { modelVersion } } : {}), 911 }); 912 913 if (this.shouldShowDoorhanger(message)) { 914 // The recommendation should remain either collapsed or expanded while the 915 // doorhanger is showing 916 this._clearScheduledStateChanges(browser, message); 917 await this.showPopup(); 918 } else { 919 await this._executeNotifierAction(browser, message); 920 } 921 } 922 923 async showPopup() { 924 const browser = this.window.gBrowser.selectedBrowser; 925 const message = RecommendationMap.get(browser); 926 const { content } = message; 927 928 // A hacky way of setting the popup anchor outside the usual url bar icon box 929 // See https://searchfox.org/mozilla-central/rev/847b64cc28b74b44c379f9bff4f415b97da1c6d7/toolkit/modules/PopupNotifications.jsm#42 930 browser.cfrpopupnotificationanchor = 931 this.window.document.getElementById(content.anchor_id) || this.container; 932 933 await this._renderPopup(message, browser); 934 } 935 936 async showMilestonePopup() { 937 const browser = this.window.gBrowser.selectedBrowser; 938 const message = RecommendationMap.get(browser); 939 const { content } = message; 940 941 // A hacky way of setting the popup anchor outside the usual url bar icon box 942 // See https://searchfox.org/mozilla-central/rev/847b64cc28b74b44c379f9bff4f415b97da1c6d7/toolkit/modules/PopupNotifications.jsm#42 943 browser.cfrpopupnotificationanchor = 944 this.window.document.getElementById(content.anchor_id) || this.container; 945 946 await this._renderMilestonePopup(message, browser); 947 return true; 948 } 949} 950 951function isHostMatch(browser, host) { 952 return ( 953 browser.documentURI.scheme.startsWith("http") && 954 browser.documentURI.host === host 955 ); 956} 957 958const CFRPageActions = { 959 // For testing purposes 960 RecommendationMap, 961 PageActionMap, 962 963 /** 964 * To be called from browser.js on a location change, passing in the browser 965 * that's been updated 966 */ 967 updatePageActions(browser) { 968 const win = browser.ownerGlobal; 969 const pageAction = PageActionMap.get(win); 970 if (!pageAction || browser !== win.gBrowser.selectedBrowser) { 971 return; 972 } 973 if (RecommendationMap.has(browser)) { 974 const recommendation = RecommendationMap.get(browser); 975 if ( 976 !recommendation.content.skip_address_bar_notifier && 977 (isHostMatch(browser, recommendation.host) || 978 // If there is no host associated we assume we're back on a tab 979 // that had a CFR message so we should show it again 980 !recommendation.host) 981 ) { 982 // The browser has a recommendation specified with this host, so show 983 // the page action 984 pageAction.showAddressBarNotifier(recommendation); 985 } else if (recommendation.retain) { 986 // Keep the recommendation first time the user navigates away just in 987 // case they will go back to the previous page 988 pageAction.hideAddressBarNotifier(); 989 recommendation.retain = false; 990 } else { 991 // The user has navigated away from the specified host in the given 992 // browser, so the recommendation is no longer valid and should be removed 993 RecommendationMap.delete(browser); 994 pageAction.hideAddressBarNotifier(); 995 } 996 } else { 997 // There's no recommendation specified for this browser, so hide the page action 998 pageAction.hideAddressBarNotifier(); 999 } 1000 }, 1001 1002 /** 1003 * Fetch the URL to the latest add-on xpi so the recommendation can download it. 1004 * @param id The add-on ID 1005 * @return A string for the URL that was fetched 1006 */ 1007 async _fetchLatestAddonVersion(id) { 1008 let url = null; 1009 try { 1010 const response = await fetch(`${ADDONS_API_URL}/${id}/`, { 1011 credentials: "omit", 1012 }); 1013 if (response.status !== 204 && response.ok) { 1014 const json = await response.json(); 1015 url = json.current_version.files[0].url; 1016 } 1017 } catch (e) { 1018 Cu.reportError( 1019 "Failed to get the latest add-on version for this recommendation" 1020 ); 1021 } 1022 return url; 1023 }, 1024 1025 /** 1026 * Show Milestone notification. 1027 * @param browser The browser for the recommendation 1028 * @param recommendation The recommendation to show 1029 * @param dispatchToASRouter A function to dispatch resulting actions to 1030 * @return Did adding the recommendation succeed? 1031 */ 1032 async showMilestone(browser, message, dispatchToASRouter, options = {}) { 1033 let win = null; 1034 const { id, content, personalizedModelVersion } = message; 1035 1036 // If we are forcing via the Admin page, the browser comes in a different format 1037 if (options.force) { 1038 win = browser.browser.ownerGlobal; 1039 RecommendationMap.set(browser.browser, { 1040 id, 1041 content, 1042 retain: true, 1043 modelVersion: personalizedModelVersion, 1044 }); 1045 } else { 1046 win = browser.ownerGlobal; 1047 RecommendationMap.set(browser, { 1048 id, 1049 content, 1050 retain: true, 1051 modelVersion: personalizedModelVersion, 1052 }); 1053 } 1054 1055 if (!PageActionMap.has(win)) { 1056 PageActionMap.set(win, new PageAction(win, dispatchToASRouter)); 1057 } 1058 1059 await PageActionMap.get(win).showMilestonePopup(); 1060 PageActionMap.get(win).addImpression(message); 1061 1062 return true; 1063 }, 1064 1065 /** 1066 * Force a recommendation to be shown. Should only happen via the Admin page. 1067 * @param browser The browser for the recommendation 1068 * @param recommendation The recommendation to show 1069 * @param dispatchToASRouter A function to dispatch resulting actions to 1070 * @return Did adding the recommendation succeed? 1071 */ 1072 async forceRecommendation(browser, recommendation, dispatchToASRouter) { 1073 // If we are forcing via the Admin page, the browser comes in a different format 1074 const win = browser.browser.ownerGlobal; 1075 const { id, content, personalizedModelVersion } = recommendation; 1076 RecommendationMap.set(browser.browser, { 1077 id, 1078 content, 1079 retain: true, 1080 modelVersion: personalizedModelVersion, 1081 }); 1082 if (!PageActionMap.has(win)) { 1083 PageActionMap.set(win, new PageAction(win, dispatchToASRouter)); 1084 } 1085 1086 if (content.skip_address_bar_notifier) { 1087 await PageActionMap.get(win).showPopup(); 1088 PageActionMap.get(win).addImpression(recommendation); 1089 } else { 1090 await PageActionMap.get(win).showAddressBarNotifier(recommendation, true); 1091 } 1092 return true; 1093 }, 1094 1095 /** 1096 * Add a recommendation specific to the given browser and host. 1097 * @param browser The browser for the recommendation 1098 * @param host The host for the recommendation 1099 * @param recommendation The recommendation to show 1100 * @param dispatchToASRouter A function to dispatch resulting actions to 1101 * @return Did adding the recommendation succeed? 1102 */ 1103 async addRecommendation(browser, host, recommendation, dispatchToASRouter) { 1104 const win = browser.ownerGlobal; 1105 if (PrivateBrowsingUtils.isWindowPrivate(win)) { 1106 return false; 1107 } 1108 if ( 1109 browser !== win.gBrowser.selectedBrowser || 1110 // We can have recommendations without URL restrictions 1111 (host && !isHostMatch(browser, host)) 1112 ) { 1113 return false; 1114 } 1115 if (RecommendationMap.has(browser)) { 1116 // Don't replace an existing message 1117 return false; 1118 } 1119 const { id, content, personalizedModelVersion } = recommendation; 1120 RecommendationMap.set(browser, { 1121 id, 1122 host, 1123 content, 1124 retain: true, 1125 modelVersion: personalizedModelVersion, 1126 }); 1127 if (!PageActionMap.has(win)) { 1128 PageActionMap.set(win, new PageAction(win, dispatchToASRouter)); 1129 } 1130 1131 if (content.skip_address_bar_notifier) { 1132 await PageActionMap.get(win).showPopup(); 1133 PageActionMap.get(win).addImpression(recommendation); 1134 } else { 1135 await PageActionMap.get(win).showAddressBarNotifier(recommendation, true); 1136 } 1137 return true; 1138 }, 1139 1140 /** 1141 * Clear all recommendations and hide all PageActions 1142 */ 1143 clearRecommendations() { 1144 // WeakMaps aren't iterable so we have to test all existing windows 1145 for (const win of Services.wm.getEnumerator("navigator:browser")) { 1146 if (win.closed || !PageActionMap.has(win)) { 1147 continue; 1148 } 1149 PageActionMap.get(win).hideAddressBarNotifier(); 1150 } 1151 // WeakMaps don't have a `clear` method 1152 PageActionMap = new WeakMap(); 1153 RecommendationMap = new WeakMap(); 1154 this.PageActionMap = PageActionMap; 1155 this.RecommendationMap = RecommendationMap; 1156 }, 1157 1158 /** 1159 * Reload the l10n Fluent files for all PageActions 1160 */ 1161 reloadL10n() { 1162 for (const win of Services.wm.getEnumerator("navigator:browser")) { 1163 if (win.closed || !PageActionMap.has(win)) { 1164 continue; 1165 } 1166 PageActionMap.get(win).reloadL10n(); 1167 } 1168 }, 1169}; 1170 1171this.PageAction = PageAction; 1172this.CFRPageActions = CFRPageActions; 1173 1174const EXPORTED_SYMBOLS = ["CFRPageActions", "PageAction"]; 1175