tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 };