mirror of
				https://github.com/mozilla/gecko-dev.git
				synced 2025-11-04 10:18:41 +02:00 
			
		
		
		
	
		
			
				
	
	
		
			1003 lines
		
	
	
	
		
			34 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			1003 lines
		
	
	
	
		
			34 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
/* 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
 | 
						|
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 | 
						|
 | 
						|
/*eslint-env browser*/
 | 
						|
 | 
						|
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
 | 
						|
 | 
						|
const lazy = {};
 | 
						|
XPCOMUtils.defineLazyModuleGetters(lazy, {
 | 
						|
  AboutWelcomeParent: "resource:///actors/AboutWelcomeParent.jsm",
 | 
						|
  ASRouter: "resource://activity-stream/lib/ASRouter.jsm",
 | 
						|
  PageEventManager: "resource://activity-stream/lib/PageEventManager.jsm",
 | 
						|
});
 | 
						|
 | 
						|
const TRANSITION_MS = 500;
 | 
						|
const CONTAINER_ID = "root";
 | 
						|
const BUNDLE_SRC =
 | 
						|
  "resource://activity-stream/aboutwelcome/aboutwelcome.bundle.js";
 | 
						|
 | 
						|
/**
 | 
						|
 * Feature Callout fetches messages relevant to a given source and displays them
 | 
						|
 * in the parent page pointing to the element they describe.
 | 
						|
 */
 | 
						|
