diff options
Diffstat (limited to '')
-rw-r--r-- | browser/components/asrouter/modules/FeatureCallout.sys.mjs | 2100 |
1 files changed, 2100 insertions, 0 deletions
diff --git a/browser/components/asrouter/modules/FeatureCallout.sys.mjs b/browser/components/asrouter/modules/FeatureCallout.sys.mjs new file mode 100644 index 0000000000..01998662f6 --- /dev/null +++ b/browser/components/asrouter/modules/FeatureCallout.sys.mjs @@ -0,0 +1,2100 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AboutWelcomeParent: "resource:///actors/AboutWelcomeParent.sys.mjs", + ASRouter: "resource:///modules/asrouter/ASRouter.sys.mjs", + CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs", + PageEventManager: "resource:///modules/asrouter/PageEventManager.sys.mjs", +}); + +const TRANSITION_MS = 500; +const CONTAINER_ID = "feature-callout"; +const CONTENT_BOX_ID = "multi-stage-message-root"; +const BUNDLE_SRC = + "chrome://browser/content/aboutwelcome/aboutwelcome.bundle.js"; + +ChromeUtils.defineLazyGetter(lazy, "log", () => { + const { Logger } = ChromeUtils.importESModule( + "resource://messaging-system/lib/Logger.sys.mjs" + ); + return new Logger("FeatureCallout"); +}); + +/** + * 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 {{name: String, defaultValue?: String}} [pref] optional pref used + * to track progress through a given feature tour. for example: + * { + * name: "browser.pdfjs.feature-tour", + * defaultValue: '{ screen: "FEATURE_CALLOUT_1", complete: false }', + * } + * or { name: "browser.pdfjs.feature-tour" } (defaultValue is optional) + * @property {String} [location] string to pass as the page when requesting + * messages from ASRouter and sending telemetry. + * @property {String} context either "chrome" or "content". "chrome" is used + * when the callout is shown in the browser chrome, and "content" is used + * when the callout is shown in a content page like Firefox View. + * @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. + * @property {Function} [listener] callback to be invoked on various callout + * events to keep the broker informed of the callout's state. + * @property {FeatureCalloutTheme} [theme] @see FeatureCallout.themePresets + */ + + /** @param {FeatureCalloutOptions} options */ + constructor({ + win, + pref, + location, + context, + browser, + listener, + theme = {}, + } = {}) { + this.win = win; + this.doc = win.document; + this.browser = browser || this.win.docShell.chromeEventHandler; + this.config = null; + this.loadingConfig = false; + this.message = null; + if (pref?.name) { + this.pref = pref; + } + this._featureTourProgress = null; + this.currentScreen = null; + this.renderObserver = null; + this.savedFocus = null; + this.ready = false; + this._positionListenersRegistered = false; + this._panelConflictListenersRegistered = false; + this.AWSetup = false; + this.location = location; + this.context = context; + this.listener = listener; + this._initTheme(theme); + + this._handlePrefChange = this._handlePrefChange.bind(this); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "cfrFeaturesUserPref", + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features", + true + ); + this.setupFeatureTourProgress(); + + // 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.context !== "chrome") { + this.win.addEventListener("visibilitychange", this); + } + + this.win.addEventListener("unload", this); + } + + setupFeatureTourProgress() { + if (this.featureTourProgress) { + return; + } + if (this.pref?.name) { + this._handlePrefChange(null, null, this.pref.name); + Services.prefs.addObserver(this.pref.name, this._handlePrefChange); + } + } + + teardownFeatureTourProgress() { + if (this.pref?.name) { + Services.prefs.removeObserver(this.pref.name, this._handlePrefChange); + } + this._featureTourProgress = null; + } + + get featureTourProgress() { + return this._featureTourProgress; + } + + /** + * 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.win); + } + return this._pageEventManager; + } + + _addPositionListeners() { + if (!this._positionListenersRegistered) { + this.win.addEventListener("resize", this); + this._positionListenersRegistered = true; + } + } + + _removePositionListeners() { + if (this._positionListenersRegistered) { + this.win.removeEventListener("resize", this); + this._positionListenersRegistered = false; + } + } + + _addPanelConflictListeners() { + if (!this._panelConflictListenersRegistered) { + this.win.addEventListener("popupshowing", this); + this.win.gURLBar.controller.addQueryListener(this); + this._panelConflictListenersRegistered = true; + } + } + + _removePanelConflictListeners() { + if (this._panelConflictListenersRegistered) { + this.win.removeEventListener("popupshowing", this); + this.win.gURLBar.controller.removeQueryListener(this); + this._panelConflictListenersRegistered = false; + } + } + + /** + * Close the tour when the urlbar is opened in the chrome. Set up by + * gURLBar.controller.addQueryListener in _addPanelConflictListeners. + */ + onViewOpen() { + this.endTour(); + } + + _handlePrefChange(subject, topic, prefName) { + switch (prefName) { + case this.pref?.name: + try { + this._featureTourProgress = JSON.parse( + Services.prefs.getStringPref( + this.pref.name, + this.pref.defaultValue ?? null + ) + ); + } catch (error) { + this._featureTourProgress = null; + } + if (topic === "nsPref:changed") { + this._maybeAdvanceScreens(); + } + break; + } + } + + _maybeAdvanceScreens() { + 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 a pref change results + // from an event in a Spotlight message, initialize the feature callout with + // the next message in the tour. + if ( + this.config?.screens.length === 1 || + this.currentScreen === "spotlight" + ) { + this.showFeatureCallout(); + 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(); + } else if (prefVal.screen !== this.currentScreen?.id) { + // Pref changes only matter to us insofar as they let us advance an + // ongoing tour. If the tour was closed and the pref changed later, e.g. + // by editing the pref directly, we don't want to start up the tour again. + // This is more important in the chrome, which is always open. + if (this.context === "chrome" && !this.currentScreen) { + return; + } + this.ready = false; + this._container?.classList.toggle( + "hidden", + this._container?.localName !== "panel" + ); + this._pageEventManager?.emit({ + type: "touradvance", + target: this._container, + }); + const onFadeOut = async () => { + // If the initial message was deployed from outside by ASRouter as a + // result of a trigger, we can't continue it through _loadConfig, since + // that effectively requests a message with a `featureCalloutCheck` + // trigger. So we need to load up the same message again, merely + // changing the startScreen index. Just check that the next screen and + // the current screen are both within the message's screens array. + let nextMessage = null; + if ( + this.context === "chrome" && + this.message?.trigger.id !== "featureCalloutCheck" + ) { + if ( + this.config?.screens.some(s => s.id === this.currentScreen?.id) && + this.config.screens.some(s => s.id === prefVal.screen) + ) { + nextMessage = this.message; + } + } + this._container?.remove(); + this.renderObserver?.disconnect(); + this._removePositionListeners(); + this._removePanelConflictListeners(); + this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove(); + if (nextMessage) { + const isMessageUnblocked = await lazy.ASRouter.isUnblockedMessage( + nextMessage + ); + if (!isMessageUnblocked) { + this.endTour(); + return; + } + } + let updated = await this._updateConfig(nextMessage); + if (!updated && !this.currentScreen) { + this.endTour(); + return; + } + let rendering = await this._renderCallout(); + if (!rendering) { + this.endTour(); + } + }; + if (this._container?.localName === "panel") { + this._container.removeEventListener("popuphiding", this); + this._container.addEventListener("popuphidden", onFadeOut, { + once: true, + }); + this._container.hidePopup(true); + } else { + this.win.setTimeout(onFadeOut, TRANSITION_MS); + } + } + } + + handleEvent(event) { + switch (event.type) { + case "focus": { + if (!this._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 === this._container || + (Node.isInstance(event.target) && + this._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. + if (this.doc.activeElement) { + let element = this.doc.activeElement; + this.savedFocus = { + element, + focusVisible: element.matches(":focus-visible"), + }; + } else { + this.savedFocus = null; + } + break; + } + + case "keypress": { + if (event.key !== "Escape") { + return; + } + if (!this._container) { + return; + } + let focusedElement = + this.context === "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 && this.theme.simulateContent) || + this._container.contains(focusedElement) + ) { + this.win.AWSendEventTelemetry?.({ + event: "DISMISS", + event_context: { + source: `KEY_${event.key}`, + page: this.location, + }, + message_id: this.config?.id.toUpperCase(), + }); + this._dismiss(); + event.preventDefault(); + } + break; + } + + case "visibilitychange": + this._maybeAdvanceScreens(); + break; + + case "resize": + case "toggle": + this.win.requestAnimationFrame(() => this._positionCallout()); + break; + + case "popupshowing": + // If another panel is showing, close the tour. + if ( + event.target !== this._container && + event.target.localName === "panel" && + event.target.id !== "ctrlTab-panel" && + event.target.ownerGlobal === this.win + ) { + this.endTour(); + } + break; + + case "popuphiding": + if (event.target === this._container) { + this.endTour(); + } + break; + + case "unload": + try { + this.teardownFeatureTourProgress(); + } catch (error) {} + break; + + default: + } + } + + async _addCalloutLinkElements() { + for (const path of [ + "browser/newtab/onboarding.ftl", + "browser/spotlight.ftl", + "branding/brand.ftl", + "toolkit/branding/brandings.ftl", + "browser/newtab/asrouter.ftl", + "browser/featureCallout.ftl", + ]) { + this.win.MozXULElement.insertFTLIfNeeded(path); + } + + const addChromeSheet = href => { + try { + this.win.windowUtils.loadSheetUsingURIString( + href, + Ci.nsIDOMWindowUtils.AUTHOR_SHEET + ); + } catch (error) { + // the sheet was probably already loaded. I don't think there's a way to + // check for this via JS, but the method checks and throws if it's + // already loaded, so we can just treat the error as expected. + } + }; + const addStylesheet = href => { + if (this.win.isChromeWindow) { + // for chrome, load the stylesheet using a special method to make sure + // it's loaded synchronously before the first paint & position. + return addChromeSheet(href); + } + if (this.doc.querySelector(`link[href="${href}"]`)) { + return null; + } + const link = this.doc.head.appendChild(this.doc.createElement("link")); + link.rel = "stylesheet"; + link.href = href; + return null; + }; + // Update styling to be compatible with about:welcome bundle + await addStylesheet( + "chrome://browser/content/aboutwelcome/aboutwelcome.css" + ); + } + + /** + * @typedef { + * | "topleft" + * | "topright" + * | "bottomleft" + * | "bottomright" + * | "leftcenter" + * | "rightcenter" + * | "topcenter" + * | "bottomcenter" + * } PopupAttachmentPoint + * + * @see nsMenuPopupFrame + * + * Each attachment point corresponds to an attachment point on the edge of a + * frame. For example, "topleft" corresponds to the frame's top left corner, + * and "rightcenter" corresponds to the center of the right edge of the frame. + */ + + /** + * @typedef {Object} PanelPosition Specifies how the callout panel should be + * positioned relative to the anchor element, by providing which point on + * the callout should be aligned with which point on the anchor element. + * @property {PopupAttachmentPoint} anchor_attachment + * @property {PopupAttachmentPoint} callout_attachment + * @property {Number} [offset_x] Offset in pixels to apply to the callout + * position in the horizontal direction. + * @property {Number} [offset_y] The same in the vertical direction. + * + * This is used when you want the callout to be displayed as a <panel> + * element. A panel is critical when the callout is displayed in the browser + * chrome, anchored to an element whose position on the screen is dynamic, + * such as a button. When the anchor moves, the panel will automatically move + * with it. Also, when the elements are aligned so that the callout would + * extend beyond the edge of the screen, the panel will automatically flip + * itself to the other side of the anchor element. This requires specifying + * both an anchor attachment point and a callout attachment point. For + * example, to get the callout to appear under a button, with its arrow on the + * right side of the callout: + * { anchor_attachment: "bottomcenter", callout_attachment: "topright" } + */ + + /** + * @typedef { + * | "top" + * | "bottom" + * | "end" + * | "start" + * | "top-end" + * | "top-start" + * | "top-center-arrow-end" + * | "top-center-arrow-start" + * } HTMLArrowPosition + * + * @see FeatureCallout._positionCallout() + * The position of the callout arrow relative to the callout container. Only + * used for HTML callouts, typically in content pages. 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. + */ + + /** + * @typedef {Object} PositionOverride CSS properties to override + * the callout's position relative to the anchor element. Although the + * callout is not actually a child of the anchor element, this allows + * absolute positioning of the callout relative to the anchor element. In + * other words, { top: "0px", left: "0px" } will position the callout in the + * top left corner of the anchor element, in the same way these properties + * would position a child element. + * @property {String} [top] + * @property {String} [left] + * @property {String} [right] + * @property {String} [bottom] + */ + + /** + * @typedef {Object} AnchorConfig + * @property {String} selector CSS selector for the anchor node. + * @property {PanelPosition} [panel_position] Used to show the callout in a + * XUL panel. Only works in chrome documents, like the main browser window. + * @property {HTMLArrowPosition} [arrow_position] Used to show the callout in + * an HTML div container. Mutually exclusive with panel_position. + * @property {PositionOverride} [absolute_position] Only used for HTML + * callouts, i.e. when panel_position is not specified. Allows absolute + * positioning of the callout relative to the anchor element. + * @property {Boolean} [hide_arrow] Whether to hide the arrow. + * @property {Boolean} [no_open_on_anchor] Whether to set the [open] style on + * the anchor element when the callout is shown. False to set it, true to + * not set it. This only works for panel callouts. Not all elements have an + * [open] style. Buttons do, for example. It's usually similar to :active. + * @property {Number} [arrow_width] The desired width of the arrow in a number + * of pixels. 33.94113 by default (this corresponds to 24px edges). + */ + + /** + * @typedef {Object} Anchor + * @property {String} selector + * @property {PanelPosition} [panel_position] + * @property {HTMLArrowPosition} [arrow_position] + * @property {PositionOverride} [absolute_position] + * @property {Boolean} [hide_arrow] + * @property {Boolean} [no_open_on_anchor] + * @property {Number} [arrow_width] + * @property {Element} element The anchor node resolved from the selector. + * @property {String} [panel_position_string] The panel_position joined into a + * string, e.g. "bottomleft topright". Passed to XULPopupElement::openPopup. + */ + + /** + * Return the first visible anchor element for the current screen. Screens can + * specify multiple anchors in an array, and the first one that is visible + * will be used. If none are visible, return null. + * @returns {Anchor|null} + */ + _getAnchor() { + /** @type {AnchorConfig[]} */ + const anchors = Array.isArray(this.currentScreen?.anchors) + ? this.currentScreen.anchors + : []; + for (let anchor of anchors) { + if (!anchor || typeof anchor !== "object") { + lazy.log.debug( + `In ${this.location}: Invalid anchor config. Expected an object, got: ${anchor}` + ); + continue; + } + const { selector, arrow_position, panel_position } = anchor; + let panel_position_string; + if (panel_position) { + panel_position_string = this._getPanelPositionString(panel_position); + // if the positionString doesn't match the format we expect, don't + // render the callout. + if (!panel_position_string && !arrow_position) { + lazy.log.debug( + `In ${ + this.location + }: Invalid panel_position config. Expected an object with anchor_attachment and callout_attachment properties, got: ${JSON.stringify( + panel_position + )}` + ); + continue; + } + } + if ( + arrow_position && + !this._HTMLArrowPositions.includes(arrow_position) + ) { + lazy.log.debug( + `In ${ + this.location + }: Invalid arrow_position config. Expected one of ${JSON.stringify( + this._HTMLArrowPositions + )}, got: ${arrow_position}` + ); + continue; + } + const element = selector && this.doc.querySelector(selector); + if (!element) { + continue; // Element doesn't exist at all. + } + const isVisible = () => { + if ( + this.context === "chrome" && + typeof this.win.isElementVisible === "function" + ) { + // In chrome windows, we can use the isElementVisible function to + // check that the element has non-zero width and height. If it was + // hidden, it would most likely have zero width and/or height. + if (!this.win.isElementVisible(element)) { + return false; + } + } + // CSS rules like visibility: hidden or display: none. These result in + // element being invisible and unclickable. + const style = this.win.getComputedStyle(element); + return style?.visibility === "visible" && style?.display !== "none"; + }; + if (!isVisible()) { + continue; + } + if ( + this.context === "chrome" && + element.id && + anchor.selector.includes(`#${element.id}`) + ) { + let widget = lazy.CustomizableUI.getWidget(element.id); + if ( + widget && + (this.win.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. + continue; + } + } + return { ...anchor, panel_position_string, element }; + } + return null; + } + + /** @see PopupAttachmentPoint */ + _popupAttachmentPoints = [ + "topleft", + "topright", + "bottomleft", + "bottomright", + "leftcenter", + "rightcenter", + "topcenter", + "bottomcenter", + ]; + + /** + * Return a string representing the position of the panel relative to the + * anchor element. Passed to XULPopupElement::openPopup. The string is of the + * form "anchor_attachment callout_attachment". + * + * @param {PanelPosition} panelPosition + * @returns {String|null} A string like "bottomcenter topright", or null if + * the panelPosition object is invalid. + */ + _getPanelPositionString(panelPosition) { + const { anchor_attachment, callout_attachment } = panelPosition; + if ( + !this._popupAttachmentPoints.includes(anchor_attachment) || + !this._popupAttachmentPoints.includes(callout_attachment) + ) { + return null; + } + let positionString = `${anchor_attachment} ${callout_attachment}`; + return positionString; + } + + /** + * Set/override methods on a panel element. Can be used to override methods on + * the custom element class, or to add additional methods. + * + * @param {MozPanel} panel The panel to set methods for + */ + _setPanelMethods(panel) { + // This method is optionally called by MozPanel::_setSideAttribute, though + // it does not exist on the class. + panel.setArrowPosition = function setArrowPosition(event) { + if (!this.hasAttribute("show-arrow")) { + return; + } + let { alignmentPosition, alignmentOffset, popupAlignment } = event; + let positionParts = alignmentPosition?.match( + /^(before|after|start|end)_(before|after|start|end)$/ + ); + if (!positionParts) { + return; + } + // Hide the arrow if the `flip` behavior has caused the panel to + // offset relative to its anchor, since the arrow would no longer + // point at the true anchor. This differs from an arrow that is + // intentionally hidden by the user in message. + if (this.getAttribute("hide-arrow") !== "permanent") { + if (alignmentOffset) { + this.setAttribute("hide-arrow", "temporary"); + } else { + this.removeAttribute("hide-arrow"); + } + } + let arrowPosition = "top"; + switch (positionParts[1]) { + case "start": + case "end": { + // Inline arrow, i.e. arrow is on one of the left/right edges. + let isRTL = + this.ownerGlobal.getComputedStyle(this).direction === "rtl"; + let isRight = isRTL ^ (positionParts[1] === "start"); + let side = isRight ? "end" : "start"; + arrowPosition = `inline-${side}`; + if (popupAlignment?.includes("center")) { + arrowPosition = `inline-${side}`; + } else if (positionParts[2] === "before") { + arrowPosition = `inline-${side}-top`; + } else if (positionParts[2] === "after") { + arrowPosition = `inline-${side}-bottom`; + } + break; + } + case "before": + case "after": { + // Block arrow, i.e. arrow is on one of the top/bottom edges. + let side = positionParts[1] === "before" ? "bottom" : "top"; + arrowPosition = side; + if (popupAlignment?.includes("center")) { + arrowPosition = side; + } else if (positionParts[2] === "end") { + arrowPosition = `${side}-end`; + } else if (positionParts[2] === "start") { + arrowPosition = `${side}-start`; + } + break; + } + } + this.setAttribute("arrow-position", arrowPosition); + }; + } + + _createContainer() { + const anchor = this._getAnchor(); + // Don't render the callout if none of the anchors is visible. + if (!anchor) { + return false; + } + + const { autohide, padding } = this.currentScreen.content; + const { + panel_position_string, + hide_arrow, + no_open_on_anchor, + arrow_width, + } = anchor; + const needsPanel = "MozXULElement" in this.win && !!panel_position_string; + + if (this._container) { + if (needsPanel ^ (this._container?.localName === "panel")) { + this._container.remove(); + } + } + + if (!this._container?.parentElement) { + if (needsPanel) { + let fragment = this.win.MozXULElement.parseXULToFragment(`<panel + class="panel-no-padding" + orient="vertical" + ignorekeys="true" + noautofocus="true" + flip="slide" + type="arrow" + position="${panel_position_string}" + ${hide_arrow ? "" : 'show-arrow=""'} + ${autohide ? "" : 'noautohide="true"'} + ${no_open_on_anchor ? 'no-open-on-anchor=""' : ""} + />`); + this._container = fragment.firstElementChild; + this._setPanelMethods(this._container); + } else { + this._container = this.doc.createElement("div"); + this._container?.classList.add("hidden"); + } + this._container.classList.add("featureCallout", "callout-arrow"); + if (hide_arrow) { + this._container.setAttribute("hide-arrow", "permanent"); + } else { + this._container.removeAttribute("hide-arrow"); + } + this._container.id = CONTAINER_ID; + this._container.setAttribute( + "aria-describedby", + `#${CONTAINER_ID} .welcome-text` + ); + this._container.tabIndex = 0; + if (arrow_width) { + this._container.style.setProperty("--arrow-width", `${arrow_width}px`); + } else { + this._container.style.removeProperty("--arrow-width"); + } + if (padding) { + this._container.style.setProperty("--callout-padding", `${padding}px`); + } else { + this._container.style.removeProperty("--callout-padding"); + } + let contentBox = this.doc.createElement("div"); + contentBox.id = CONTENT_BOX_ID; + contentBox.classList.add("onboardingContainer"); + // This value is reported as the "page" in about:welcome telemetry + contentBox.dataset.page = this.location; + this._applyTheme(); + if (needsPanel && this.win.isChromeWindow) { + this.doc.getElementById("mainPopupSet").appendChild(this._container); + } else { + this.doc.body.prepend(this._container); + } + const makeArrow = classPrefix => { + const arrowRotationBox = this.doc.createElement("div"); + arrowRotationBox.classList.add("arrow-box", `${classPrefix}-arrow-box`); + const arrow = this.doc.createElement("div"); + arrow.classList.add("arrow", `${classPrefix}-arrow`); + arrowRotationBox.appendChild(arrow); + return arrowRotationBox; + }; + this._container.appendChild(makeArrow("shadow")); + this._container.appendChild(contentBox); + this._container.appendChild(makeArrow("background")); + } + return this._container; + } + + /** @see HTMLArrowPosition */ + _HTMLArrowPositions = [ + "top", + "bottom", + "end", + "start", + "top-end", + "top-start", + "top-center-arrow-end", + "top-center-arrow-start", + ]; + + /** + * Set callout's position relative to parent element + */ + _positionCallout() { + const container = this._container; + const anchor = this._getAnchor(); + if (!container || !anchor) { + this.endTour(); + return; + } + const parentEl = anchor.element; + const { doc } = this; + const arrowPosition = anchor.arrow_position || "top"; + const arrowWidth = anchor.arrow_width || 33.94113; + const arrowHeight = arrowWidth / 2; + const overlapAmount = 5; + let overlap = overlapAmount - arrowHeight; + // Is the document layout right to left? + const RTL = this.doc.dir === "rtl"; + const customPosition = anchor.absolute_position; + + 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 centerVertically = () => { + let topOffset = + (container.getBoundingClientRect().height - + parentEl.getBoundingClientRect().height) / + 2; + container.style.top = `${getOffset(parentEl).top - topOffset}px`; + }; + + /** + * Horizontally align a top/bottom-positioned callout according to the + * passed position. + * @param {String} position one of... + * - "center": for use with top/bottom. arrow is in the center, and the + * center of the callout aligns with the parent center. + * - "center-arrow-start": for use with center-arrow-top-start. arrow is + * on the start (left) side of the callout, and the callout is aligned + * so that the arrow points to the center of the parent element. + * - "center-arrow-end": for use with center-arrow-top-end. arrow is on + * the end, and the arrow points to the center of the parent. + * - "start": currently unused. align the callout's starting edge with the + * parent's starting edge. + * - "end": currently unused. same as start but for the ending edge. + */ + const alignHorizontally = position => { + switch (position) { + case "center": { + const sideOffset = + (parentEl.getBoundingClientRect().width - + container.getBoundingClientRect().width) / + 2; + const containerSide = RTL + ? doc.documentElement.clientWidth - + getOffset(parentEl).right + + sideOffset + : getOffset(parentEl).left + sideOffset; + container.style[RTL ? "right" : "left"] = `${Math.max( + containerSide, + 0 + )}px`; + break; + } + case "end": + case "start": { + const containerSide = + RTL ^ (position === "end") + ? parentEl.getBoundingClientRect().left + + parentEl.getBoundingClientRect().width - + container.getBoundingClientRect().width + : parentEl.getBoundingClientRect().left; + container.style.left = `${Math.max(containerSide, 0)}px`; + break; + } + case "center-arrow-end": + case "center-arrow-start": { + const parentRect = parentEl.getBoundingClientRect(); + const containerWidth = container.getBoundingClientRect().width; + const containerSide = + RTL ^ position.endsWith("end") + ? parentRect.left + + parentRect.width / 2 + + 12 + + arrowWidth / 2 - + containerWidth + : parentRect.left + parentRect.width / 2 - 12 - arrowWidth / 2; + const maxContainerSide = + doc.documentElement.clientWidth - containerWidth; + container.style.left = `${Math.min( + maxContainerSide, + Math.max(containerSide, 0) + )}px`; + } + } + }; + + // Remember not to use HTML-only properties/methods like offsetHeight. Try + // to use getBoundingClientRect() instead, which is available on XUL + // elements. This is necessary to support feature callout in chrome, which + // is still largely XUL-based. + 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.getBoundingClientRect().height + ); + }, + neededSpace: container.getBoundingClientRect().height - overlap, + position() { + // Point to an element above the callout + let containerTop = + getOffset(parentEl).top + + parentEl.getBoundingClientRect().height - + overlap; + container.style.top = `${Math.max(0, containerTop)}px`; + alignHorizontally("center"); + }, + }, + bottom: { + availableSpace() { + return getOffset(parentEl).top; + }, + neededSpace: container.getBoundingClientRect().height - overlap, + position() { + // Point to an element below the callout + let containerTop = + getOffset(parentEl).top - + container.getBoundingClientRect().height + + overlap; + container.style.top = `${Math.max(0, containerTop)}px`; + alignHorizontally("center"); + }, + }, + right: { + availableSpace() { + return getOffset(parentEl).left; + }, + neededSpace: container.getBoundingClientRect().width - overlap, + position() { + // Point to an element to the right of the callout + let containerLeft = + getOffset(parentEl).left - + container.getBoundingClientRect().width + + overlap; + container.style.left = `${Math.max(0, containerLeft)}px`; + if ( + container.getBoundingClientRect().height <= + parentEl.getBoundingClientRect().height + ) { + container.style.top = `${getOffset(parentEl).top}px`; + } else { + centerVertically(); + } + }, + }, + left: { + availableSpace() { + return doc.documentElement.clientWidth - getOffset(parentEl).right; + }, + neededSpace: container.getBoundingClientRect().width - overlap, + position() { + // Point to an element to the left of the callout + let containerLeft = + getOffset(parentEl).left + + parentEl.getBoundingClientRect().width - + overlap; + container.style.left = `${Math.max(0, containerLeft)}px`; + if ( + container.getBoundingClientRect().height <= + parentEl.getBoundingClientRect().height + ) { + container.style.top = `${getOffset(parentEl).top}px`; + } else { + centerVertically(); + } + }, + }, + "top-start": { + availableSpace() { + return ( + doc.documentElement.clientHeight - + getOffset(parentEl).top - + parentEl.getBoundingClientRect().height + ); + }, + neededSpace: container.getBoundingClientRect().height - overlap, + position() { + // Point to an element above and at the start of the callout + let containerTop = + getOffset(parentEl).top + + parentEl.getBoundingClientRect().height - + overlap; + container.style.top = `${Math.max(0, containerTop)}px`; + alignHorizontally("start"); + }, + }, + "top-end": { + availableSpace() { + return ( + doc.documentElement.clientHeight - + getOffset(parentEl).top - + parentEl.getBoundingClientRect().height + ); + }, + neededSpace: container.getBoundingClientRect().height - overlap, + position() { + // Point to an element above and at the end of the callout + let containerTop = + getOffset(parentEl).top + + parentEl.getBoundingClientRect().height - + overlap; + container.style.top = `${Math.max(0, containerTop)}px`; + alignHorizontally("end"); + }, + }, + "top-center-arrow-start": { + availableSpace() { + return ( + doc.documentElement.clientHeight - + getOffset(parentEl).top - + parentEl.getBoundingClientRect().height + ); + }, + neededSpace: container.getBoundingClientRect().height - overlap, + position() { + // Point to an element above and at the start of the callout + let containerTop = + getOffset(parentEl).top + + parentEl.getBoundingClientRect().height - + overlap; + container.style.top = `${Math.max(0, containerTop)}px`; + alignHorizontally("center-arrow-start"); + }, + }, + "top-center-arrow-end": { + availableSpace() { + return ( + doc.documentElement.clientHeight - + getOffset(parentEl).top - + parentEl.getBoundingClientRect().height + ); + }, + neededSpace: container.getBoundingClientRect().height - overlap, + position() { + // Point to an element above and at the end of the callout + let containerTop = + getOffset(parentEl).top + + parentEl.getBoundingClientRect().height - + overlap; + container.style.top = `${Math.max(0, containerTop)}px`; + alignHorizontally("center-arrow-end"); + }, + }, + }; + + const clearPosition = () => { + Object.keys(positioners).forEach(position => { + container.style[position] = "unset"; + }); + container.removeAttribute("arrow-position"); + }; + + const setArrowPosition = position => { + let val; + switch (position) { + case "bottom": + val = "bottom"; + break; + case "left": + val = "inline-start"; + break; + case "right": + val = "inline-end"; + break; + case "top-start": + case "top-center-arrow-start": + val = RTL ? "top-end" : "top-start"; + break; + case "top-end": + case "top-center-arrow-end": + val = RTL ? "top-start" : "top-end"; + break; + case "top": + default: + val = "top"; + break; + } + + container.setAttribute("arrow-position", val); + }; + + const addValueToPixelValue = (value, pixelValue) => { + return `${parseFloat(pixelValue) + value}px`; + }; + + const subtractPixelValueFromValue = (pixelValue, value) => { + return `${value - parseFloat(pixelValue)}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) { + if (!Object.prototype.hasOwnProperty.call(positioners, position)) { + continue; + } + + 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 + ); + + if (RTL) { + container.style.right = leftPosition; + } else { + container.style.left = leftPosition; + } + } + + if (customPosition.right) { + const rightPosition = subtractPixelValueFromValue( + customPosition.right, + parentEl.getBoundingClientRect().right - + container.getBoundingClientRect().width + ); + + if (RTL) { + container.style.right = rightPosition; + } else { + container.style.left = rightPosition; + } + } + + if (customPosition.bottom) { + container.style.top = subtractPixelValueFromValue( + customPosition.bottom, + parentEl.getBoundingClientRect().bottom - + container.getBoundingClientRect().height + ); + } + }; + } + }; + + 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("-"); + return ( + positioners[edgePosition].availableSpace() > + positioners[edgePosition].neededSpace + ); + }; + + const choosePosition = () => { + let position = arrowPosition; + if (!this._HTMLArrowPositions.includes(position)) { + // Configured arrow position is not valid + position = null; + } + 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 || (position && calloutFits(position))) { + return position; + } + let sortedPositions = ["top", "bottom", "left", "right"] + .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; + }; + + clearPosition(container); + + if (customPosition) { + overridePosition(); + } + + let finalPosition = choosePosition(); + if (finalPosition) { + positioners[finalPosition].position(); + setArrowPosition(finalPosition); + } + + container.classList.remove("hidden"); + } + + /** Expose top level functions expected by the aboutwelcome bundle. */ + _setupWindowFunctions() { + if (this.AWSetup) { + return; + } + + const handleActorMessage = + lazy.AboutWelcomeParent.prototype.onContentMessage.bind({}); + const getActionHandler = name => data => + handleActorMessage(`AWPage:${name}`, data, this.doc); + + const telemetryMessageHandler = getActionHandler("TELEMETRY_EVENT"); + const AWSendEventTelemetry = data => { + if (this.config?.metrics !== "block") { + return telemetryMessageHandler(data); + } + return null; + }; + this._windowFuncs = { + AWGetFeatureConfig: () => this.config, + AWGetSelectedTheme: getActionHandler("GET_SELECTED_THEME"), + // Do not send telemetry if message config sets metrics as 'block'. + AWSendEventTelemetry, + AWSendToDeviceEmailsSupported: getActionHandler( + "SEND_TO_DEVICE_EMAILS_SUPPORTED" + ), + AWSendToParent: (name, data) => getActionHandler(name)(data), + AWFinish: () => this.endTour(), + AWEvaluateScreenTargeting: getActionHandler("EVALUATE_SCREEN_TARGETING"), + }; + for (const [name, func] of Object.entries(this._windowFuncs)) { + this.win[name] = func; + } + + this.AWSetup = true; + } + + /** Clean up the functions defined above. */ + _clearWindowFunctions() { + if (this.AWSetup) { + this.AWSetup = false; + + for (const name of Object.keys(this._windowFuncs)) { + delete this.win[name]; + } + } + } + + /** + * Emit an event to the broker, if one is present. + * @param {String} name + * @param {any} data + */ + _emitEvent(name, data) { + this.listener?.(this.win, name, data); + } + + endTour(skipFadeOut = false) { + // We don't want focus events that happen during teardown to affect + // this.savedFocus + this.win.removeEventListener("focus", this, { + capture: true, + passive: true, + }); + this.win.removeEventListener("keypress", this, { capture: true }); + this._pageEventManager?.emit({ + type: "tourend", + target: this._container, + }); + this._container?.removeEventListener("popuphiding", this); + this._pageEventManager?.clear(); + + // Delete almost everything to get this ready to show a different message. + this.teardownFeatureTourProgress(); + this.pref = null; + this.ready = false; + this.message = null; + this.content = null; + this.currentScreen = null; + // wait for fade out transition + this._container?.classList.toggle( + "hidden", + this._container?.localName !== "panel" + ); + this._clearWindowFunctions(); + const onFadeOut = () => { + this._container?.remove(); + this.renderObserver?.disconnect(); + this._removePositionListeners(); + this._removePanelConflictListeners(); + 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.savedFocus) { + this.savedFocus.element.focus({ + focusVisible: this.savedFocus.focusVisible, + }); + } + this.savedFocus = null; + this._emitEvent("end"); + }; + if (this._container?.localName === "panel") { + this._container.addEventListener("popuphidden", onFadeOut, { + once: true, + }); + this._container.hidePopup(!skipFadeOut); + } else if (this._container) { + this.win.setTimeout(onFadeOut, skipFadeOut ? 0 : TRANSITION_MS); + } else { + onFadeOut(); + } + } + + _dismiss() { + let action = this.currentScreen?.content.dismiss_button?.action; + if (action?.type) { + this.win.AWSendToParent("SPECIAL_ACTION", action); + if (!action.dismiss) { + return; + } + } + 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 = () => { + 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 = () => { + 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 }); + } + + /** + * Update the internal config with a new message. If a message is not + * provided, try requesting one from ASRouter. The message content is stored + * in this.config, which is returned by AWGetFeatureConfig. The aboutwelcome + * bundle will use that function to get the content when it executes. + * @param {Object} [message] ASRouter message. Omit to request a new one. + * @returns {Promise<boolean>} true if a message is loaded, false if not. + */ + async _updateConfig(message) { + if (this.loadingConfig) { + return false; + } + + this.message = message || (await this._loadConfig()); + + switch (this.message.template) { + case "feature_callout": + break; + case "spotlight": + // Special handling for spotlight messages, which can be configured as a + // kind of introduction to a feature tour. + this.currentScreen = "spotlight"; + // fall through + default: + return false; + } + + this.config = this.message.content; + + // Set the default start screen. + let newScreen = this.config?.screens?.[this.config?.startScreen || 0]; + // If we have a feature tour in progress, try to set the start screen to + // whichever screen is configured in the feature tour pref. + if ( + this.config.screens && + this.config?.tour_pref_name && + this.config.tour_pref_name === this.pref?.name && + this.featureTourProgress + ) { + const newIndex = this.config.screens.findIndex( + screen => screen.id === this.featureTourProgress.screen + ); + if (newIndex !== -1) { + newScreen = this.config.screens[newIndex]; + if (newScreen?.id !== this.currentScreen?.id) { + // This is how we tell the bundle to render the correct screen. + this.config.startScreen = newIndex; + } + } + } + if (newScreen?.id === this.currentScreen?.id) { + return false; + } + + this.currentScreen = newScreen; + return true; + } + + /** + * Request a message from ASRouter, targeting the `browser` and `page` values + * passed to the constructor. + * @returns {Promise<Object>} the requested message. + */ + async _loadConfig() { + this.loadingConfig = true; + await lazy.ASRouter.waitForInitialized; + let result = await lazy.ASRouter.sendTriggerMessage({ + browser: this.browser, + // triggerId and triggerContext + id: "featureCalloutCheck", + context: { source: this.location }, + }); + this.loadingConfig = false; + return result.message; + } + + /** + * Try to render the callout in the current document. + * @returns {Promise<Boolean>} whether the callout was rendered. + */ + async _renderCallout() { + this._setupWindowFunctions(); + await this._addCalloutLinkElements(); + let container = this._createContainer(); + if (container) { + // This results in rendering the Feature Callout + await this._addScriptsAndRender(); + this._observeRender(container.querySelector(`#${CONTENT_BOX_ID}`)); + if (container.localName === "div") { + this._addPositionListeners(); + } + return true; + } + return false; + } + + /** + * For each member of the screen's page_event_listeners array, add a listener. + * @param {Array<PageEventListenerConfig>} listeners + * + * @typedef {Object} PageEventListenerConfig + * @property {PageEventListenerParams} params Event listener parameters + * @property {PageEventListenerAction} action Sent when the event fires + * + * @typedef {Object} PageEventListenerParams See PageEventManager.sys.mjs + * @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? + * @property {Number} [interval] Used only for `timeout` and `interval` event + * types. These don't set up real event listeners, but instead invoke the + * action on a timer. + * + * @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? + * @property {Boolean} [reposition] Reposition 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.location; + const message_id = this.config?.id.toUpperCase(); + const source = + typeof event.target === "string" + ? event.target + : this._getUniqueElementIdentifier(event.target); + if (action.type) { + this.win.AWSendEventTelemetry?.({ + event: "PAGE_EVENT", + event_context: { + action: action.type, + reason: event.type?.toUpperCase(), + source, + page, + }, + message_id, + }); + 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(); + } + if (action.reposition) { + this.win.requestAnimationFrame(() => this._positionCallout()); + } + } + + /** + * 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; + } + + /** + * Get the element that should be initially focused. Prioritize the primary + * button, then the secondary button, then any additional button, excluding + * pseudo-links and the dismiss button. If no button is found, focus the first + * input element. If no affirmative action is found, focus the first button, + * which is probably the dismiss button. If no button is found, focus the + * container itself. + * @returns {Element|null} The element to focus when the callout is shown. + */ + getInitialFocus() { + if (!this._container) { + return null; + } + return ( + this._container.querySelector( + ".primary:not(:disabled, [hidden], .text-link, .cta-link, .split-button)" + ) || + this._container.querySelector( + ".secondary:not(:disabled, [hidden], .text-link, .cta-link, .split-button)" + ) || + this._container.querySelector( + "button:not(:disabled, [hidden], .text-link, .cta-link, .dismiss-button, .split-button)" + ) || + this._container.querySelector("input:not(:disabled, [hidden])") || + this._container.querySelector( + "button:not(:disabled, [hidden], .text-link, .cta-link)" + ) || + this._container + ); + } + + /** + * Show a feature callout message, either by requesting one from ASRouter or + * by showing a message passed as an argument. + * @param {Object} [message] optional message to show instead of requesting one + * @returns {Promise<Boolean>} true if a message was shown + */ + async showFeatureCallout(message) { + let updated = await this._updateConfig(message); + + if (!updated || !this.config?.screens?.length) { + return !!this.currentScreen; + } + + if (!this.renderObserver) { + this.renderObserver = new this.win.MutationObserver(() => { + // Check if the Feature Callout screen has loaded for the first time + if (!this.ready && this._container.querySelector(".screen")) { + const onRender = () => { + this.ready = true; + this._pageEventManager?.clear(); + this._attachPageEventListeners( + this.currentScreen?.content?.page_event_listeners + ); + this.getInitialFocus()?.focus(); + this.win.addEventListener("keypress", this, { capture: true }); + if (this._container.localName === "div") { + this.win.addEventListener("focus", this, { + capture: true, // get the event before retargeting + passive: true, + }); + this._positionCallout(); + } else { + this._container.classList.remove("hidden"); + } + }; + if ( + this._container.localName === "div" && + this.doc.activeElement && + !this.savedFocus + ) { + let element = this.doc.activeElement; + this.savedFocus = { + element, + focusVisible: element.matches(":focus-visible"), + }; + } + // 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 + if (this._container.localName === "div") { + this.win.requestAnimationFrame(() => { + this.win.requestAnimationFrame(onRender); + }); + } else if (this._container.localName === "panel") { + const anchor = this._getAnchor(); + if (!anchor) { + this.endTour(); + return; + } + const position = anchor.panel_position_string; + this._container.addEventListener("popupshown", onRender, { + once: true, + }); + this._container.addEventListener("popuphiding", this); + this._addPanelConflictListeners(); + this._container.openPopup(anchor.element, { position }); + } + } + }); + } + + this._pageEventManager?.clear(); + this.ready = false; + this._container?.remove(); + this.renderObserver?.disconnect(); + + if (!this.cfrFeaturesUserPref) { + this.endTour(); + return false; + } + + let rendering = (await this._renderCallout()) && !!this.currentScreen; + if (!rendering) { + this.endTour(); + } + + if (this.message.template) { + lazy.ASRouter.addImpression(this.message); + } + return rendering; + } + + /** + * @typedef {Object} FeatureCalloutTheme An object with a set of custom color + * schemes and/or a preset key. If both are provided, the preset will be + * applied first, then the custom themes will override the preset values. + * @property {String} [preset] Key of {@link FeatureCallout.themePresets} + * @property {ColorScheme} [light] Custom light scheme + * @property {ColorScheme} [dark] Custom dark scheme + * @property {ColorScheme} [hcm] Custom high contrast scheme + * @property {ColorScheme} [all] Custom scheme that will be applied in all + * cases, but overridden by the other schemes if they are present. This is + * useful if the values are already controlled by the browser theme. + * @property {Boolean} [simulateContent] Set to true if the feature callout + * exists in the browser chrome but is meant to be displayed over the + * content area to appear as if it is part of the page. This will cause the + * styles to use a media query targeting the content instead of the chrome, + * so that if the browser theme doesn't match the content color scheme, the + * callout will correctly follow the content scheme. This is currently used + * for the feature callouts displayed over the PDF.js viewer. + */ + + /** + * @typedef {Object} ColorScheme An object with key-value pairs, with keys + * from {@link FeatureCallout.themePropNames}, mapped to CSS color values + */ + + /** + * Combine the preset and custom themes into a single object and store it. + * @param {FeatureCalloutTheme} theme + */ + _initTheme(theme) { + /** @type {FeatureCalloutTheme} */ + this.theme = Object.assign( + {}, + FeatureCallout.themePresets[theme.preset], + theme + ); + } + + /** + * Apply all the theme colors to the feature callout's root element as CSS + * custom properties in inline styles. These custom properties are consumed by + * _feature-callout-theme.scss, which is bundled with the other styles that + * are loaded by {@link FeatureCallout.prototype._addCalloutLinkElements}. + */ + _applyTheme() { + if (this._container) { + // This tells the stylesheets to use -moz-content-prefers-color-scheme + // instead of prefers-color-scheme, in order to follow the content color + // scheme instead of the chrome color scheme, in case of a mismatch when + // the feature callout exists in the chrome but is meant to look like it's + // part of the content of a page in a browser tab (like PDF.js). + this._container.classList.toggle( + "simulateContent", + !!this.theme.simulateContent + ); + for (const type of ["light", "dark", "hcm"]) { + const scheme = this.theme[type]; + for (const name of FeatureCallout.themePropNames) { + this._setThemeVariable( + `--fc-${name}-${type}`, + scheme?.[name] || this.theme.all?.[name] + ); + } + } + } + } + + /** + * Set or remove a CSS custom property on the feature callout container + * @param {String} name Name of the CSS custom property + * @param {String|void} [value] Value of the property, or omit to remove it + */ + _setThemeVariable(name, value) { + if (value) { + this._container.style.setProperty(name, value); + } else { + this._container.style.removeProperty(name); + } + } + + /** A list of all the theme properties that can be set */ + static themePropNames = [ + "background", + "color", + "border", + "accent-color", + "button-background", + "button-color", + "button-border", + "button-background-hover", + "button-color-hover", + "button-border-hover", + "button-background-active", + "button-color-active", + "button-border-active", + "primary-button-background", + "primary-button-color", + "primary-button-border", + "primary-button-background-hover", + "primary-button-color-hover", + "primary-button-border-hover", + "primary-button-background-active", + "primary-button-color-active", + "primary-button-border-active", + "link-color", + "link-color-hover", + "link-color-active", + ]; + + /** @type {Object<String, FeatureCalloutTheme>} */ + static themePresets = { + // For themed system pages like New Tab and Firefox View. Themed content + // colors inherit from the user's theme through contentTheme.js. + "themed-content": { + all: { + background: "var(--newtab-background-color-secondary)", + color: "var(--newtab-text-primary-color, var(--in-content-page-color))", + border: + "color-mix(in srgb, var(--newtab-background-color-secondary) 80%, #000)", + "accent-color": "var(--in-content-primary-button-background)", + "button-background": "color-mix(in srgb, transparent 93%, #000)", + "button-color": + "var(--newtab-text-primary-color, var(--in-content-page-color))", + "button-border": "transparent", + "button-background-hover": "color-mix(in srgb, transparent 88%, #000)", + "button-color-hover": + "var(--newtab-text-primary-color, var(--in-content-page-color))", + "button-border-hover": "transparent", + "button-background-active": "color-mix(in srgb, transparent 80%, #000)", + "button-color-active": + "var(--newtab-text-primary-color, var(--in-content-page-color))", + "button-border-active": "transparent", + "primary-button-background": + "var(--in-content-primary-button-background)", + "primary-button-color": "var(--in-content-primary-button-text-color)", + "primary-button-border": + "var(--in-content-primary-button-border-color)", + "primary-button-background-hover": + "var(--in-content-primary-button-background-hover)", + "primary-button-color-hover": + "var(--in-content-primary-button-text-color-hover)", + "primary-button-border-hover": + "var(--in-content-primary-button-border-hover)", + "primary-button-background-active": + "var(--in-content-primary-button-background-active)", + "primary-button-color-active": + "var(--in-content-primary-button-text-color-active)", + "primary-button-border-active": + "var(--in-content-primary-button-border-active)", + "link-color": "LinkText", + "link-color-hover": "LinkText", + "link-color-active": "ActiveText", + "link-color-visited": "VisitedText", + }, + dark: { + border: + "color-mix(in srgb, var(--newtab-background-color-secondary) 80%, #FFF)", + "button-background": "color-mix(in srgb, transparent 80%, #000)", + "button-background-hover": "color-mix(in srgb, transparent 65%, #000)", + "button-background-active": "color-mix(in srgb, transparent 55%, #000)", + }, + hcm: { + background: "-moz-dialog", + color: "-moz-dialogtext", + border: "-moz-dialogtext", + "accent-color": "LinkText", + "button-background": "ButtonFace", + "button-color": "ButtonText", + "button-border": "ButtonText", + "button-background-hover": "ButtonText", + "button-color-hover": "ButtonFace", + "button-border-hover": "ButtonText", + "button-background-active": "ButtonText", + "button-color-active": "ButtonFace", + "button-border-active": "ButtonText", + }, + }, + // PDF.js colors are from toolkit/components/pdfjs/content/web/viewer.css + pdfjs: { + all: { + background: "#FFF", + color: "rgb(12, 12, 13)", + border: "#CFCFD8", + "accent-color": "#0A84FF", + "button-background": "rgb(215, 215, 219)", + "button-color": "rgb(12, 12, 13)", + "button-border": "transparent", + "button-background-hover": "rgb(221, 222, 223)", + "button-color-hover": "rgb(12, 12, 13)", + "button-border-hover": "transparent", + "button-background-active": "rgb(221, 222, 223)", + "button-color-active": "rgb(12, 12, 13)", + "button-border-active": "transparent", + // use default primary button colors in _feature-callout-theme.scss + "link-color": "LinkText", + "link-color-hover": "LinkText", + "link-color-active": "ActiveText", + "link-color-visited": "VisitedText", + }, + dark: { + background: "#1C1B22", + color: "#F9F9FA", + border: "#3A3944", + "button-background": "rgb(74, 74, 79)", + "button-color": "#F9F9FA", + "button-background-hover": "rgb(102, 102, 103)", + "button-color-hover": "#F9F9FA", + "button-background-active": "rgb(102, 102, 103)", + "button-color-active": "#F9F9FA", + }, + hcm: { + background: "-moz-dialog", + color: "-moz-dialogtext", + border: "CanvasText", + "accent-color": "Highlight", + "button-background": "ButtonFace", + "button-color": "ButtonText", + "button-border": "ButtonText", + "button-background-hover": "Highlight", + "button-color-hover": "CanvasText", + "button-border-hover": "Highlight", + "button-background-active": "Highlight", + "button-color-active": "CanvasText", + "button-border-active": "Highlight", + }, + }, + newtab: { + all: { + background: "var(--newtab-background-color-secondary, #FFF)", + color: "var(--newtab-text-primary-color, WindowText)", + border: + "color-mix(in srgb, var(--newtab-background-color-secondary, #FFF) 80%, #000)", + "accent-color": "#0061e0", + "button-background": "color-mix(in srgb, transparent 93%, #000)", + "button-color": "var(--newtab-text-primary-color, WindowText)", + "button-border": "transparent", + "button-background-hover": "color-mix(in srgb, transparent 88%, #000)", + "button-color-hover": "var(--newtab-text-primary-color, WindowText)", + "button-border-hover": "transparent", + "button-background-active": "color-mix(in srgb, transparent 80%, #000)", + "button-color-active": "var(--newtab-text-primary-color, WindowText)", + "button-border-active": "transparent", + // use default primary button colors in _feature-callout-theme.scss + "link-color": "rgb(0, 97, 224)", + "link-color-hover": "rgb(0, 97, 224)", + "link-color-active": "color-mix(in srgb, rgb(0, 97, 224) 80%, #000)", + "link-color-visited": "rgb(0, 97, 224)", + }, + dark: { + "accent-color": "rgb(0, 221, 255)", + background: "var(--newtab-background-color-secondary, #42414D)", + border: + "color-mix(in srgb, var(--newtab-background-color-secondary, #42414D) 80%, #FFF)", + "button-background": "color-mix(in srgb, transparent 80%, #000)", + "button-background-hover": "color-mix(in srgb, transparent 65%, #000)", + "button-background-active": "color-mix(in srgb, transparent 55%, #000)", + "link-color": "rgb(0, 221, 255)", + "link-color-hover": "rgb(0,221,255)", + "link-color-active": "color-mix(in srgb, rgb(0, 221, 255) 60%, #FFF)", + "link-color-visited": "rgb(0, 221, 255)", + }, + hcm: { + background: "-moz-dialog", + color: "-moz-dialogtext", + border: "-moz-dialogtext", + "accent-color": "SelectedItem", + "button-background": "ButtonFace", + "button-color": "ButtonText", + "button-border": "ButtonText", + "button-background-hover": "ButtonText", + "button-color-hover": "ButtonFace", + "button-border-hover": "ButtonText", + "button-background-active": "ButtonText", + "button-color-active": "ButtonFace", + "button-border-active": "ButtonText", + "link-color": "LinkText", + "link-color-hover": "LinkText", + "link-color-active": "ActiveText", + "link-color-visited": "VisitedText", + }, + }, + // These colors are intended to inherit the user's theme properties from the + // main chrome window, for callouts to be anchored to chrome elements. + // Specific schemes aren't necessary since the theme and frontend + // stylesheets handle these variables' values. + chrome: { + all: { + background: "var(--arrowpanel-background)", + color: "var(--arrowpanel-color)", + border: "var(--arrowpanel-border-color)", + "accent-color": "var(--focus-outline-color)", + "button-background": "var(--button-bgcolor)", + "button-color": "var(--button-color)", + "button-border": "transparent", + "button-background-hover": "var(--button-hover-bgcolor)", + "button-color-hover": "var(--button-color)", + "button-border-hover": "transparent", + "button-background-active": "var(--button-active-bgcolor)", + "button-color-active": "var(--button-color)", + "button-border-active": "transparent", + "primary-button-background": "var(--button-primary-bgcolor)", + "primary-button-color": "var(--button-primary-color)", + "primary-button-border": "transparent", + "primary-button-background-hover": + "var(--button-primary-hover-bgcolor)", + "primary-button-color-hover": "var(--button-primary-color)", + "primary-button-border-hover": "transparent", + "primary-button-background-active": + "var(--button-primary-active-bgcolor)", + "primary-button-color-active": "var(--button-primary-color)", + "primary-button-border-active": "transparent", + "link-color": "LinkText", + "link-color-hover": "LinkText", + "link-color-active": "ActiveText", + "link-color-visited": "VisitedText", + }, + }, + }; +} |