Source code
Revision control
Copy as Markdown
Other Tools
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
// We use importESModule here instead of static import so that
// the Karma test environment won't choke on this module. This
// is because the Karma test environment already stubs out
// XPCOMUtils and overrides importESModule to be a no-op (which
// can't be done for a static import statement).
// eslint-disable-next-line mozilla/use-static-import
const { XPCOMUtils } = ChromeUtils.importESModule(
  "resource://gre/modules/XPCOMUtils.sys.mjs"
);
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
  CustomizableUI:
    "moz-src:///browser/components/customizableui/CustomizableUI.sys.mjs",
  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
  RemoteL10n: "resource:///modules/asrouter/RemoteL10n.sys.mjs",
});
XPCOMUtils.defineLazyServiceGetter(
  lazy,
  "TrackingDBService",
  "@mozilla.org/tracking-db-service;1",
  Ci.nsITrackingDBService
);
XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "milestones",
  "browser.contentblocking.cfr-milestone.milestones",
  "[]",
  null,
  JSON.parse
);
const POPUP_NOTIFICATION_ID = "contextual-feature-recommendation";
const SUMO_BASE_URL = Services.urlFormatter.formatURLPref(
  "app.support.baseURL"
);
const ADDONS_API_URL =
const DELAY_BEFORE_EXPAND_MS = 1000;
const CATEGORY_ICONS = {
  cfrAddons: "webextensions-icon",
  cfrFeatures: "recommendations-icon",
  cfrHeartbeat: "highlights-icon",
};
/**
 * A WeakMap from browsers to {host, recommendation} pairs. Recommendations are
 * defined in the ExtensionDoorhanger.schema.json.
 *
 * A recommendation is specific to a browser and host and is active until the
 * given browser is closed or the user navigates (within that browser) away from
 * the host.
 */
let RecommendationMap = new WeakMap();
/**
 * A WeakMap from windows to their CFR PageAction.
 */
let PageActionMap = new WeakMap();
/**
 * We need one PageAction for each window
 */
