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