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