export class PageAction {
  constructor(win, dispatchCFRAction) {
    this.window = win;
    this.urlbar = win.gURLBar; // The global URLBar object
    this.urlbarinput = win.gURLBar.textbox; // The URLBar DOM node
    this.container = win.document.getElementById(
      "contextual-feature-recommendation"
    );
    this.button = win.document.getElementById("cfr-button");
    this.label = win.document.getElementById("cfr-label");
    // This should NOT be use directly to dispatch message-defined actions attached to buttons.
    // Please use dispatchUserAction instead.
    this._dispatchCFRAction = dispatchCFRAction;
    this._popupStateChange = this._popupStateChange.bind(this);
    this._collapse = this._collapse.bind(this);
    this._cfrUrlbarButtonClick = this._cfrUrlbarButtonClick.bind(this);
    this._executeNotifierAction = this._executeNotifierAction.bind(this);
    this.dispatchUserAction = this.dispatchUserAction.bind(this);
    // Saved timeout IDs for scheduled state changes, so they can be cancelled
    this.stateTransitionTimeoutIDs = [];
    ChromeUtils.defineLazyGetter(this, "isDarkTheme", () => {
      try {
        return this.window.document.documentElement.hasAttribute(
          "lwt-toolbar-field-brighttext"
        );
      } catch (e) {
        return false;
      }
    });
  }
  addImpression(recommendation) {
    this._dispatchImpression(recommendation);
    // Only send an impression ping upon the first expansion.
    // Note that when the user clicks on the "show" button on the asrouter admin
    // page (both `bucket_id` and `id` will be set as null), we don't want to send
    // the impression ping in that case.
    if (!!recommendation.id && !!recommendation.content.bucket_id) {
      this._sendTelemetry({
        message_id: recommendation.id,
        bucket_id: recommendation.content.bucket_id,
        event: "IMPRESSION",
      });
    }
  }
  reloadL10n() {
    lazy.RemoteL10n.reloadL10n();
  }
  async showAddressBarNotifier(recommendation, shouldExpand = false) {
    this.container.hidden = false;
    let notificationText = await this.getStrings(
      recommendation.content.notification_text
    );
    this.label.value = notificationText;
    if (notificationText.attributes) {
      this.button.setAttribute(
        "tooltiptext",
        notificationText.attributes.tooltiptext
      );
      // For a11y, we want the more descriptive text.
      this.container.setAttribute(
        "aria-label",
        notificationText.attributes.tooltiptext
      );
    }
    this.container.setAttribute(
      "data-cfr-icon",
      CATEGORY_ICONS[recommendation.content.category]
    );
    if (recommendation.content.active_color) {
      this.container.style.setProperty(
        "--cfr-active-color",
        recommendation.content.active_color
      );
    }
    if (recommendation.content.active_text_color) {
      this.container.style.setProperty(
        "--cfr-active-text-color",
        recommendation.content.active_text_color
      );
    }
    // Wait for layout to flush to avoid a synchronous reflow then calculate the
    // label width. We can safely get the width even though the recommendation is
    // collapsed; the label itself remains full width (with its overflow hidden)
    let [{ width }] = await this.window.promiseDocumentFlushed(() =>
      this.label.getClientRects()
    );
    this.urlbarinput.style.setProperty("--cfr-label-width", `${width}px`);
    this.container.addEventListener("click", this._cfrUrlbarButtonClick);
    // Collapse the recommendation on url bar focus in order to free up more
    // space to display and edit the url
    this.urlbar.addEventListener("focus", this._collapse);
    if (shouldExpand) {
      this._clearScheduledStateChanges();
      // After one second, expand
      this._expand(DELAY_BEFORE_EXPAND_MS);
      this.addImpression(recommendation);
    }
    if (notificationText.attributes) {
      this.window.A11yUtils.announce({
        raw: notificationText.attributes["a11y-announcement"],
        source: this.container,
      });
    }
  }
  hideAddressBarNotifier() {
    this.container.hidden = true;
    this._clearScheduledStateChanges();
    this.urlbarinput.removeAttribute("cfr-recommendation-state");
    this.container.removeEventListener("click", this._cfrUrlbarButtonClick);
    this.urlbar.removeEventListener("focus", this._collapse);
    if (this.currentNotification) {
      this.window.PopupNotifications.remove(this.currentNotification);
      this.currentNotification = null;
    }
  }
  _expand(delay) {
    if (delay > 0) {
      this.stateTransitionTimeoutIDs.push(
        this.window.setTimeout(() => {
          this.urlbarinput.setAttribute("cfr-recommendation-state", "expanded");
        }, delay)
      );
    } else {
      // Non-delayed state change overrides any scheduled state changes
      this._clearScheduledStateChanges();
      this.urlbarinput.setAttribute("cfr-recommendation-state", "expanded");
    }
  }
  _collapse(delay) {
    if (delay > 0) {
      this.stateTransitionTimeoutIDs.push(
        this.window.setTimeout(() => {
          if (
            this.urlbarinput.getAttribute("cfr-recommendation-state") ===
            "expanded"
          ) {
            this.urlbarinput.setAttribute(
              "cfr-recommendation-state",
              "collapsed"
            );
          }
        }, delay)
      );
    } else {
      // Non-delayed state change overrides any scheduled state changes
      this._clearScheduledStateChanges();
      if (
        this.urlbarinput.getAttribute("cfr-recommendation-state") === "expanded"
      ) {
        this.urlbarinput.setAttribute("cfr-recommendation-state", "collapsed");
      }
    }
  }
  _clearScheduledStateChanges() {
    while (this.stateTransitionTimeoutIDs.length) {
      // clearTimeout is safe even with invalid/expired IDs
      this.window.clearTimeout(this.stateTransitionTimeoutIDs.pop());
    }
  }
  // This is called when the popup closes as a result of interaction _outside_
  // the popup, e.g. by hitting <esc>
  _popupStateChange(state) {
    if (state === "shown") {
      if (this._autoFocus) {
        this.window.document.commandDispatcher.advanceFocusIntoSubtree(
          this.currentNotification.owner.panel
        );
        this._autoFocus = false;
      }
    } else if (state === "removed") {
      if (this.currentNotification) {
        this.window.PopupNotifications.remove(this.currentNotification);
        this.currentNotification = null;
      }
    } else if (state === "dismissed") {
      const message = RecommendationMap.get(this.currentNotification?.browser);
      this._sendTelemetry({
        message_id: message?.id,
        bucket_id: message?.content.bucket_id,
        event: "DISMISS",
      });
      this._collapse();
    }
  }
  shouldShowDoorhanger(recommendation) {
    if (recommendation.content.layout === "chiclet_open_url") {
      return false;
    }
    return true;
  }
  dispatchUserAction(action) {
    this._dispatchCFRAction(
      { type: "USER_ACTION", data: action },
      this.window.gBrowser.selectedBrowser
    );
  }
  _dispatchImpression(message) {
    this._dispatchCFRAction({ type: "IMPRESSION", data: message });
  }
  _sendTelemetry(ping) {
    const data = { action: "cfr_user_event", source: "CFR", ...ping };
    if (lazy.PrivateBrowsingUtils.isWindowPrivate(this.window)) {
      data.is_private = true;
    }
    this._dispatchCFRAction({
      type: "DOORHANGER_TELEMETRY",
      data,
    });
  }
  _blockMessage(messageID) {
    this._dispatchCFRAction({
      type: "BLOCK_MESSAGE_BY_ID",
      data: { id: messageID },
    });
  }
  maybeLoadCustomElement(win) {
    if (!win.customElements.get("remote-text")) {
      Services.scriptloader.loadSubScript(
        "chrome://browser/content/asrouter/components/remote-text.js",
        win
      );
    }
  }
  /**
   * getStrings - Handles getting the localized strings vs message overrides.
   *              If string_id is not defined it assumes you passed in an override
   *              message and it just returns it.
   *              If subAttribute is provided, the string for it is returned.
   * @return A string. One of 1) passed in string 2) a String object with
   *         attributes property if there are attributes 3) the sub attribute.
   */
  async getStrings(string, subAttribute = "") {
    if (!string.string_id) {
      if (subAttribute) {
        if (string.attributes) {
          return string.attributes[subAttribute];
        }
        console.error(`String ${string.value} does not contain any attributes`);
        return subAttribute;
      }
      if (typeof string.value === "string") {
        const stringWithAttributes = new String(string.value); // eslint-disable-line no-new-wrappers
        stringWithAttributes.attributes = string.attributes;
        return stringWithAttributes;
      }
      return string;
    }
    const [localeStrings] = await lazy.RemoteL10n.l10n.formatMessages([
      {
        id: string.string_id,
        args: string.args,
      },
    ]);
    const mainString = new String(localeStrings.value); // eslint-disable-line no-new-wrappers
    if (localeStrings.attributes) {
      const attributes = localeStrings.attributes.reduce((acc, attribute) => {
        acc[attribute.name] = attribute.value;
        return acc;
      }, {});
      mainString.attributes = attributes;
    }
    return subAttribute ? mainString.attributes[subAttribute] : mainString;
  }
  async _setAddonRating(document, content) {
    const footerFilledStars = this.window.document.getElementById(
      "cfr-notification-footer-filled-stars"
    );
    const footerEmptyStars = this.window.document.getElementById(
      "cfr-notification-footer-empty-stars"
    );
    const footerUsers = this.window.document.getElementById(
      "cfr-notification-footer-users"
    );
    const rating = content.addon?.rating;
    if (rating) {
      const MAX_RATING = 5;
      const STARS_WIDTH = 16 * MAX_RATING;
      const calcWidth = stars => `${(stars / MAX_RATING) * STARS_WIDTH}px`;
      const filledWidth =
        rating <= MAX_RATING ? calcWidth(rating) : calcWidth(MAX_RATING);
      const emptyWidth =
        rating <= MAX_RATING ? calcWidth(MAX_RATING - rating) : calcWidth(0);
      footerFilledStars.style.width = filledWidth;
      footerEmptyStars.style.width = emptyWidth;
      const ratingString = await this.getStrings(
        {
          string_id: "cfr-doorhanger-extension-rating",
          args: { total: rating },
        },
        "tooltiptext"
      );
      footerFilledStars.setAttribute("tooltiptext", ratingString);
      footerEmptyStars.setAttribute("tooltiptext", ratingString);
    } else {
      footerFilledStars.style.width = "";
      footerEmptyStars.style.width = "";
      footerFilledStars.removeAttribute("tooltiptext");
      footerEmptyStars.removeAttribute("tooltiptext");
    }
    const users = content.addon?.users;
    if (users) {
      footerUsers.setAttribute("value", users);
      footerUsers.hidden = false;
    } else {
      // Prevent whitespace around empty label from affecting other spacing
      footerUsers.hidden = true;
      footerUsers.removeAttribute("value");
    }
  }
  _createElementAndAppend({ type, id }, parent) {
    let element = this.window.document.createXULElement(type);
    if (id) {
      element.setAttribute("id", id);
    }
    parent.appendChild(element);
    return element;
  }
  async _renderMilestonePopup(message, browser) {
    this.maybeLoadCustomElement(this.window);
    let { content, id } = message;
    let { primary, secondary } = content.buttons;
    let earliestDate = await lazy.TrackingDBService.getEarliestRecordedDate();
    let timestamp = earliestDate ?? new Date().getTime();
    let panelTitle = "";
    let headerLabel = this.window.document.getElementById(
      "cfr-notification-header-label"
    );
    let reachedMilestone = 0;
    let totalSaved = await lazy.TrackingDBService.sumAllEvents();
    for (let milestone of lazy.milestones) {
      if (totalSaved >= milestone) {
        reachedMilestone = milestone;
      }
    }
    if (headerLabel.firstChild) {
      headerLabel.firstChild.remove();
    }
    headerLabel.appendChild(
      lazy.RemoteL10n.createElement(this.window.document, "span", {
        content: message.content.heading_text,
        attributes: {
          blockedCount: reachedMilestone,
          date: timestamp,
        },
      })
    );
    // Use the message layout as a CSS selector to hide different parts of the
    // notification template markup
    this.window.document
      .getElementById("contextual-feature-recommendation-notification")
      .setAttribute("data-notification-category", content.layout);
    this.window.document
      .getElementById("contextual-feature-recommendation-notification")
      .setAttribute("data-notification-bucket", content.bucket_id);
    let primaryBtnString = await this.getStrings(primary.label);
    let primaryActionCallback = () => {
      this.dispatchUserAction(primary.action);
      this._sendTelemetry({
        message_id: id,
        bucket_id: content.bucket_id,
        event: "CLICK_BUTTON",
      });
      RecommendationMap.delete(browser);
      // Invalidate the pref after the user interacts with the button.
      // We don't need to show the illustration in the privacy panel.
      Services.prefs.clearUserPref(
        "browser.contentblocking.cfr-milestone.milestone-shown-time"
      );
    };
    let secondaryBtnString = await this.getStrings(secondary[0].label);
    let secondaryActionsCallback = () => {
      this.dispatchUserAction(secondary[0].action);
      this._sendTelemetry({
        message_id: id,
        bucket_id: content.bucket_id,
        event: "DISMISS",
      });
      RecommendationMap.delete(browser);
    };
    let mainAction = {
      label: primaryBtnString,
      accessKey: primaryBtnString.attributes.accesskey,
      callback: primaryActionCallback,
    };
    let secondaryActions = [
      {
        label: secondaryBtnString,
        accessKey: secondaryBtnString.attributes.accesskey,
        callback: secondaryActionsCallback,
      },
    ];
    // Actually show the notification
    this.currentNotification = this.window.PopupNotifications.show(
      browser,
      POPUP_NOTIFICATION_ID,
      panelTitle,
      "cfr",
      mainAction,
      secondaryActions,
      {
        hideClose: true,
        persistWhileVisible: true,
        recordTelemetryInPrivateBrowsing: content.show_in_private_browsing,
      }
    );
    Services.prefs.setIntPref(
      "browser.contentblocking.cfr-milestone.milestone-achieved",
      reachedMilestone
    );
    Services.prefs.setStringPref(
      "browser.contentblocking.cfr-milestone.milestone-shown-time",
      Date.now().toString()
    );
  }
  // eslint-disable-next-line max-statements
  async _renderPopup(message, browser) {
    this.maybeLoadCustomElement(this.window);
    const { id, content } = message;
    const headerLabel = this.window.document.getElementById(
      "cfr-notification-header-label"
    );
    const headerLink = this.window.document.getElementById(
      "cfr-notification-header-link"
    );
    const headerImage = this.window.document.getElementById(
      "cfr-notification-header-image"
    );
    const footerText = this.window.document.getElementById(
      "cfr-notification-footer-text"
    );
    const footerLink = this.window.document.getElementById(
      "cfr-notification-footer-learn-more-link"
    );
    const { primary, secondary } = content.buttons;
    let primaryActionCallback;
    let persistent = !!content.persistent_doorhanger;
    let options = {
      persistent,
      persistWhileVisible: persistent,
      recordTelemetryInPrivateBrowsing: content.show_in_private_browsing,
    };
    let panelTitle;
    headerLabel.value = await this.getStrings(content.heading_text);
    if (content.info_icon) {
      headerLink.setAttribute(
        "href",
        SUMO_BASE_URL + content.info_icon.sumo_path
      );
      headerImage.setAttribute(
        "tooltiptext",
        await this.getStrings(content.info_icon.label, "tooltiptext")
      );
    }
    headerLink.onclick = () =>
      this._sendTelemetry({
        message_id: id,
        bucket_id: content.bucket_id,
        event: "RATIONALE",
      });
    // Use the message layout as a CSS selector to hide different parts of the
    // notification template markup
    this.window.document
      .getElementById("contextual-feature-recommendation-notification")
      .setAttribute("data-notification-category", content.layout);
    this.window.document
      .getElementById("contextual-feature-recommendation-notification")
      .setAttribute("data-notification-bucket", content.bucket_id);
    const author = this.window.document.getElementById(
      "cfr-notification-author"
    );
    if (author.firstChild) {
      author.firstChild.remove();
    }
    switch (content.layout) {
      case "icon_and_message": {
        // Clearing content and styles that may have been set by a prior addon_recommendation CFR
        this._setAddonRating(this.window.document, content);
        author.appendChild(
          lazy.RemoteL10n.createElement(this.window.document, "span", {
            content: content.text,
          })
        );
        primaryActionCallback = () => {
          this._blockMessage(id);
          this.dispatchUserAction(primary.action);
          this.hideAddressBarNotifier();
          this._sendTelemetry({
            message_id: id,
            bucket_id: content.bucket_id,
            event: "ENABLE",
          });
          RecommendationMap.delete(browser);
        };
        let getIcon = () => {
          if (content.icon_dark_theme && this.isDarkTheme) {
            return content.icon_dark_theme;
          }
          return content.icon;
        };
        let learnMoreURL = content.learn_more
          ? SUMO_BASE_URL + content.learn_more
          : null;
        panelTitle = await this.getStrings(content.heading_text);
        options = {
          popupIconURL: getIcon(),
          popupIconClass: content.icon_class,
          learnMoreURL,
          ...options,
        };
        break;
      }
      default: {
        const authorText = await this.getStrings({
          string_id: "cfr-doorhanger-extension-author",
          args: { name: content.addon.author },
        });
        panelTitle = await this.getStrings(content.addon.title);
        await this._setAddonRating(this.window.document, content);
        if (footerText.firstChild) {
          footerText.firstChild.remove();
        }
        if (footerText.lastChild) {
          footerText.lastChild.remove();
        }
        // Main body content of the dropdown
        footerText.appendChild(
          lazy.RemoteL10n.createElement(this.window.document, "span", {
            content: content.text,
          })
        );
        footerLink.value = await this.getStrings({
          string_id: "cfr-doorhanger-extension-learn-more-link",
        });
        footerLink.setAttribute("href", content.addon.amo_url);
        footerLink.onclick = () =>
          this._sendTelemetry({
            message_id: id,
            bucket_id: content.bucket_id,
            event: "LEARN_MORE",
          });
        footerText.appendChild(footerLink);
        options = {
          popupIconURL: content.addon.icon,
          popupIconClass: content.icon_class,
          name: authorText,
          ...options,
        };
        primaryActionCallback = async () => {
          primary.action.data.url =
            // eslint-disable-next-line no-use-before-define
            await CFRPageActions._fetchLatestAddonVersion(content.addon.id);
          this._blockMessage(id);
          this.dispatchUserAction(primary.action);
          this.hideAddressBarNotifier();
          this._sendTelemetry({
            message_id: id,
            bucket_id: content.bucket_id,
            event: "INSTALL",
          });
          RecommendationMap.delete(browser);
        };
      }
    }
    const primaryBtnStrings = await this.getStrings(primary.label);
    const mainAction = {
      label: primaryBtnStrings,
      accessKey: primaryBtnStrings.attributes.accesskey,
      callback: primaryActionCallback,
    };
    let _renderSecondaryButtonAction = async (event, button) => {
      let label = await this.getStrings(button.label);
      let { attributes } = label;
      return {
        label,
        accessKey: attributes.accesskey,
        callback: () => {
          if (button.action) {
            this.dispatchUserAction(button.action);
          } else {
            this._blockMessage(id);
            this.hideAddressBarNotifier();
            RecommendationMap.delete(browser);
          }
          this._sendTelemetry({
            message_id: id,
            bucket_id: content.bucket_id,
            event,
          });
          // We want to collapse if needed when we dismiss
          this._collapse();
        },
      };
    };
    // For each secondary action, define default telemetry event
    const defaultSecondaryEvent = ["DISMISS", "BLOCK", "MANAGE"];
    const secondaryActions = await Promise.all(
      secondary.map((button, i) => {
        return _renderSecondaryButtonAction(
          button.event || defaultSecondaryEvent[i],
          button
        );
      })
    );
    // If the recommendation button is focused, it was probably activated via
    // the keyboard. Therefore, focus the first element in the notification when
    // it appears.
    // We don't use the autofocus option provided by PopupNotifications.show
    // because it doesn't focus the first element; i.e. the user still has to
    // press tab once. That's not good enough, especially for screen reader
    // users. Instead, we handle this ourselves in _popupStateChange.
    this._autoFocus = this.window.document.activeElement === this.container;
    // Actually show the notification
    this.currentNotification = this.window.PopupNotifications.show(
      browser,
      POPUP_NOTIFICATION_ID,
      panelTitle,
      "cfr",
      mainAction,
      secondaryActions,
      {
        ...options,
        hideClose: true,
        eventCallback: this._popupStateChange,
      }
    );
  }
  _executeNotifierAction(browser, message) {
    switch (message.content.layout) {
      case "chiclet_open_url":
        this._dispatchCFRAction(
          {
            type: "USER_ACTION",
            data: {
              type: "OPEN_URL",
              data: {
                args: message.content.action.url,
                where: message.content.action.where,
              },
            },
          },
          this.window
        );
        break;
    }
    this._blockMessage(message.id);
    this.hideAddressBarNotifier();
    RecommendationMap.delete(browser);
  }
  /**
   * Respond to a user click on the recommendation by showing a doorhanger/
   * popup notification or running the action defined in the message
   */
  async _cfrUrlbarButtonClick() {
    const browser = this.window.gBrowser.selectedBrowser;
    if (!RecommendationMap.has(browser)) {
      // There's no recommendation for this browser, so the user shouldn't have
      // been able to click
      this.hideAddressBarNotifier();
      return;
    }
    const message = RecommendationMap.get(browser);
    const { id, content } = message;
    this._sendTelemetry({
      message_id: id,
      bucket_id: content.bucket_id,
      event: "CLICK_DOORHANGER",
    });
    if (this.shouldShowDoorhanger(message)) {
      // The recommendation should remain either collapsed or expanded while the
      // doorhanger is showing
      this._clearScheduledStateChanges(browser, message);
      await this.showPopup();
    } else {
      await this._executeNotifierAction(browser, message);
    }
  }
  _getVisibleElement(idOrEl) {
    const element =
      typeof idOrEl === "string"
        ? idOrEl && this.window.document.getElementById(idOrEl)
        : idOrEl;
    if (!element) {
      return null; // element doesn't exist at all
    }
    const { visibility, display } = this.window.getComputedStyle(element);
    if (
      !this.window.isElementVisible(element) ||
      visibility !== "visible" ||
      display === "none"
    ) {
      // CSS rules like visibility: hidden or display: none. these result in
      // element being invisible and unclickable.
      return null;
    }
    let widget = lazy.CustomizableUI.getWidget(idOrEl);
    if (
      widget &&
      (this.window.CustomizationHandler.isCustomizing() ||
        widget.areaType?.includes("panel"))
    ) {
      // The element is a customizable widget (a toolbar item, e.g. the
      // reload button or the downloads button). Widgets can be in various
      // areas, like the overflow panel or the customization palette.
      // Widgets in the palette are present in the chrome's DOM during
      // customization, but can't be used.
      return null;
    }
    return element;
  }
  async showPopup() {
    const browser = this.window.gBrowser.selectedBrowser;
    const message = RecommendationMap.get(browser);
    const { content } = message;
    // A hacky way of setting the popup anchor outside the usual url bar icon box
    browser.cfrpopupnotificationanchor =
      this._getVisibleElement(content.anchor_id) ||
      this._getVisibleElement(content.alt_anchor_id) ||
      this._getVisibleElement(this.button) ||
      this._getVisibleElement(this.container);
    await this._renderPopup(message, browser);
  }
  async showMilestonePopup() {
    const browser = this.window.gBrowser.selectedBrowser;
    const message = RecommendationMap.get(browser);
    const { content } = message;
    // A hacky way of setting the popup anchor outside the usual url bar icon box
    browser.cfrpopupnotificationanchor =
      this.window.document.getElementById(content.anchor_id) || this.container;
    await this._renderMilestonePopup(message, browser);
    return true;
  }
}
function isHostMatch(browser, host) {
  return (
    browser.documentURI.scheme.startsWith("http") &&
    browser.documentURI.host === host
  );
}
export const CFRPageActions = {
  // For testing purposes
  RecommendationMap,
  PageActionMap,
  /**
   * To be called from browser.js on a location change, passing in the browser
   * that's been updated
   */
  updatePageActions(browser) {
    const win = browser.ownerGlobal;
    const pageAction = PageActionMap.get(win);
    if (!pageAction || browser !== win.gBrowser.selectedBrowser) {
      return;
    }
    if (RecommendationMap.has(browser)) {
      const recommendation = RecommendationMap.get(browser);
      if (
        !recommendation.content.skip_address_bar_notifier &&
        (isHostMatch(browser, recommendation.host) ||
          // If there is no host associated we assume we're back on a tab
          // that had a CFR message so we should show it again
          !recommendation.host)
      ) {
        // The browser has a recommendation specified with this host, so show
        // the page action
        pageAction.showAddressBarNotifier(recommendation);
      } else if (!recommendation.content.persistent_doorhanger) {
        if (recommendation.retain) {
          // Keep the recommendation first time the user navigates away just in
          // case they will go back to the previous page
          pageAction.hideAddressBarNotifier();
          recommendation.retain = false;
        } else {
          // The user has navigated away from the specified host in the given
          // browser, so the recommendation is no longer valid and should be removed
          RecommendationMap.delete(browser);
          pageAction.hideAddressBarNotifier();
        }
      }
    } else {
      // There's no recommendation specified for this browser, so hide the page action
      pageAction.hideAddressBarNotifier();
    }
  },
  /**
   * Fetch the URL to the latest add-on xpi so the recommendation can download it.
   * @param id          The add-on ID
   * @return            A string for the URL that was fetched
   */
  async _fetchLatestAddonVersion(id) {
    let url = null;
    try {
      const response = await fetch(`${ADDONS_API_URL}/${id}/`, {
        credentials: "omit",
      });
      if (response.status !== 204 && response.ok) {
        const json = await response.json();
        url = json.current_version.files[0].url;
      }
    } catch (e) {
      console.error(
        "Failed to get the latest add-on version for this recommendation"
      );
    }
    return url;
  },
  /**
   * Force a recommendation to be shown. Should only happen via the Admin page.
   * @param browser                 The browser for the recommendation
   * @param recommendation  The recommendation to show
   * @param dispatchCFRAction      A function to dispatch resulting actions to
   * @return                        Did adding the recommendation succeed?
   */
  async forceRecommendation(browser, recommendation, dispatchCFRAction) {
    if (!browser) {
      return false;
    }
    // If we are forcing via the Admin page, the browser comes in a different format
    const win = browser.ownerGlobal;
    const { id, content } = recommendation;
    RecommendationMap.set(browser, {
      id,
      content,
      retain: true,
    });
    if (!PageActionMap.has(win)) {
      PageActionMap.set(win, new PageAction(win, dispatchCFRAction));
    }
    if (content.skip_address_bar_notifier) {
      if (recommendation.template === "milestone_message") {
        await PageActionMap.get(win).showMilestonePopup();
        PageActionMap.get(win).addImpression(recommendation);
      } else {
        await PageActionMap.get(win).showPopup();
        PageActionMap.get(win).addImpression(recommendation);
      }
    } else {
      await PageActionMap.get(win).showAddressBarNotifier(recommendation, true);
    }
    return true;
  },
  /**
   * Add a recommendation specific to the given browser and host.
   * @param browser                 The browser for the recommendation
   * @param host                    The host for the recommendation
   * @param recommendation          The recommendation to show
   * @param dispatchCFRAction       A function to dispatch resulting actions to
   * @return                        Did adding the recommendation succeed?
   */
  async addRecommendation(browser, host, recommendation, dispatchCFRAction) {
    if (!browser) {
      return false;
    }
    const win = browser.ownerGlobal;
    if (
      browser !== win.gBrowser.selectedBrowser ||
      // We can have recommendations without URL restrictions
      (host && !isHostMatch(browser, host))
    ) {
      return false;
    }
    if (RecommendationMap.has(browser)) {
      // Don't replace an existing message
      return false;
    }
    const { id, content } = recommendation;
    if (
      !content.show_in_private_browsing &&
      lazy.PrivateBrowsingUtils.isWindowPrivate(win)
    ) {
      return false;
    }
    RecommendationMap.set(browser, {
      id,
      host,
      content,
      retain: true,
    });
    if (!PageActionMap.has(win)) {
      PageActionMap.set(win, new PageAction(win, dispatchCFRAction));
    }
    if (content.skip_address_bar_notifier) {
      if (recommendation.template === "milestone_message") {
        await PageActionMap.get(win).showMilestonePopup();
        PageActionMap.get(win).addImpression(recommendation);
      } else {
        // Tracking protection messages
        await PageActionMap.get(win).showPopup();
        PageActionMap.get(win).addImpression(recommendation);
      }
    } else {
      // Doorhanger messages
      await PageActionMap.get(win).showAddressBarNotifier(recommendation, true);
    }
    return true;
  },
  /**
   * Clear all recommendations and hide all PageActions
   */
  clearRecommendations() {
    // WeakMaps aren't iterable so we have to test all existing windows
    for (const win of Services.wm.getEnumerator("navigator:browser")) {
      if (win.closed || !PageActionMap.has(win)) {
        continue;
      }
      PageActionMap.get(win).hideAddressBarNotifier();
    }
    // WeakMaps don't have a `clear` method
    PageActionMap = new WeakMap();
    RecommendationMap = new WeakMap();
    this.PageActionMap = PageActionMap;
    this.RecommendationMap = RecommendationMap;
  },
  /**
   * Reload the l10n Fluent files for all PageActions
   */
  reloadL10n() {
    for (const win of Services.wm.getEnumerator("navigator:browser")) {
      if (win.closed || !PageActionMap.has(win)) {
        continue;
      }
      PageActionMap.get(win).reloadL10n();
    }
  },
};