export class FeatureCallout {
 | 
						|
  /**
 | 
						|
   * @typedef {Object} FeatureCalloutOptions
 | 
						|
   * @property {Window} win window in which messages will be rendered
 | 
						|
   * @property {string} prefName name of the pref used to track progress through
 | 
						|
   *   a given feature tour, e.g. "browser.pdfjs.feature-tour"
 | 
						|
   * @property {string} [page] string to pass as the page when requesting
 | 
						|
   *   messages from ASRouter and sending telemetry. for browser chrome, the
 | 
						|
   *   string "chrome" is used
 | 
						|
   * @property {MozBrowser} [browser] <browser> element responsible for the
 | 
						|
   *   feature callout. for content pages, this is the browser element that the
 | 
						|
   *   callout is being shown in. for chrome, this is the active browser
 | 
						|
   */
 | 
						|
 | 
						|
  /** @param {FeatureCalloutOptions} options */
 | 
						|
  constructor({ win, prefName, page, browser } = {}) {
 | 
						|
    this.win = win;
 | 
						|
    this.doc = win.document;
 | 
						|
    this.browser = browser || this.win.docShell.chromeEventHandler;
 | 
						|
    this.config = null;
 | 
						|
    this.loadingConfig = false;
 | 
						|
    this.message = null;
 | 
						|
    this.currentScreen = null;
 | 
						|
    this.renderObserver = null;
 | 
						|
    this.savedActiveElement = null;
 | 
						|
    this.ready = false;
 | 
						|
    this._positionListenersRegistered = false;
 | 
						|
    this.AWSetup = false;
 | 
						|
    this.page = page;
 | 
						|
 | 
						|
    XPCOMUtils.defineLazyPreferenceGetter(
 | 
						|
      this,
 | 
						|
      "featureTourProgress",
 | 
						|
      prefName,
 | 
						|
      '{"screen":"","complete":true}',
 | 
						|
      this._handlePrefChange.bind(this),
 | 
						|
      val => {
 | 
						|
        try {
 | 
						|
          return JSON.parse(val);
 | 
						|
        } catch (error) {
 | 
						|
          return null;
 | 
						|
        }
 | 
						|
      }
 | 
						|
    );
 | 
						|
 | 
						|
    XPCOMUtils.defineLazyPreferenceGetter(
 | 
						|
      this,
 | 
						|
      "cfrFeaturesUserPref",
 | 
						|
      "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features",
 | 
						|
      true,
 | 
						|
      function(pref, previous, latest) {
 | 
						|
        if (latest) {
 | 
						|
          this.showFeatureCallout();
 | 
						|
        } else {
 | 
						|
          this._handlePrefChange();
 | 
						|
        }
 | 
						|
      }.bind(this)
 | 
						|
    );
 | 
						|
    this.featureTourProgress; // Load initial value of progress pref
 | 
						|
 | 
						|
    // When the window is focused, ensure tour is synced with tours in any other
 | 
						|
    // instances of the parent page. This does not apply when the Callout is
 | 
						|
    // shown in the browser chrome.
 | 
						|
    if (this.page !== "chrome") {
 | 
						|
      this.win.addEventListener("visibilitychange", this);
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Get the page event manager and instantiate it if necessary. Only used by
 | 
						|
   * _attachPageEventListeners, since we don't want to do this unnecessary work
 | 
						|
   * if a message with page event listeners hasn't loaded. Other consumers
 | 
						|
   * should use `this._pageEventManager?.property` instead.
 | 
						|
   */
 | 
						|
  get _loadPageEventManager() {
 | 
						|
    if (!this._pageEventManager) {
 | 
						|
      this._pageEventManager = new lazy.PageEventManager(this.doc);
 | 
						|
    }
 | 
						|
    return this._pageEventManager;
 | 
						|
  }
 | 
						|
 | 
						|
  _addPositionListeners() {
 | 
						|
    if (!this._positionListenersRegistered) {
 | 
						|
      this.win.addEventListener("resize", this);
 | 
						|
      const parentEl = this.doc.querySelector(
 | 
						|
        this.currentScreen?.parent_selector
 | 
						|
      );
 | 
						|
      parentEl?.addEventListener("toggle", this);
 | 
						|
      this._positionListenersRegistered = true;
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  _removePositionListeners() {
 | 
						|
    if (this._positionListenersRegistered) {
 | 
						|
      this.win.removeEventListener("resize", this);
 | 
						|
      const parentEl = this.doc.querySelector(
 | 
						|
        this.currentScreen?.parent_selector
 | 
						|
      );
 | 
						|
      parentEl?.removeEventListener("toggle", this);
 | 
						|
      this._positionListenersRegistered = false;
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  async _handlePrefChange() {
 | 
						|
    if (this.doc.visibilityState === "hidden" || !this.featureTourProgress) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    // If we have more than one screen, it means that we're
 | 
						|
    // displaying a feature tour, and transitions are handled
 | 
						|
    // based on the value of a tour progress pref. Otherwise,
 | 
						|
    // just show the feature callout.
 | 
						|
    if (this.config?.screens.length === 1) {
 | 
						|
      this.showFeatureCallout();
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    // If a pref change results from an event in a Spotlight message,
 | 
						|
    // reload the page to clear the Spotlight and initialize the
 | 
						|
    // feature callout with the next message in the tour.
 | 
						|
    if (this.currentScreen == "spotlight") {
 | 
						|
      this.win.location.reload();
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    let prefVal = this.featureTourProgress;
 | 
						|
    // End the tour according to the tour progress pref or if the user disabled
 | 
						|
    // contextual feature recommendations.
 | 
						|
    if (prefVal.complete || !this.cfrFeaturesUserPref) {
 | 
						|
      this.endTour();
 | 
						|
      this.currentScreen = null;
 | 
						|
    } else if (prefVal.screen !== this.currentScreen?.id) {
 | 
						|
      this.ready = false;
 | 
						|
      const container = this.doc.getElementById(CONTAINER_ID);
 | 
						|
      container?.classList.add("hidden");
 | 
						|
      this._pageEventManager?.clear();
 | 
						|
      // wait for fade out transition
 | 
						|
      this.win.setTimeout(async () => {
 | 
						|
        await this._loadConfig();
 | 
						|
        container?.remove();
 | 
						|
        this._removePositionListeners();
 | 
						|
        this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove();
 | 
						|
        await this._renderCallout();
 | 
						|
      }, TRANSITION_MS);
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  handleEvent(event) {
 | 
						|
    switch (event.type) {
 | 
						|
      case "focus": {
 | 
						|
        let container = this.doc.getElementById(CONTAINER_ID);
 | 
						|
        if (!container) {
 | 
						|
          return;
 | 
						|
        }
 | 
						|
        // If focus has fired on the feature callout window itself, or on something
 | 
						|
        // contained in that window, ignore it, as we can't possibly place the focus
 | 
						|
        // on it after the callout is closd.
 | 
						|
        if (
 | 
						|
          event.target.id === CONTAINER_ID ||
 | 
						|
          (Node.isInstance(event.target) && container.contains(event.target))
 | 
						|
        ) {
 | 
						|
          return;
 | 
						|
        }
 | 
						|
        // Save this so that if the next focus event is re-entering the popup,
 | 
						|
        // then we'll put the focus back here where the user left it once we exit
 | 
						|
        // the feature callout series.
 | 
						|
        this.savedActiveElement = this.doc.activeElement;
 | 
						|
        break;
 | 
						|
      }
 | 
						|
 | 
						|
      case "keypress": {
 | 
						|
        if (event.key !== "Escape") {
 | 
						|
          return;
 | 
						|
        }
 | 
						|
        let container = this.doc.getElementById(CONTAINER_ID);
 | 
						|
        if (!container) {
 | 
						|
          return;
 | 
						|
        }
 | 
						|
        let focusedElement =
 | 
						|
          this.page === "chrome"
 | 
						|
            ? Services.focus.focusedElement
 | 
						|
            : this.doc.activeElement;
 | 
						|
        // If the window has a focused element, let it handle the ESC key instead.
 | 
						|
        if (
 | 
						|
          !focusedElement ||
 | 
						|
          focusedElement === this.doc.body ||
 | 
						|
          focusedElement === this.browser ||
 | 
						|
          container.contains(focusedElement)
 | 
						|
        ) {
 | 
						|
          this.win.AWSendEventTelemetry?.({
 | 
						|
            event: "DISMISS",
 | 
						|
            event_context: {
 | 
						|
              source: `KEY_${event.key}`,
 | 
						|
              page: this.page,
 | 
						|
            },
 | 
						|
            message_id: this.config?.id.toUpperCase(),
 | 
						|
          });
 | 
						|
          this._dismiss();
 | 
						|
          event.preventDefault();
 | 
						|
        }
 | 
						|
        break;
 | 
						|
      }
 | 
						|
 | 
						|
      case "visibilitychange":
 | 
						|
        this._handlePrefChange();
 | 
						|
        break;
 | 
						|
 | 
						|
      case "resize":
 | 
						|
      case "toggle":
 | 
						|
        this._positionCallout();
 | 
						|
        break;
 | 
						|
 | 
						|
      default:
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  _addCalloutLinkElements() {
 | 
						|
    const addStylesheet = href => {
 | 
						|
      if (this.doc.querySelector(`link[href="${href}"]`)) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
      const link = this.doc.head.appendChild(this.doc.createElement("link"));
 | 
						|
      link.rel = "stylesheet";
 | 
						|
      link.href = href;
 | 
						|
    };
 | 
						|
    const addLocalization = hrefs => {
 | 
						|
      hrefs.forEach(href => {
 | 
						|
        // eslint-disable-next-line no-undef
 | 
						|
        this.win.MozXULElement.insertFTLIfNeeded(href);
 | 
						|
      });
 | 
						|
    };
 | 
						|
 | 
						|
    // Update styling to be compatible with about:welcome bundle
 | 
						|
    addStylesheet(
 | 
						|
      "chrome://activity-stream/content/aboutwelcome/aboutwelcome.css"
 | 
						|
    );
 | 
						|
 | 
						|
    addLocalization([
 | 
						|
      "browser/newtab/onboarding.ftl",
 | 
						|
      "browser/spotlight.ftl",
 | 
						|
      "branding/brand.ftl",
 | 
						|
      "toolkit/branding/brandings.ftl",
 | 
						|
      "browser/newtab/asrouter.ftl",
 | 
						|
      "browser/featureCallout.ftl",
 | 
						|
    ]);
 | 
						|
  }
 | 
						|
 | 
						|
  _createContainer() {
 | 
						|
    let parent = this.doc.querySelector(this.currentScreen?.parent_selector);
 | 
						|
    // Don't render the callout if the parent element is not present.
 | 
						|
    // This means the message was misconfigured, mistargeted, or the
 | 
						|
    // content of the parent page is not as expected.
 | 
						|
    if (!parent && !this.currentScreen?.content?.callout_position_override) {
 | 
						|
      if (this.message?.template === "feature_callout") {
 | 
						|
        Services.telemetry.recordEvent(
 | 
						|
          "messaging_experiments",
 | 
						|
          "feature_callout",
 | 
						|
          "create_failed",
 | 
						|
          `${this.message.id || "no_message"}-${this.currentScreen
 | 
						|
            ?.parent_selector || "no_current_screen"}`
 | 
						|
        );
 | 
						|
      }
 | 
						|
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
 | 
						|
    let container = this.doc.createElement("div");
 | 
						|
    container.classList.add(
 | 
						|
      "onboardingContainer",
 | 
						|
      "featureCallout",
 | 
						|
      "callout-arrow",
 | 
						|
      "hidden"
 | 
						|
    );
 | 
						|
    container.id = CONTAINER_ID;
 | 
						|
    // This value is reported as the "page" in about:welcome telemetry
 | 
						|
    container.dataset.page = this.page;
 | 
						|
    container.setAttribute(
 | 
						|
      "aria-describedby",
 | 
						|
      `#${CONTAINER_ID} .welcome-text`
 | 
						|
    );
 | 
						|
    container.tabIndex = 0;
 | 
						|
    this.doc.body.prepend(container);
 | 
						|
    return container;
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Set callout's position relative to parent element
 | 
						|
   */
 | 
						|
  _positionCallout() {
 | 
						|
    const container = this.doc.getElementById(CONTAINER_ID);
 | 
						|
    const parentEl = this.doc.querySelector(
 | 
						|
      this.currentScreen?.parent_selector
 | 
						|
    );
 | 
						|
    const doc = this.doc;
 | 
						|
    // All possible arrow positions
 | 
						|
    // If the position contains a dash, the value before the dash
 | 
						|
    // refers to which edge of the feature callout the arrow points
 | 
						|
    // from. The value after the dash describes where along that edge
 | 
						|
    // the arrow sits, with middle as the default.
 | 
						|
    const arrowPositions = [
 | 
						|
      "top",
 | 
						|
      "bottom",
 | 
						|
      "end",
 | 
						|
      "start",
 | 
						|
      "top-end",
 | 
						|
      "top-start",
 | 
						|
    ];
 | 
						|
    const arrowPosition = this.currentScreen?.content?.arrow_position || "top";
 | 
						|
    // Callout should overlap the parent element by 17px (so the box, not
 | 
						|
    // including the arrow, will overlap by 5px)
 | 
						|
    const arrowWidth = 12;
 | 
						|
    let overlap = 17;
 | 
						|
    // If we have no overlap, we send the callout the same number of pixels
 | 
						|
    // in the opposite direction
 | 
						|
    overlap = this.currentScreen?.content?.noCalloutOverlap
 | 
						|
      ? overlap * -1
 | 
						|
      : overlap;
 | 
						|
    overlap -= arrowWidth;
 | 
						|
    // Is the document layout right to left?
 | 
						|
    const RTL = this.doc.dir === "rtl";
 | 
						|
    const customPosition = this.currentScreen?.content
 | 
						|
      .callout_position_override;
 | 
						|
 | 
						|
    // Early exit if the container doesn't exist,
 | 
						|
    // or if we're missing a parent element and don't have a custom callout position
 | 
						|
    if (!container || (!parentEl && !customPosition)) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    const getOffset = el => {
 | 
						|
      const rect = el.getBoundingClientRect();
 | 
						|
      return {
 | 
						|
        left: rect.left + this.win.scrollX,
 | 
						|
        right: rect.right + this.win.scrollX,
 | 
						|
        top: rect.top + this.win.scrollY,
 | 
						|
        bottom: rect.bottom + this.win.scrollY,
 | 
						|
      };
 | 
						|
    };
 | 
						|
 | 
						|
    const clearPosition = () => {
 | 
						|
      Object.keys(positioners).forEach(position => {
 | 
						|
        container.style[position] = "unset";
 | 
						|
      });
 | 
						|
      arrowPositions.forEach(position => {
 | 
						|
        if (container.classList.contains(`arrow-${position}`)) {
 | 
						|
          container.classList.remove(`arrow-${position}`);
 | 
						|
        }
 | 
						|
        if (container.classList.contains(`arrow-inline-${position}`)) {
 | 
						|
          container.classList.remove(`arrow-inline-${position}`);
 | 
						|
        }
 | 
						|
      });
 | 
						|
    };
 | 
						|
 | 
						|
    const addArrowPositionClassToContainer = finalArrowPosition => {
 | 
						|
      let className;
 | 
						|
      switch (finalArrowPosition) {
 | 
						|
        case "bottom":
 | 
						|
          className = "arrow-bottom";
 | 
						|
          break;
 | 
						|
        case "left":
 | 
						|
          className = "arrow-inline-start";
 | 
						|
          break;
 | 
						|
        case "right":
 | 
						|
          className = "arrow-inline-end";
 | 
						|
          break;
 | 
						|
        case "top-start":
 | 
						|
          className = RTL ? "arrow-top-end" : "arrow-top-start";
 | 
						|
          break;
 | 
						|
        case "top-end":
 | 
						|
          className = RTL ? "arrow-top-start" : "arrow-top-end";
 | 
						|
          break;
 | 
						|
        case "top":
 | 
						|
        default:
 | 
						|
          className = "arrow-top";
 | 
						|
          break;
 | 
						|
      }
 | 
						|
 | 
						|
      container.classList.add(className);
 | 
						|
    };
 | 
						|
 | 
						|
    const addValueToPixelValue = (value, pixelValue) => {
 | 
						|
      return `${Number(pixelValue.split("px")[0]) + value}px`;
 | 
						|
    };
 | 
						|
 | 
						|
    const subtractPixelValueFromValue = (pixelValue, value) => {
 | 
						|
      return `${value - Number(pixelValue.split("px")[0])}px`;
 | 
						|
    };
 | 
						|
 | 
						|
    const overridePosition = () => {
 | 
						|
      // We override _every_ positioner here, because we want to manually set all
 | 
						|
      // container.style.positions in every positioner's "position" function
 | 
						|
      // regardless of the actual arrow position
 | 
						|
      // Note: We override the position functions with new functions here,
 | 
						|
      // but they don't actually get executed until the respective position functions are called
 | 
						|
      // and this function is not executed unless the message has a custom position property.
 | 
						|
 | 
						|
      // We're positioning relative to a parent element's bounds,
 | 
						|
      // if that parent element exists.
 | 
						|
 | 
						|
      for (const position in positioners) {
 | 
						|
        positioners[position].position = () => {
 | 
						|
          if (customPosition.top) {
 | 
						|
            container.style.top = addValueToPixelValue(
 | 
						|
              parentEl.getBoundingClientRect().top,
 | 
						|
              customPosition.top
 | 
						|
            );
 | 
						|
          }
 | 
						|
 | 
						|
          if (customPosition.left) {
 | 
						|
            const leftPosition = addValueToPixelValue(
 | 
						|
              parentEl.getBoundingClientRect().left,
 | 
						|
              customPosition.left
 | 
						|
            );
 | 
						|
 | 
						|
            RTL
 | 
						|
              ? (container.style.right = leftPosition)
 | 
						|
              : (container.style.left = leftPosition);
 | 
						|
          }
 | 
						|
 | 
						|
          if (customPosition.right) {
 | 
						|
            const rightPosition = subtractPixelValueFromValue(
 | 
						|
              customPosition.right,
 | 
						|
              parentEl.getBoundingClientRect().right - container.clientWidth
 | 
						|
            );
 | 
						|
 | 
						|
            RTL
 | 
						|
              ? (container.style.right = rightPosition)
 | 
						|
              : (container.style.left = rightPosition);
 | 
						|
          }
 | 
						|
 | 
						|
          if (customPosition.bottom) {
 | 
						|
            container.style.top = subtractPixelValueFromValue(
 | 
						|
              customPosition.bottom,
 | 
						|
              parentEl.getBoundingClientRect().bottom - container.clientHeight
 | 
						|
            );
 | 
						|
          }
 | 
						|
        };
 | 
						|
      }
 | 
						|
    };
 | 
						|
 | 
						|
    const positioners = {
 | 
						|
      // availableSpace should be the space between the edge of the page in the assumed direction
 | 
						|
      // and the edge of the parent (with the callout being intended to fit between those two edges)
 | 
						|
      // while needed space should be the space necessary to fit the callout container
 | 
						|
      top: {
 | 
						|
        availableSpace() {
 | 
						|
          return (
 | 
						|
            doc.documentElement.clientHeight -
 | 
						|
            getOffset(parentEl).top -
 | 
						|
            parentEl.clientHeight
 | 
						|
          );
 | 
						|
        },
 | 
						|
        neededSpace: container.clientHeight - overlap,
 | 
						|
        position() {
 | 
						|
          // Point to an element above the callout
 | 
						|
          let containerTop =
 | 
						|
            getOffset(parentEl).top + parentEl.clientHeight - overlap;
 | 
						|
          container.style.top = `${Math.max(0, containerTop)}px`;
 | 
						|
          alignHorizontally("center");
 | 
						|
        },
 | 
						|
      },
 | 
						|
      bottom: {
 | 
						|
        availableSpace() {
 | 
						|
          return getOffset(parentEl).top;
 | 
						|
        },
 | 
						|
        neededSpace: container.clientHeight - overlap,
 | 
						|
        position() {
 | 
						|
          // Point to an element below the callout
 | 
						|
          let containerTop =
 | 
						|
            getOffset(parentEl).top - container.clientHeight + overlap;
 | 
						|
          container.style.top = `${Math.max(0, containerTop)}px`;
 | 
						|
          alignHorizontally("center");
 | 
						|
        },
 | 
						|
      },
 | 
						|
      right: {
 | 
						|
        availableSpace() {
 | 
						|
          return getOffset(parentEl).left;
 | 
						|
        },
 | 
						|
        neededSpace: container.clientWidth - overlap,
 | 
						|
        position() {
 | 
						|
          // Point to an element to the right of the callout
 | 
						|
          let containerLeft =
 | 
						|
            getOffset(parentEl).left - container.clientWidth + overlap;
 | 
						|
          container.style.left = `${Math.max(0, containerLeft)}px`;
 | 
						|
          if (container.offsetHeight <= parentEl.offsetHeight) {
 | 
						|
            container.style.top = `${getOffset(parentEl).top}px`;
 | 
						|
          } else {
 | 
						|
            centerVertically();
 | 
						|
          }
 | 
						|
        },
 | 
						|
      },
 | 
						|
      left: {
 | 
						|
        availableSpace() {
 | 
						|
          return doc.documentElement.clientWidth - getOffset(parentEl).right;
 | 
						|
        },
 | 
						|
        neededSpace: container.clientWidth - overlap,
 | 
						|
        position() {
 | 
						|
          // Point to an element to the left of the callout
 | 
						|
          let containerLeft =
 | 
						|
            getOffset(parentEl).left + parentEl.clientWidth - overlap;
 | 
						|
          container.style.left = `${Math.max(0, containerLeft)}px`;
 | 
						|
          if (container.offsetHeight <= parentEl.offsetHeight) {
 | 
						|
            container.style.top = `${getOffset(parentEl).top}px`;
 | 
						|
          } else {
 | 
						|
            centerVertically();
 | 
						|
          }
 | 
						|
        },
 | 
						|
      },
 | 
						|
      "top-start": {
 | 
						|
        availableSpace() {
 | 
						|
          doc.documentElement.clientHeight -
 | 
						|
            getOffset(parentEl).top -
 | 
						|
            parentEl.clientHeight;
 | 
						|
        },
 | 
						|
        neededSpace: container.clientHeight - overlap,
 | 
						|
        position() {
 | 
						|
          // Point to an element above and at the start of the callout
 | 
						|
          let containerTop =
 | 
						|
            getOffset(parentEl).top + parentEl.clientHeight - overlap;
 | 
						|
          container.style.top = `${Math.max(
 | 
						|
            container.clientHeight - overlap,
 | 
						|
            containerTop
 | 
						|
          )}px`;
 | 
						|
          alignHorizontally("start");
 | 
						|
        },
 | 
						|
      },
 | 
						|
      "top-end": {
 | 
						|
        availableSpace() {
 | 
						|
          doc.documentElement.clientHeight -
 | 
						|
            getOffset(parentEl).top -
 | 
						|
            parentEl.clientHeight;
 | 
						|
        },
 | 
						|
        neededSpace: container.clientHeight - overlap,
 | 
						|
        position() {
 | 
						|
          // Point to an element above and at the end of the callout
 | 
						|
          let containerTop =
 | 
						|
            getOffset(parentEl).top + parentEl.clientHeight - overlap;
 | 
						|
          container.style.top = `${Math.max(
 | 
						|
            container.clientHeight - overlap,
 | 
						|
            containerTop
 | 
						|
          )}px`;
 | 
						|
          alignHorizontally("end");
 | 
						|
        },
 | 
						|
      },
 | 
						|
    };
 | 
						|
 | 
						|
    const calloutFits = position => {
 | 
						|
      // Does callout element fit in this position relative
 | 
						|
      // to the parent element without going off screen?
 | 
						|
 | 
						|
      // Only consider which edge of the callout the arrow points from,
 | 
						|
      // not the alignment of the arrow along the edge of the callout
 | 
						|
      let edgePosition = position.split("-")[0];
 | 
						|
      return (
 | 
						|
        positioners[edgePosition].availableSpace() >
 | 
						|
        positioners[edgePosition].neededSpace
 | 
						|
      );
 | 
						|
    };
 | 
						|
 | 
						|
    const choosePosition = () => {
 | 
						|
      let position = arrowPosition;
 | 
						|
      if (!arrowPositions.includes(position)) {
 | 
						|
        // Configured arrow position is not valid
 | 
						|
        return false;
 | 
						|
      }
 | 
						|
      if (["start", "end"].includes(position)) {
 | 
						|
        // position here is referencing the direction that the callout container
 | 
						|
        // is pointing to, and therefore should be the _opposite_ side of the arrow
 | 
						|
        // eg. if arrow is at the "end" in LTR layouts, the container is pointing
 | 
						|
        // at an element to the right of itself, while in RTL layouts it is pointing to the left of itself
 | 
						|
        position = RTL ^ (position === "start") ? "left" : "right";
 | 
						|
      }
 | 
						|
      // If we're overriding the position, we don't need to sort for available space
 | 
						|
      if (customPosition || calloutFits(position)) {
 | 
						|
        return position;
 | 
						|
      }
 | 
						|
      let sortedPositions = Object.keys(positioners)
 | 
						|
        .filter(p => p !== position)
 | 
						|
        .filter(calloutFits)
 | 
						|
        .sort((a, b) => {
 | 
						|
          return (
 | 
						|
            positioners[b].availableSpace() - positioners[b].neededSpace >
 | 
						|
            positioners[a].availableSpace() - positioners[a].neededSpace
 | 
						|
          );
 | 
						|
        });
 | 
						|
      // If the callout doesn't fit in any position, use the configured one.
 | 
						|
      // The callout will be adjusted to overlap the parent element so that
 | 
						|
      // the former doesn't go off screen.
 | 
						|
      return sortedPositions[0] || position;
 | 
						|
    };
 | 
						|
 | 
						|
    const centerVertically = () => {
 | 
						|
      let topOffset = (container.offsetHeight - parentEl.offsetHeight) / 2;
 | 
						|
      container.style.top = `${getOffset(parentEl).top - topOffset}px`;
 | 
						|
    };
 | 
						|
 | 
						|
    /**
 | 
						|
     * Horizontally align a top/bottom-positioned callout according to the
 | 
						|
     * passed position.
 | 
						|
     * @param {string} [position = "start"] <"start"|"end"|"center">
 | 
						|
     */
 | 
						|
    const alignHorizontally = position => {
 | 
						|
      switch (position) {
 | 
						|
        case "center": {
 | 
						|
          let sideOffset = (parentEl.clientWidth - container.clientWidth) / 2;
 | 
						|
          let containerSide = RTL
 | 
						|
            ? doc.documentElement.clientWidth -
 | 
						|
              getOffset(parentEl).right +
 | 
						|
              sideOffset
 | 
						|
            : getOffset(parentEl).left + sideOffset;
 | 
						|
          container.style[RTL ? "right" : "left"] = `${Math.max(
 | 
						|
            containerSide,
 | 
						|
            0
 | 
						|
          )}px`;
 | 
						|
          break;
 | 
						|
        }
 | 
						|
        default: {
 | 
						|
          let containerSide =
 | 
						|
            RTL ^ (position === "end")
 | 
						|
              ? parentEl.getBoundingClientRect().left +
 | 
						|
                parentEl.clientWidth -
 | 
						|
                container.clientWidth
 | 
						|
              : parentEl.getBoundingClientRect().left;
 | 
						|
          container.style.left = `${Math.max(containerSide, 0)}px`;
 | 
						|
          break;
 | 
						|
        }
 | 
						|
      }
 | 
						|
    };
 | 
						|
 | 
						|
    clearPosition(container);
 | 
						|
 | 
						|
    if (customPosition) {
 | 
						|
      overridePosition();
 | 
						|
    }
 | 
						|
 | 
						|
    let finalPosition = choosePosition();
 | 
						|
    if (finalPosition) {
 | 
						|
      positioners[finalPosition].position();
 | 
						|
      addArrowPositionClassToContainer(finalPosition);
 | 
						|
    }
 | 
						|
 | 
						|
    container.classList.remove("hidden");
 | 
						|
  }
 | 
						|
 | 
						|
  /** Expose top level functions expected by the aboutwelcome bundle. */
 | 
						|
  _setupWindowFunctions() {
 | 
						|
    if (this.AWSetup) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
    const AWParent = new lazy.AboutWelcomeParent();
 | 
						|
    this.win.addEventListener("unload", () => {
 | 
						|
      AWParent.didDestroy();
 | 
						|
    });
 | 
						|
    const receive = name => data =>
 | 
						|
      AWParent.onContentMessage(`AWPage:${name}`, data, this.doc);
 | 
						|
    this.win.AWGetFeatureConfig = () => this.config;
 | 
						|
    this.win.AWGetSelectedTheme = receive("GET_SELECTED_THEME");
 | 
						|
    // Do not send telemetry if message config sets metrics as 'block'.
 | 
						|
    if (this.config?.metrics !== "block") {
 | 
						|
      this.win.AWSendEventTelemetry = receive("TELEMETRY_EVENT");
 | 
						|
    }
 | 
						|
    this.win.AWSendToDeviceEmailsSupported = receive(
 | 
						|
      "SEND_TO_DEVICE_EMAILS_SUPPORTED"
 | 
						|
    );
 | 
						|
    this.win.AWSendToParent = (name, data) => receive(name)(data);
 | 
						|
    this.win.AWFinish = () => {
 | 
						|
      this.endTour();
 | 
						|
    };
 | 
						|
    this.win.AWEvaluateScreenTargeting = receive("EVALUATE_SCREEN_TARGETING");
 | 
						|
    this.AWSetup = true;
 | 
						|
  }
 | 
						|
 | 
						|
  /** Clean up the functions defined above. */
 | 
						|
  _clearWindowFunctions() {
 | 
						|
    const windowFuncs = [
 | 
						|
      "AWGetFeatureConfig",
 | 
						|
      "AWGetSelectedTheme",
 | 
						|
      "AWSendEventTelemetry",
 | 
						|
      "AWSendToDeviceEmailsSupported",
 | 
						|
      "AWSendToParent",
 | 
						|
      "AWFinish",
 | 
						|
    ];
 | 
						|
    windowFuncs.forEach(func => delete this.win[func]);
 | 
						|
  }
 | 
						|
 | 
						|
  endTour(skipFadeOut = false) {
 | 
						|
    // We don't want focus events that happen during teardown to affect
 | 
						|
    // this.savedActiveElement
 | 
						|
    this.win.removeEventListener("focus", this, {
 | 
						|
      capture: true,
 | 
						|
      passive: true,
 | 
						|
    });
 | 
						|
    this.win.removeEventListener("keypress", this, { capture: true });
 | 
						|
    this._pageEventManager?.clear();
 | 
						|
 | 
						|
    // We're deleting featureTourProgress here to ensure that the
 | 
						|
    // reference is freed for garbage collection. This prevents errors
 | 
						|
    // caused by lingering instances when instantiating and removing
 | 
						|
    // multiple feature tour instances in succession.
 | 
						|
    delete this.featureTourProgress;
 | 
						|
    this.ready = false;
 | 
						|
    // wait for fade out transition
 | 
						|
    let container = this.doc.getElementById(CONTAINER_ID);
 | 
						|
    container?.classList.add("hidden");
 | 
						|
    this._clearWindowFunctions();
 | 
						|
    this.win.setTimeout(
 | 
						|
      () => {
 | 
						|
        container?.remove();
 | 
						|
        this.renderObserver?.disconnect();
 | 
						|
        this._removePositionListeners();
 | 
						|
        this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove();
 | 
						|
        // Put the focus back to the last place the user focused outside of the
 | 
						|
        // featureCallout windows.
 | 
						|
        if (this.savedActiveElement) {
 | 
						|
          this.savedActiveElement.focus({ focusVisible: true });
 | 
						|
        }
 | 
						|
      },
 | 
						|
      skipFadeOut ? 0 : TRANSITION_MS
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  _dismiss() {
 | 
						|
    let action = this.currentScreen?.content.dismiss_button?.action;
 | 
						|
    if (action?.type) {
 | 
						|
      this.win.AWSendToParent("SPECIAL_ACTION", action);
 | 
						|
    } else {
 | 
						|
      this.endTour();
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  async _addScriptsAndRender() {
 | 
						|
    const reactSrc = "resource://activity-stream/vendor/react.js";
 | 
						|
    const domSrc = "resource://activity-stream/vendor/react-dom.js";
 | 
						|
    // Add React script
 | 
						|
    const getReactReady = async () => {
 | 
						|
      return new Promise(resolve => {
 | 
						|
        let reactScript = this.doc.createElement("script");
 | 
						|
        reactScript.src = reactSrc;
 | 
						|
        this.doc.head.appendChild(reactScript);
 | 
						|
        reactScript.addEventListener("load", resolve);
 | 
						|
      });
 | 
						|
    };
 | 
						|
    // Add ReactDom script
 | 
						|
    const getDomReady = async () => {
 | 
						|
      return new Promise(resolve => {
 | 
						|
        let domScript = this.doc.createElement("script");
 | 
						|
        domScript.src = domSrc;
 | 
						|
        this.doc.head.appendChild(domScript);
 | 
						|
        domScript.addEventListener("load", resolve);
 | 
						|
      });
 | 
						|
    };
 | 
						|
    // Load React, then React Dom
 | 
						|
    if (!this.doc.querySelector(`[src="${reactSrc}"]`)) {
 | 
						|
      await getReactReady();
 | 
						|
    }
 | 
						|
    if (!this.doc.querySelector(`[src="${domSrc}"]`)) {
 | 
						|
      await getDomReady();
 | 
						|
    }
 | 
						|
    // Load the bundle to render the content as configured.
 | 
						|
    this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove();
 | 
						|
    let bundleScript = this.doc.createElement("script");
 | 
						|
    bundleScript.src = BUNDLE_SRC;
 | 
						|
    this.doc.head.appendChild(bundleScript);
 | 
						|
  }
 | 
						|
 | 
						|
  _observeRender(container) {
 | 
						|
    this.renderObserver?.observe(container, { childList: true });
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Request a message from ASRouter, targeting the `browser` and `page` values
 | 
						|
   * passed to the constructor. The message content is stored in this.config,
 | 
						|
   * which is returned by AWGetFeatureConfig. The aboutwelcome bundle will use
 | 
						|
   * that function to get the content. It will only be called when the bundle
 | 
						|
   * loads, so the bundle must be reloaded for a new message to be rendered.
 | 
						|
   * @returns {Promise<boolean>} true if a message is loaded, false if not.
 | 
						|
   */
 | 
						|
  async _loadConfig() {
 | 
						|
    if (this.loadingConfig) {
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
    this.loadingConfig = true;
 | 
						|
    await lazy.ASRouter.waitForInitialized;
 | 
						|
    let result = await lazy.ASRouter.sendTriggerMessage({
 | 
						|
      browser: this.browser,
 | 
						|
      // triggerId and triggerContext
 | 
						|
      id: "featureCalloutCheck",
 | 
						|
      context: { source: this.page },
 | 
						|
    });
 | 
						|
    this.message = result.message;
 | 
						|
    this.loadingConfig = false;
 | 
						|
 | 
						|
    if (result.message.template !== "feature_callout") {
 | 
						|
      // If another message type, like a Spotlight modal, is included
 | 
						|
      // in the tour, save the template name as the current screen.
 | 
						|
      this.currentScreen = result.message.template;
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
 | 
						|
    this.config = result.message.content;
 | 
						|
 | 
						|
    let newScreen = this.config?.screens?.[this.config?.startScreen || 0];
 | 
						|
    if (newScreen?.id === this.currentScreen?.id) {
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
 | 
						|
    // Only add an impression if we actually have a message to impress
 | 
						|
    if (Object.keys(result.message).length) {
 | 
						|
      lazy.ASRouter.addImpression(result.message);
 | 
						|
    }
 | 
						|
 | 
						|
    this.currentScreen = newScreen;
 | 
						|
    return true;
 | 
						|
  }
 | 
						|
 | 
						|
  async _renderCallout() {
 | 
						|
    let container = this._createContainer();
 | 
						|
    if (container) {
 | 
						|
      // This results in rendering the Feature Callout
 | 
						|
      await this._addScriptsAndRender();
 | 
						|
      this._observeRender(container);
 | 
						|
      this._addPositionListeners();
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * For each member of the screen's page_event_listeners array, add a listener.
 | 
						|
   * @param {Array<PageEventListener>} listeners An array of listeners to set up
 | 
						|
   *
 | 
						|
   * @typedef {Object} PageEventListener
 | 
						|
   * @property {PageEventListenerParams} params Event listener parameters
 | 
						|
   * @property {PageEventListenerAction} action Sent when the event fires
 | 
						|
   *
 | 
						|
   * @typedef {Object} PageEventListenerParams See PageEventManager.jsm
 | 
						|
   * @property {String} type Event type string e.g. `click`
 | 
						|
   * @property {String} selectors Target selector, e.g. `tag.class, #id[attr]`
 | 
						|
   * @property {PageEventListenerOptions} [options] addEventListener options
 | 
						|
   *
 | 
						|
   * @typedef {Object} PageEventListenerOptions
 | 
						|
   * @property {Boolean} [capture] Use event capturing phase?
 | 
						|
   * @property {Boolean} [once] Remove listener after first event?
 | 
						|
   * @property {Boolean} [preventDefault] Prevent default action?
 | 
						|
   *
 | 
						|
   * @typedef {Object} PageEventListenerAction Action sent to AboutWelcomeParent
 | 
						|
   * @property {String} [type] Action type, e.g. `OPEN_URL`
 | 
						|
   * @property {Object} [data] Extra data, properties depend on action type
 | 
						|
   * @property {Boolean} [dismiss] Dismiss screen after performing action?
 | 
						|
   */
 | 
						|
  _attachPageEventListeners(listeners) {
 | 
						|
    listeners?.forEach(({ params, action }) =>
 | 
						|
      this._loadPageEventManager[params.options?.once ? "once" : "on"](
 | 
						|
        params,
 | 
						|
        event => {
 | 
						|
          this._handlePageEventAction(action, event);
 | 
						|
          if (params.options?.preventDefault) {
 | 
						|
            event.preventDefault?.();
 | 
						|
          }
 | 
						|
        }
 | 
						|
      )
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Perform an action in response to a page event.
 | 
						|
   * @param {PageEventListenerAction} action
 | 
						|
   * @param {Event} event Triggering event
 | 
						|
   */
 | 
						|
  _handlePageEventAction(action, event) {
 | 
						|
    const page = this.page;
 | 
						|
    const message_id = this.config?.id.toUpperCase();
 | 
						|
    const source = this._getUniqueElementIdentifier(event.target);
 | 
						|
    this.win.AWSendEventTelemetry?.({
 | 
						|
      event: "PAGE_EVENT",
 | 
						|
      event_context: {
 | 
						|
        action: action.type ?? (action.dismiss ? "DISMISS" : ""),
 | 
						|
        reason: event.type?.toUpperCase(),
 | 
						|
        source,
 | 
						|
        page,
 | 
						|
      },
 | 
						|
      message_id,
 | 
						|
    });
 | 
						|
    if (action.type) {
 | 
						|
      this.win.AWSendToParent("SPECIAL_ACTION", action);
 | 
						|
    }
 | 
						|
    if (action.dismiss) {
 | 
						|
      this.win.AWSendEventTelemetry?.({
 | 
						|
        event: "DISMISS",
 | 
						|
        event_context: { source: `PAGE_EVENT:${source}`, page },
 | 
						|
        message_id,
 | 
						|
      });
 | 
						|
      this._dismiss();
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * For a given element, calculate a unique string that identifies it.
 | 
						|
   * @param {Element} target Element to calculate the selector for
 | 
						|
   * @returns {String} Computed event target selector, e.g. `button#next`
 | 
						|
   */
 | 
						|
  _getUniqueElementIdentifier(target) {
 | 
						|
    let source;
 | 
						|
    if (Element.isInstance(target)) {
 | 
						|
      source = target.localName;
 | 
						|
      if (target.className) {
 | 
						|
        source += `.${[...target.classList].join(".")}`;
 | 
						|
      }
 | 
						|
      if (target.id) {
 | 
						|
        source += `#${target.id}`;
 | 
						|
      }
 | 
						|
      if (target.attributes.length) {
 | 
						|
        source += `${[...target.attributes]
 | 
						|
          .filter(attr => ["is", "role", "open"].includes(attr.name))
 | 
						|
          .map(attr => `[${attr.name}="${attr.value}"]`)
 | 
						|
          .join("")}`;
 | 
						|
      }
 | 
						|
      if (this.doc.querySelectorAll(source).length > 1) {
 | 
						|
        let uniqueAncestor = target.closest(`[id]:not(:scope, :root, body)`);
 | 
						|
        if (uniqueAncestor) {
 | 
						|
          source = `${this._getUniqueElementIdentifier(
 | 
						|
            uniqueAncestor
 | 
						|
          )} > ${source}`;
 | 
						|
        }
 | 
						|
      }
 | 
						|
    }
 | 
						|
    return source;
 | 
						|
  }
 | 
						|
 | 
						|
  async showFeatureCallout() {
 | 
						|
    let updated = await this._loadConfig();
 | 
						|
 | 
						|
    if (!updated || !this.config?.screens?.length) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    this.renderObserver = new this.win.MutationObserver(() => {
 | 
						|
      // Check if the Feature Callout screen has loaded for the first time
 | 
						|
      if (!this.ready && this.doc.querySelector(`#${CONTAINER_ID} .screen`)) {
 | 
						|
        // Once the screen element is added to the DOM, wait for the
 | 
						|
        // animation frame after next to ensure that _positionCallout
 | 
						|
        // has access to the rendered screen with the correct height
 | 
						|
        this.win.requestAnimationFrame(() => {
 | 
						|
          this.win.requestAnimationFrame(() => {
 | 
						|
            this.ready = true;
 | 
						|
            this._attachPageEventListeners(
 | 
						|
              this.currentScreen?.content?.page_event_listeners
 | 
						|
            );
 | 
						|
            this.win.addEventListener("keypress", this, { capture: true });
 | 
						|
            this._positionCallout();
 | 
						|
            let container = this.doc.getElementById(CONTAINER_ID);
 | 
						|
            container.focus();
 | 
						|
            this.win.addEventListener("focus", this, {
 | 
						|
              capture: true, // get the event before retargeting
 | 
						|
              passive: true,
 | 
						|
            });
 | 
						|
          });
 | 
						|
        });
 | 
						|
      }
 | 
						|
    });
 | 
						|
 | 
						|
    this._pageEventManager?.clear();
 | 
						|
    this.ready = false;
 | 
						|
    const container = this.doc.getElementById(CONTAINER_ID);
 | 
						|
    container?.remove();
 | 
						|
 | 
						|
    // If user has disabled CFR, don't show any callouts. But make sure we load
 | 
						|
    // the necessary stylesheets first, since re-enabling CFR should allow
 | 
						|
    // callouts to be shown without needing to reload. In the future this could
 | 
						|
    // allow adding a CTA to disable recommendations with a label like "Don't show
 | 
						|
    // these again" (or potentially a toggle to re-enable them).
 | 
						|
    if (!this.cfrFeaturesUserPref) {
 | 
						|
      this.currentScreen = null;
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    this._addCalloutLinkElements();
 | 
						|
    this._setupWindowFunctions();
 | 
						|
    await this._renderCallout();
 | 
						|
  }
 | 
						|
}
 |