Backed out changeset 496886cb30a5 (bug 1867152) for bc failures on browser_user_input...
[gecko.git] / browser / modules / FeatureCallout.sys.mjs
blob75f00e87986881cddd510d6d9d5e11b91ad12fa4
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
7 const lazy = {};
9 ChromeUtils.defineESModuleGetters(lazy, {
10   AboutWelcomeParent: "resource:///actors/AboutWelcomeParent.sys.mjs",
11   CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs",
12   PageEventManager: "resource://activity-stream/lib/PageEventManager.sys.mjs",
13 });
15 XPCOMUtils.defineLazyModuleGetters(lazy, {
16   ASRouter: "resource://activity-stream/lib/ASRouter.jsm",
17 });
19 const TRANSITION_MS = 500;
20 const CONTAINER_ID = "feature-callout";
21 const CONTENT_BOX_ID = "multi-stage-message-root";
22 const BUNDLE_SRC =
23   "resource://activity-stream/aboutwelcome/aboutwelcome.bundle.js";
25 XPCOMUtils.defineLazyGetter(lazy, "log", () => {
26   const { Logger } = ChromeUtils.importESModule(
27     "resource://messaging-system/lib/Logger.sys.mjs"
28   );
29   return new Logger("FeatureCallout");
30 });
32 /**
33  * Feature Callout fetches messages relevant to a given source and displays them
34  * in the parent page pointing to the element they describe.
35  */
36 export class FeatureCallout {
37   /**
38    * @typedef {Object} FeatureCalloutOptions
39    * @property {Window} win window in which messages will be rendered.
40    * @property {{name: String, defaultValue?: String}} [pref] optional pref used
41    *   to track progress through a given feature tour. for example:
42    *   {
43    *     name: "browser.pdfjs.feature-tour",
44    *     defaultValue: '{ screen: "FEATURE_CALLOUT_1", complete: false }',
45    *   }
46    *   or { name: "browser.pdfjs.feature-tour" } (defaultValue is optional)
47    * @property {String} [location] string to pass as the page when requesting
48    *   messages from ASRouter and sending telemetry.
49    * @property {String} context either "chrome" or "content". "chrome" is used
50    *   when the callout is shown in the browser chrome, and "content" is used
51    *   when the callout is shown in a content page like Firefox View.
52    * @property {MozBrowser} [browser] <browser> element responsible for the
53    *   feature callout. for content pages, this is the browser element that the
54    *   callout is being shown in. for chrome, this is the active browser.
55    * @property {Function} [listener] callback to be invoked on various callout
56    *   events to keep the broker informed of the callout's state.
57    * @property {FeatureCalloutTheme} [theme] @see FeatureCallout.themePresets
58    */
60   /** @param {FeatureCalloutOptions} options */
61   constructor({
62     win,
63     pref,
64     location,
65     context,
66     browser,
67     listener,
68     theme = {},
69   } = {}) {
70     this.win = win;
71     this.doc = win.document;
72     this.browser = browser || this.win.docShell.chromeEventHandler;
73     this.config = null;
74     this.loadingConfig = false;
75     this.message = null;
76     if (pref?.name) {
77       this.pref = pref;
78     }
79     this._featureTourProgress = null;
80     this.currentScreen = null;
81     this.renderObserver = null;
82     this.savedFocus = null;
83     this.ready = false;
84     this._positionListenersRegistered = false;
85     this._panelConflictListenersRegistered = false;
86     this.AWSetup = false;
87     this.location = location;
88     this.context = context;
89     this.listener = listener;
90     this._initTheme(theme);
92     this._handlePrefChange = this._handlePrefChange.bind(this);
94     XPCOMUtils.defineLazyPreferenceGetter(
95       this,
96       "cfrFeaturesUserPref",
97       "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features",
98       true
99     );
100     this.setupFeatureTourProgress();
102     // When the window is focused, ensure tour is synced with tours in any other
103     // instances of the parent page. This does not apply when the Callout is
104     // shown in the browser chrome.
105     if (this.context !== "chrome") {
106       this.win.addEventListener("visibilitychange", this);
107     }
109     this.win.addEventListener("unload", this);
110   }
112   setupFeatureTourProgress() {
113     if (this.featureTourProgress) {
114       return;
115     }
116     if (this.pref?.name) {
117       this._handlePrefChange(null, null, this.pref.name);
118       Services.prefs.addObserver(this.pref.name, this._handlePrefChange);
119     }
120   }
122   teardownFeatureTourProgress() {
123     if (this.pref?.name) {
124       Services.prefs.removeObserver(this.pref.name, this._handlePrefChange);
125     }
126     this._featureTourProgress = null;
127   }
129   get featureTourProgress() {
130     return this._featureTourProgress;
131   }
133   /**
134    * Get the page event manager and instantiate it if necessary. Only used by
135    * _attachPageEventListeners, since we don't want to do this unnecessary work
136    * if a message with page event listeners hasn't loaded. Other consumers
137    * should use `this._pageEventManager?.property` instead.
138    */
139   get _loadPageEventManager() {
140     if (!this._pageEventManager) {
141       this._pageEventManager = new lazy.PageEventManager(this.win);
142     }
143     return this._pageEventManager;
144   }
146   _addPositionListeners() {
147     if (!this._positionListenersRegistered) {
148       this.win.addEventListener("resize", this);
149       this._positionListenersRegistered = true;
150     }
151   }
153   _removePositionListeners() {
154     if (this._positionListenersRegistered) {
155       this.win.removeEventListener("resize", this);
156       this._positionListenersRegistered = false;
157     }
158   }
160   _addPanelConflictListeners() {
161     if (!this._panelConflictListenersRegistered) {
162       this.win.addEventListener("popupshowing", this);
163       this.win.gURLBar.controller.addQueryListener(this);
164       this._panelConflictListenersRegistered = true;
165     }
166   }
168   _removePanelConflictListeners() {
169     if (this._panelConflictListenersRegistered) {
170       this.win.removeEventListener("popupshowing", this);
171       this.win.gURLBar.controller.removeQueryListener(this);
172       this._panelConflictListenersRegistered = false;
173     }
174   }
176   /**
177    * Close the tour when the urlbar is opened in the chrome. Set up by
178    * gURLBar.controller.addQueryListener in _addPanelConflictListeners.
179    */
180   onViewOpen() {
181     this.endTour();
182   }
184   _handlePrefChange(subject, topic, prefName) {
185     switch (prefName) {
186       case this.pref?.name:
187         try {
188           this._featureTourProgress = JSON.parse(
189             Services.prefs.getStringPref(
190               this.pref.name,
191               this.pref.defaultValue ?? null
192             )
193           );
194         } catch (error) {
195           this._featureTourProgress = null;
196         }
197         if (topic === "nsPref:changed") {
198           this._maybeAdvanceScreens();
199         }
200         break;
201     }
202   }
204   async _maybeAdvanceScreens() {
205     if (this.doc.visibilityState === "hidden" || !this.featureTourProgress) {
206       return;
207     }
209     // If we have more than one screen, it means that we're displaying a feature
210     // tour, and transitions are handled based on the value of a tour progress
211     // pref. Otherwise, just show the feature callout. If a pref change results
212     // from an event in a Spotlight message, initialize the feature callout with
213     // the next message in the tour.
214     if (
215       this.config?.screens.length === 1 ||
216       this.currentScreen == "spotlight"
217     ) {
218       this.showFeatureCallout();
219       return;
220     }
222     let prefVal = this.featureTourProgress;
223     // End the tour according to the tour progress pref or if the user disabled
224     // contextual feature recommendations.
225     if (prefVal.complete || !this.cfrFeaturesUserPref) {
226       this.endTour();
227     } else if (prefVal.screen !== this.currentScreen?.id) {
228       // Pref changes only matter to us insofar as they let us advance an
229       // ongoing tour. If the tour was closed and the pref changed later, e.g.
230       // by editing the pref directly, we don't want to start up the tour again.
231       // This is more important in the chrome, which is always open.
232       if (this.context === "chrome" && !this.currentScreen) {
233         return;
234       }
235       this.ready = false;
236       this._container?.classList.toggle(
237         "hidden",
238         this._container?.localName !== "panel"
239       );
240       this._pageEventManager?.emit({
241         type: "touradvance",
242         target: this._container,
243       });
244       const onFadeOut = async () => {
245         // If the initial message was deployed from outside by ASRouter as a
246         // result of a trigger, we can't continue it through _loadConfig, since
247         // that effectively requests a message with a `featureCalloutCheck`
248         // trigger. So we need to load up the same message again, merely
249         // changing the startScreen index. Just check that the next screen and
250         // the current screen are both within the message's screens array.
251         let nextMessage = null;
252         if (
253           this.context === "chrome" &&
254           this.message?.trigger.id !== "featureCalloutCheck"
255         ) {
256           if (
257             this.config?.screens.some(s => s.id === this.currentScreen?.id) &&
258             this.config.screens.some(s => s.id === prefVal.screen)
259           ) {
260             nextMessage = this.message;
261           }
262         }
263         this._container?.remove();
264         this.renderObserver?.disconnect();
265         this._removePositionListeners();
266         this._removePanelConflictListeners();
267         this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove();
268         if (nextMessage) {
269           const isMessageUnblocked = await lazy.ASRouter.isUnblockedMessage(
270             nextMessage
271           );
272           if (!isMessageUnblocked) {
273             this.endTour();
274             return;
275           }
276         }
277         let updated = await this._updateConfig(nextMessage);
278         if (!updated && !this.currentScreen) {
279           this.endTour();
280           return;
281         }
282         let rendering = await this._renderCallout();
283         if (!rendering) {
284           this.endTour();
285         }
286       };
287       if (this._container?.localName === "panel") {
288         this._container.addEventListener("popuphidden", onFadeOut, {
289           once: true,
290         });
291         this._container.hidePopup(true);
292       } else {
293         this.win.setTimeout(onFadeOut, TRANSITION_MS);
294       }
295     }
296   }
298   handleEvent(event) {
299     switch (event.type) {
300       case "focus": {
301         if (!this._container) {
302           return;
303         }
304         // If focus has fired on the feature callout window itself, or on something
305         // contained in that window, ignore it, as we can't possibly place the focus
306         // on it after the callout is closd.
307         if (
308           event.target === this._container ||
309           (Node.isInstance(event.target) &&
310             this._container.contains(event.target))
311         ) {
312           return;
313         }
314         // Save this so that if the next focus event is re-entering the popup,
315         // then we'll put the focus back here where the user left it once we exit
316         // the feature callout series.
317         if (this.doc.activeElement) {
318           let element = this.doc.activeElement;
319           this.savedFocus = {
320             element,
321             focusVisible: element.matches(":focus-visible"),
322           };
323         } else {
324           this.savedFocus = null;
325         }
326         break;
327       }
329       case "keypress": {
330         if (event.key !== "Escape") {
331           return;
332         }
333         if (!this._container) {
334           return;
335         }
336         let focusedElement =
337           this.context === "chrome"
338             ? Services.focus.focusedElement
339             : this.doc.activeElement;
340         // If the window has a focused element, let it handle the ESC key instead.
341         if (
342           !focusedElement ||
343           focusedElement === this.doc.body ||
344           (focusedElement === this.browser && this.theme.simulateContent) ||
345           this._container.contains(focusedElement)
346         ) {
347           this.win.AWSendEventTelemetry?.({
348             event: "DISMISS",
349             event_context: {
350               source: `KEY_${event.key}`,
351               page: this.location,
352             },
353             message_id: this.config?.id.toUpperCase(),
354           });
355           this._dismiss();
356           event.preventDefault();
357         }
358         break;
359       }
361       case "visibilitychange":
362         this._maybeAdvanceScreens();
363         break;
365       case "resize":
366       case "toggle":
367         this.win.requestAnimationFrame(() => this._positionCallout());
368         break;
370       case "popupshowing":
371         // If another panel is showing, close the tour.
372         if (
373           event.target !== this._container &&
374           event.target.localName === "panel" &&
375           event.target.id !== "ctrlTab-panel" &&
376           event.target.ownerGlobal === this.win
377         ) {
378           this.endTour();
379         }
380         break;
382       case "popuphiding":
383         if (event.target === this._container) {
384           this.endTour();
385         }
386         break;
388       case "unload":
389         try {
390           this.teardownFeatureTourProgress();
391         } catch (error) {}
392         break;
394       default:
395     }
396   }
398   async _addCalloutLinkElements() {
399     for (const path of [
400       "browser/newtab/onboarding.ftl",
401       "browser/spotlight.ftl",
402       "branding/brand.ftl",
403       "toolkit/branding/brandings.ftl",
404       "browser/newtab/asrouter.ftl",
405       "browser/featureCallout.ftl",
406     ]) {
407       this.win.MozXULElement.insertFTLIfNeeded(path);
408     }
410     const addChromeSheet = async href => {
411       try {
412         this.win.windowUtils.loadSheetUsingURIString(
413           href,
414           Ci.nsIDOMWindowUtils.AUTHOR_SHEET
415         );
416       } catch (error) {
417         // the sheet was probably already loaded. I don't think there's a way to
418         // check for this via JS, but the method checks and throws if it's
419         // already loaded, so we can just treat the error as expected.
420       }
421     };
422     const addStylesheet = href => {
423       if (this.win.isChromeWindow) {
424         // for chrome, load the stylesheet using a special method to make sure
425         // it's loaded synchronously before the first paint & position.
426         return addChromeSheet(href);
427       }
428       if (this.doc.querySelector(`link[href="${href}"]`)) {
429         return null;
430       }
431       const link = this.doc.head.appendChild(this.doc.createElement("link"));
432       link.rel = "stylesheet";
433       link.href = href;
434       return null;
435     };
436     // Update styling to be compatible with about:welcome bundle
437     await addStylesheet(
438       "chrome://activity-stream/content/aboutwelcome/aboutwelcome.css"
439     );
440   }
442   /**
443    * @typedef {
444    * | "topleft"
445    * | "topright"
446    * | "bottomleft"
447    * | "bottomright"
448    * | "leftcenter"
449    * | "rightcenter"
450    * | "topcenter"
451    * | "bottomcenter"
452    * } PopupAttachmentPoint
453    *
454    * @see nsMenuPopupFrame
455    *
456    * Each attachment point corresponds to an attachment point on the edge of a
457    * frame. For example, "topleft" corresponds to the frame's top left corner,
458    * and "rightcenter" corresponds to the center of the right edge of the frame.
459    */
461   /**
462    * @typedef {Object} PanelPosition Specifies how the callout panel should be
463    *   positioned relative to the anchor element, by providing which point on
464    *   the callout should be aligned with which point on the anchor element.
465    * @property {PopupAttachmentPoint} anchor_attachment
466    * @property {PopupAttachmentPoint} callout_attachment
467    * @property {Number} [offset_x] Offset in pixels to apply to the callout
468    *   position in the horizontal direction.
469    * @property {Number} [offset_y] The same in the vertical direction.
470    *
471    * This is used when you want the callout to be displayed as a <panel>
472    * element. A panel is critical when the callout is displayed in the browser
473    * chrome, anchored to an element whose position on the screen is dynamic,
474    * such as a button. When the anchor moves, the panel will automatically move
475    * with it. Also, when the elements are aligned so that the callout would
476    * extend beyond the edge of the screen, the panel will automatically flip
477    * itself to the other side of the anchor element. This requires specifying
478    * both an anchor attachment point and a callout attachment point. For
479    * example, to get the callout to appear under a button, with its arrow on the
480    * right side of the callout:
481    * { anchor_attachment: "bottomcenter", callout_attachment: "topright" }
482    */
484   /**
485    * @typedef {
486    * | "top"
487    * | "bottom"
488    * | "end"
489    * | "start"
490    * | "top-end"
491    * | "top-start"
492    * | "top-center-arrow-end"
493    * | "top-center-arrow-start"
494    * } HTMLArrowPosition
495    *
496    * @see FeatureCallout._positionCallout()
497    * The position of the callout arrow relative to the callout container. Only
498    * used for HTML callouts, typically in content pages. If the position
499    * contains a dash, the value before the dash refers to which edge of the
500    * feature callout the arrow points from. The value after the dash describes
501    * where along that edge the arrow sits, with middle as the default.
502    */
504   /**
505    * @typedef {Object} PositionOverride CSS properties to override
506    *   the callout's position relative to the anchor element. Although the
507    *   callout is not actually a child of the anchor element, this allows
508    *   absolute positioning of the callout relative to the anchor element. In
509    *   other words, { top: "0px", left: "0px" } will position the callout in the
510    *   top left corner of the anchor element, in the same way these properties
511    *   would position a child element.
512    * @property {String} [top]
513    * @property {String} [left]
514    * @property {String} [right]
515    * @property {String} [bottom]
516    */
518   /**
519    * @typedef {Object} AnchorConfig
520    * @property {String} selector CSS selector for the anchor node.
521    * @property {PanelPosition} [panel_position] Used to show the callout in a
522    *   XUL panel. Only works in chrome documents, like the main browser window.
523    * @property {HTMLArrowPosition} [arrow_position] Used to show the callout in
524    *   an HTML div container. Mutually exclusive with panel_position.
525    * @property {PositionOverride} [absolute_position] Only used for HTML
526    *   callouts, i.e. when panel_position is not specified. Allows absolute
527    *   positioning of the callout relative to the anchor element.
528    * @property {Boolean} [hide_arrow] Whether to hide the arrow.
529    * @property {Boolean} [no_open_on_anchor] Whether to set the [open] style on
530    *   the anchor element when the callout is shown. False to set it, true to
531    *   not set it. This only works for panel callouts. Not all elements have an
532    *   [open] style. Buttons do, for example. It's usually similar to :active.
533    * @property {Number} [arrow_width] The desired width of the arrow in a number
534    *   of pixels. 33.94113 by default (this corresponds to 24px edges).
535    */
537   /**
538    * @typedef {Object} Anchor
539    * @property {String} selector
540    * @property {PanelPosition} [panel_position]
541    * @property {HTMLArrowPosition} [arrow_position]
542    * @property {PositionOverride} [absolute_position]
543    * @property {Boolean} [hide_arrow]
544    * @property {Boolean} [no_open_on_anchor]
545    * @property {Number} [arrow_width]
546    * @property {Element} element The anchor node resolved from the selector.
547    * @property {String} [panel_position_string] The panel_position joined into a
548    *   string, e.g. "bottomleft topright". Passed to XULPopupElement::openPopup.
549    */
551   /**
552    * Return the first visible anchor element for the current screen. Screens can
553    * specify multiple anchors in an array, and the first one that is visible
554    * will be used. If none are visible, return null.
555    * @returns {Anchor|null}
556    */
557   _getAnchor() {
558     /** @type {AnchorConfig[]} */
559     const anchors = Array.isArray(this.currentScreen?.anchors)
560       ? this.currentScreen.anchors
561       : [];
562     for (let anchor of anchors) {
563       if (!anchor || typeof anchor !== "object") {
564         lazy.log.debug(
565           `In ${this.location}: Invalid anchor config. Expected an object, got: ${anchor}`
566         );
567         continue;
568       }
569       const { selector, arrow_position, panel_position } = anchor;
570       let panel_position_string;
571       if (panel_position) {
572         panel_position_string = this._getPanelPositionString(panel_position);
573         // if the positionString doesn't match the format we expect, don't
574         // render the callout.
575         if (!panel_position_string && !arrow_position) {
576           lazy.log.debug(
577             `In ${
578               this.location
579             }: Invalid panel_position config. Expected an object with anchor_attachment and callout_attachment properties, got: ${JSON.stringify(
580               panel_position
581             )}`
582           );
583           continue;
584         }
585       }
586       if (
587         arrow_position &&
588         !this._HTMLArrowPositions.includes(arrow_position)
589       ) {
590         lazy.log.debug(
591           `In ${
592             this.location
593           }: Invalid arrow_position config. Expected one of ${JSON.stringify(
594             this._HTMLArrowPositions
595           )}, got: ${arrow_position}`
596         );
597         continue;
598       }
599       const element = selector && this.doc.querySelector(selector);
600       if (!element) {
601         continue; // Element doesn't exist at all.
602       }
603       const isVisible = () => {
604         if (
605           this.context === "chrome" &&
606           typeof this.win.isElementVisible === "function"
607         ) {
608           // In chrome windows, we can use the isElementVisible function to
609           // check that the element has non-zero width and height. If it was
610           // hidden, it would most likely have zero width and/or height.
611           if (!this.win.isElementVisible(element)) {
612             return false;
613           }
614         }
615         // CSS rules like visibility: hidden or display: none. These result in
616         // element being invisible and unclickable.
617         const style = this.win.getComputedStyle(element);
618         return style?.visibility === "visible" && style?.display !== "none";
619       };
620       if (!isVisible()) {
621         continue;
622       }
623       if (
624         this.context === "chrome" &&
625         element.id &&
626         anchor.selector.includes("#" + element.id)
627       ) {
628         let widget = lazy.CustomizableUI.getWidget(element.id);
629         if (
630           widget &&
631           (this.win.CustomizationHandler.isCustomizing() ||
632             widget.areaType?.includes("panel"))
633         ) {
634           // The element is a customizable widget (a toolbar item, e.g. the
635           // reload button or the downloads button). Widgets can be in various
636           // areas, like the overflow panel or the customization palette.
637           // Widgets in the palette are present in the chrome's DOM during
638           // customization, but can't be used.
639           continue;
640         }
641       }
642       return { ...anchor, panel_position_string, element };
643     }
644     return null;
645   }
647   /** @see PopupAttachmentPoint */
648   _popupAttachmentPoints = [
649     "topleft",
650     "topright",
651     "bottomleft",
652     "bottomright",
653     "leftcenter",
654     "rightcenter",
655     "topcenter",
656     "bottomcenter",
657   ];
659   /**
660    * Return a string representing the position of the panel relative to the
661    * anchor element. Passed to XULPopupElement::openPopup. The string is of the
662    * form "anchor_attachment callout_attachment".
663    *
664    * @param {PanelPosition} panelPosition
665    * @returns {String|null} A string like "bottomcenter topright", or null if
666    *   the panelPosition object is invalid.
667    */
668   _getPanelPositionString(panelPosition) {
669     const { anchor_attachment, callout_attachment } = panelPosition;
670     if (
671       !this._popupAttachmentPoints.includes(anchor_attachment) ||
672       !this._popupAttachmentPoints.includes(callout_attachment)
673     ) {
674       return null;
675     }
676     let positionString = `${anchor_attachment} ${callout_attachment}`;
677     return positionString;
678   }
680   /**
681    * Set/override methods on a panel element. Can be used to override methods on
682    * the custom element class, or to add additional methods.
683    *
684    * @param {MozPanel} panel The panel to set methods for
685    */
686   _setPanelMethods(panel) {
687     // This method is optionally called by MozPanel::_setSideAttribute, though
688     // it does not exist on the class.
689     panel.setArrowPosition = function setArrowPosition(event) {
690       if (!this.hasAttribute("show-arrow")) {
691         return;
692       }
693       let { alignmentPosition, alignmentOffset, popupAlignment } = event;
694       let positionParts = alignmentPosition?.match(
695         /^(before|after|start|end)_(before|after|start|end)$/
696       );
697       if (!positionParts) {
698         return;
699       }
700       // Hide the arrow if the `flip` behavior has caused the panel to
701       // offset relative to its anchor, since the arrow would no longer
702       // point at the true anchor. This differs from an arrow that is
703       // intentionally hidden by the user in message.
704       if (this.getAttribute("hide-arrow") !== "permanent") {
705         if (alignmentOffset) {
706           this.setAttribute("hide-arrow", "temporary");
707         } else {
708           this.removeAttribute("hide-arrow");
709         }
710       }
711       let arrowPosition = "top";
712       switch (positionParts[1]) {
713         case "start":
714         case "end": {
715           // Inline arrow, i.e. arrow is on one of the left/right edges.
716           let isRTL =
717             this.ownerGlobal.getComputedStyle(this).direction == "rtl";
718           let isRight = isRTL ^ (positionParts[1] == "start");
719           let side = isRight ? "end" : "start";
720           arrowPosition = `inline-${side}`;
721           if (popupAlignment?.includes("center")) {
722             arrowPosition = `inline-${side}`;
723           } else if (positionParts[2] == "before") {
724             arrowPosition = `inline-${side}-top`;
725           } else if (positionParts[2] == "after") {
726             arrowPosition = `inline-${side}-bottom`;
727           }
728           break;
729         }
730         case "before":
731         case "after": {
732           // Block arrow, i.e. arrow is on one of the top/bottom edges.
733           let side = positionParts[1] == "before" ? "bottom" : "top";
734           arrowPosition = side;
735           if (popupAlignment?.includes("center")) {
736             arrowPosition = side;
737           } else if (positionParts[2] == "end") {
738             arrowPosition = `${side}-end`;
739           } else if (positionParts[2] == "start") {
740             arrowPosition = `${side}-start`;
741           }
742           break;
743         }
744       }
745       this.setAttribute("arrow-position", arrowPosition);
746     };
747   }
749   _createContainer() {
750     const anchor = this._getAnchor();
751     // Don't render the callout if none of the anchors is visible.
752     if (!anchor) {
753       return false;
754     }
756     const { autohide, padding } = this.currentScreen.content;
757     const {
758       panel_position_string,
759       hide_arrow,
760       no_open_on_anchor,
761       arrow_width,
762     } = anchor;
763     const needsPanel = "MozXULElement" in this.win && !!panel_position_string;
765     if (this._container) {
766       if (needsPanel ^ (this._container?.localName === "panel")) {
767         this._container.remove();
768       }
769     }
771     if (!this._container?.parentElement) {
772       if (needsPanel) {
773         let fragment = this.win.MozXULElement.parseXULToFragment(`<panel
774             class="panel-no-padding"
775             orient="vertical"
776             ignorekeys="true"
777             noautofocus="true"
778             flip="slide"
779             type="arrow"
780             position="${panel_position_string}"
781             ${hide_arrow ? "" : 'show-arrow=""'}
782             ${autohide ? "" : 'noautohide="true"'}
783             ${no_open_on_anchor ? 'no-open-on-anchor=""' : ""}
784           />`);
785         this._container = fragment.firstElementChild;
786         this._setPanelMethods(this._container);
787       } else {
788         this._container = this.doc.createElement("div");
789         this._container?.classList.add("hidden");
790       }
791       this._container.classList.add("featureCallout", "callout-arrow");
792       if (hide_arrow) {
793         this._container.setAttribute("hide-arrow", "permanent");
794       } else {
795         this._container.removeAttribute("hide-arrow");
796       }
797       this._container.id = CONTAINER_ID;
798       this._container.setAttribute(
799         "aria-describedby",
800         `#${CONTAINER_ID} .welcome-text`
801       );
802       this._container.tabIndex = 0;
803       if (arrow_width) {
804         this._container.style.setProperty("--arrow-width", `${arrow_width}px`);
805       } else {
806         this._container.style.removeProperty("--arrow-width");
807       }
808       if (padding) {
809         this._container.style.setProperty("--callout-padding", `${padding}px`);
810       } else {
811         this._container.style.removeProperty("--callout-padding");
812       }
813       let contentBox = this.doc.createElement("div");
814       contentBox.id = CONTENT_BOX_ID;
815       contentBox.classList.add("onboardingContainer");
816       // This value is reported as the "page" in about:welcome telemetry
817       contentBox.dataset.page = this.location;
818       this._applyTheme();
819       if (needsPanel && this.win.isChromeWindow) {
820         this.doc.getElementById("mainPopupSet").appendChild(this._container);
821       } else {
822         this.doc.body.prepend(this._container);
823       }
824       const makeArrow = classPrefix => {
825         const arrowRotationBox = this.doc.createElement("div");
826         arrowRotationBox.classList.add("arrow-box", `${classPrefix}-arrow-box`);
827         const arrow = this.doc.createElement("div");
828         arrow.classList.add("arrow", `${classPrefix}-arrow`);
829         arrowRotationBox.appendChild(arrow);
830         return arrowRotationBox;
831       };
832       this._container.appendChild(makeArrow("shadow"));
833       this._container.appendChild(contentBox);
834       this._container.appendChild(makeArrow("background"));
835     }
836     return this._container;
837   }
839   /** @see HTMLArrowPosition */
840   _HTMLArrowPositions = [
841     "top",
842     "bottom",
843     "end",
844     "start",
845     "top-end",
846     "top-start",
847     "top-center-arrow-end",
848     "top-center-arrow-start",
849   ];
851   /**
852    * Set callout's position relative to parent element
853    */
854   _positionCallout() {
855     const container = this._container;
856     const anchor = this._getAnchor();
857     if (!container || !anchor) {
858       this.endTour();
859       return;
860     }
861     const parentEl = anchor.element;
862     const doc = this.doc;
863     const arrowPosition = anchor.arrow_position || "top";
864     const arrowWidth = anchor.arrow_width || 33.94113;
865     const arrowHeight = arrowWidth / 2;
866     const overlapAmount = 5;
867     let overlap = overlapAmount - arrowHeight;
868     // Is the document layout right to left?
869     const RTL = this.doc.dir === "rtl";
870     const customPosition = anchor.absolute_position;
872     const getOffset = el => {
873       const rect = el.getBoundingClientRect();
874       return {
875         left: rect.left + this.win.scrollX,
876         right: rect.right + this.win.scrollX,
877         top: rect.top + this.win.scrollY,
878         bottom: rect.bottom + this.win.scrollY,
879       };
880     };
882     const clearPosition = () => {
883       Object.keys(positioners).forEach(position => {
884         container.style[position] = "unset";
885       });
886       container.removeAttribute("arrow-position");
887     };
889     const setArrowPosition = position => {
890       let val;
891       switch (position) {
892         case "bottom":
893           val = "bottom";
894           break;
895         case "left":
896           val = "inline-start";
897           break;
898         case "right":
899           val = "inline-end";
900           break;
901         case "top-start":
902         case "top-center-arrow-start":
903           val = RTL ? "top-end" : "top-start";
904           break;
905         case "top-end":
906         case "top-center-arrow-end":
907           val = RTL ? "top-start" : "top-end";
908           break;
909         case "top":
910         default:
911           val = "top";
912           break;
913       }
915       container.setAttribute("arrow-position", val);
916     };
918     const addValueToPixelValue = (value, pixelValue) => {
919       return `${parseFloat(pixelValue) + value}px`;
920     };
922     const subtractPixelValueFromValue = (pixelValue, value) => {
923       return `${value - parseFloat(pixelValue)}px`;
924     };
926     const overridePosition = () => {
927       // We override _every_ positioner here, because we want to manually set
928       // all container.style.positions in every positioner's "position" function
929       // regardless of the actual arrow position
931       // Note: We override the position functions with new functions here, but
932       // they don't actually get executed until the respective position
933       // functions are called and this function is not executed unless the
934       // message has a custom position property.
936       // We're positioning relative to a parent element's bounds, if that parent
937       // element exists.
939       for (const position in positioners) {
940         positioners[position].position = () => {
941           if (customPosition.top) {
942             container.style.top = addValueToPixelValue(
943               parentEl.getBoundingClientRect().top,
944               customPosition.top
945             );
946           }
948           if (customPosition.left) {
949             const leftPosition = addValueToPixelValue(
950               parentEl.getBoundingClientRect().left,
951               customPosition.left
952             );
954             RTL
955               ? (container.style.right = leftPosition)
956               : (container.style.left = leftPosition);
957           }
959           if (customPosition.right) {
960             const rightPosition = subtractPixelValueFromValue(
961               customPosition.right,
962               parentEl.getBoundingClientRect().right -
963                 container.getBoundingClientRect().width
964             );
966             RTL
967               ? (container.style.right = rightPosition)
968               : (container.style.left = rightPosition);
969           }
971           if (customPosition.bottom) {
972             container.style.top = subtractPixelValueFromValue(
973               customPosition.bottom,
974               parentEl.getBoundingClientRect().bottom -
975                 container.getBoundingClientRect().height
976             );
977           }
978         };
979       }
980     };
982     // Remember not to use HTML-only properties/methods like offsetHeight. Try
983     // to use getBoundingClientRect() instead, which is available on XUL
984     // elements. This is necessary to support feature callout in chrome, which
985     // is still largely XUL-based.
986     const positioners = {
987       // availableSpace should be the space between the edge of the page in the
988       // assumed direction and the edge of the parent (with the callout being
989       // intended to fit between those two edges) while needed space should be
990       // the space necessary to fit the callout container.
991       top: {
992         availableSpace() {
993           return (
994             doc.documentElement.clientHeight -
995             getOffset(parentEl).top -
996             parentEl.getBoundingClientRect().height
997           );
998         },
999         neededSpace: container.getBoundingClientRect().height - overlap,
1000         position() {
1001           // Point to an element above the callout
1002           let containerTop =
1003             getOffset(parentEl).top +
1004             parentEl.getBoundingClientRect().height -
1005             overlap;
1006           container.style.top = `${Math.max(0, containerTop)}px`;
1007           alignHorizontally("center");
1008         },
1009       },
1010       bottom: {
1011         availableSpace() {
1012           return getOffset(parentEl).top;
1013         },
1014         neededSpace: container.getBoundingClientRect().height - overlap,
1015         position() {
1016           // Point to an element below the callout
1017           let containerTop =
1018             getOffset(parentEl).top -
1019             container.getBoundingClientRect().height +
1020             overlap;
1021           container.style.top = `${Math.max(0, containerTop)}px`;
1022           alignHorizontally("center");
1023         },
1024       },
1025       right: {
1026         availableSpace() {
1027           return getOffset(parentEl).left;
1028         },
1029         neededSpace: container.getBoundingClientRect().width - overlap,
1030         position() {
1031           // Point to an element to the right of the callout
1032           let containerLeft =
1033             getOffset(parentEl).left -
1034             container.getBoundingClientRect().width +
1035             overlap;
1036           container.style.left = `${Math.max(0, containerLeft)}px`;
1037           if (
1038             container.getBoundingClientRect().height <=
1039             parentEl.getBoundingClientRect().height
1040           ) {
1041             container.style.top = `${getOffset(parentEl).top}px`;
1042           } else {
1043             centerVertically();
1044           }
1045         },
1046       },
1047       left: {
1048         availableSpace() {
1049           return doc.documentElement.clientWidth - getOffset(parentEl).right;
1050         },
1051         neededSpace: container.getBoundingClientRect().width - overlap,
1052         position() {
1053           // Point to an element to the left of the callout
1054           let containerLeft =
1055             getOffset(parentEl).left +
1056             parentEl.getBoundingClientRect().width -
1057             overlap;
1058           container.style.left = `${Math.max(0, containerLeft)}px`;
1059           if (
1060             container.getBoundingClientRect().height <=
1061             parentEl.getBoundingClientRect().height
1062           ) {
1063             container.style.top = `${getOffset(parentEl).top}px`;
1064           } else {
1065             centerVertically();
1066           }
1067         },
1068       },
1069       "top-start": {
1070         availableSpace() {
1071           return (
1072             doc.documentElement.clientHeight -
1073             getOffset(parentEl).top -
1074             parentEl.getBoundingClientRect().height
1075           );
1076         },
1077         neededSpace: container.getBoundingClientRect().height - overlap,
1078         position() {
1079           // Point to an element above and at the start of the callout
1080           let containerTop =
1081             getOffset(parentEl).top +
1082             parentEl.getBoundingClientRect().height -
1083             overlap;
1084           container.style.top = `${Math.max(0, containerTop)}px`;
1085           alignHorizontally("start");
1086         },
1087       },
1088       "top-end": {
1089         availableSpace() {
1090           return (
1091             doc.documentElement.clientHeight -
1092             getOffset(parentEl).top -
1093             parentEl.getBoundingClientRect().height
1094           );
1095         },
1096         neededSpace: container.getBoundingClientRect().height - overlap,
1097         position() {
1098           // Point to an element above and at the end of the callout
1099           let containerTop =
1100             getOffset(parentEl).top +
1101             parentEl.getBoundingClientRect().height -
1102             overlap;
1103           container.style.top = `${Math.max(0, containerTop)}px`;
1104           alignHorizontally("end");
1105         },
1106       },
1107       "top-center-arrow-start": {
1108         availableSpace() {
1109           return (
1110             doc.documentElement.clientHeight -
1111             getOffset(parentEl).top -
1112             parentEl.getBoundingClientRect().height
1113           );
1114         },
1115         neededSpace: container.getBoundingClientRect().height - overlap,
1116         position() {
1117           // Point to an element above and at the start of the callout
1118           let containerTop =
1119             getOffset(parentEl).top +
1120             parentEl.getBoundingClientRect().height -
1121             overlap;
1122           container.style.top = `${Math.max(0, containerTop)}px`;
1123           alignHorizontally("center-arrow-start");
1124         },
1125       },
1126       "top-center-arrow-end": {
1127         availableSpace() {
1128           return (
1129             doc.documentElement.clientHeight -
1130             getOffset(parentEl).top -
1131             parentEl.getBoundingClientRect().height
1132           );
1133         },
1134         neededSpace: container.getBoundingClientRect().height - overlap,
1135         position() {
1136           // Point to an element above and at the end of the callout
1137           let containerTop =
1138             getOffset(parentEl).top +
1139             parentEl.getBoundingClientRect().height -
1140             overlap;
1141           container.style.top = `${Math.max(0, containerTop)}px`;
1142           alignHorizontally("center-arrow-end");
1143         },
1144       },
1145     };
1147     const calloutFits = position => {
1148       // Does callout element fit in this position relative
1149       // to the parent element without going off screen?
1151       // Only consider which edge of the callout the arrow points from,
1152       // not the alignment of the arrow along the edge of the callout
1153       let edgePosition = position.split("-")[0];
1154       return (
1155         positioners[edgePosition].availableSpace() >
1156         positioners[edgePosition].neededSpace
1157       );
1158     };
1160     const choosePosition = () => {
1161       let position = arrowPosition;
1162       if (!this._HTMLArrowPositions.includes(position)) {
1163         // Configured arrow position is not valid
1164         position = null;
1165       }
1166       if (["start", "end"].includes(position)) {
1167         // position here is referencing the direction that the callout container
1168         // is pointing to, and therefore should be the _opposite_ side of the
1169         // arrow eg. if arrow is at the "end" in LTR layouts, the container is
1170         // pointing at an element to the right of itself, while in RTL layouts
1171         // it is pointing to the left of itself
1172         position = RTL ^ (position === "start") ? "left" : "right";
1173       }
1174       // If we're overriding the position, we don't need to sort for available space
1175       if (customPosition || (position && calloutFits(position))) {
1176         return position;
1177       }
1178       let sortedPositions = ["top", "bottom", "left", "right"]
1179         .filter(p => p !== position)
1180         .filter(calloutFits)
1181         .sort((a, b) => {
1182           return (
1183             positioners[b].availableSpace() - positioners[b].neededSpace >
1184             positioners[a].availableSpace() - positioners[a].neededSpace
1185           );
1186         });
1187       // If the callout doesn't fit in any position, use the configured one.
1188       // The callout will be adjusted to overlap the parent element so that
1189       // the former doesn't go off screen.
1190       return sortedPositions[0] || position;
1191     };
1193     const centerVertically = () => {
1194       let topOffset =
1195         (container.getBoundingClientRect().height -
1196           parentEl.getBoundingClientRect().height) /
1197         2;
1198       container.style.top = `${getOffset(parentEl).top - topOffset}px`;
1199     };
1201     /**
1202      * Horizontally align a top/bottom-positioned callout according to the
1203      * passed position.
1204      * @param {String} position one of...
1205      *   - "center": for use with top/bottom. arrow is in the center, and the
1206      *       center of the callout aligns with the parent center.
1207      *   - "center-arrow-start": for use with center-arrow-top-start. arrow is
1208      *       on the start (left) side of the callout, and the callout is aligned
1209      *       so that the arrow points to the center of the parent element.
1210      *   - "center-arrow-end": for use with center-arrow-top-end. arrow is on
1211      *       the end, and the arrow points to the center of the parent.
1212      *   - "start": currently unused. align the callout's starting edge with the
1213      *       parent's starting edge.
1214      *   - "end": currently unused. same as start but for the ending edge.
1215      */
1216     const alignHorizontally = position => {
1217       switch (position) {
1218         case "center": {
1219           const sideOffset =
1220             (parentEl.getBoundingClientRect().width -
1221               container.getBoundingClientRect().width) /
1222             2;
1223           const containerSide = RTL
1224             ? doc.documentElement.clientWidth -
1225               getOffset(parentEl).right +
1226               sideOffset
1227             : getOffset(parentEl).left + sideOffset;
1228           container.style[RTL ? "right" : "left"] = `${Math.max(
1229             containerSide,
1230             0
1231           )}px`;
1232           break;
1233         }
1234         case "end":
1235         case "start": {
1236           const containerSide =
1237             RTL ^ (position === "end")
1238               ? parentEl.getBoundingClientRect().left +
1239                 parentEl.getBoundingClientRect().width -
1240                 container.getBoundingClientRect().width
1241               : parentEl.getBoundingClientRect().left;
1242           container.style.left = `${Math.max(containerSide, 0)}px`;
1243           break;
1244         }
1245         case "center-arrow-end":
1246         case "center-arrow-start": {
1247           const parentRect = parentEl.getBoundingClientRect();
1248           const containerWidth = container.getBoundingClientRect().width;
1249           const containerSide =
1250             RTL ^ position.endsWith("end")
1251               ? parentRect.left +
1252                 parentRect.width / 2 +
1253                 12 +
1254                 arrowWidth / 2 -
1255                 containerWidth
1256               : parentRect.left + parentRect.width / 2 - 12 - arrowWidth / 2;
1257           const maxContainerSide =
1258             doc.documentElement.clientWidth - containerWidth;
1259           container.style.left = `${Math.min(
1260             maxContainerSide,
1261             Math.max(containerSide, 0)
1262           )}px`;
1263         }
1264       }
1265     };
1267     clearPosition(container);
1269     if (customPosition) {
1270       overridePosition();
1271     }
1273     let finalPosition = choosePosition();
1274     if (finalPosition) {
1275       positioners[finalPosition].position();
1276       setArrowPosition(finalPosition);
1277     }
1279     container.classList.remove("hidden");
1280   }
1282   /** Expose top level functions expected by the aboutwelcome bundle. */
1283   _setupWindowFunctions() {
1284     if (this.AWSetup) {
1285       return;
1286     }
1288     const handleActorMessage =
1289       lazy.AboutWelcomeParent.prototype.onContentMessage.bind({});
1290     const getActionHandler = name => data =>
1291       handleActorMessage(`AWPage:${name}`, data, this.doc);
1293     const telemetryMessageHandler = getActionHandler("TELEMETRY_EVENT");
1294     const AWSendEventTelemetry = data => {
1295       if (this.config?.metrics !== "block") {
1296         return telemetryMessageHandler(data);
1297       }
1298       return null;
1299     };
1300     this._windowFuncs = {
1301       AWGetFeatureConfig: () => this.config,
1302       AWGetSelectedTheme: getActionHandler("GET_SELECTED_THEME"),
1303       // Do not send telemetry if message config sets metrics as 'block'.
1304       AWSendEventTelemetry,
1305       AWSendToDeviceEmailsSupported: getActionHandler(
1306         "SEND_TO_DEVICE_EMAILS_SUPPORTED"
1307       ),
1308       AWSendToParent: (name, data) => getActionHandler(name)(data),
1309       AWFinish: () => this.endTour(),
1310       AWEvaluateScreenTargeting: getActionHandler("EVALUATE_SCREEN_TARGETING"),
1311     };
1312     for (const [name, func] of Object.entries(this._windowFuncs)) {
1313       this.win[name] = func;
1314     }
1316     this.AWSetup = true;
1317   }
1319   /** Clean up the functions defined above. */
1320   _clearWindowFunctions() {
1321     if (this.AWSetup) {
1322       this.AWSetup = false;
1324       for (const name of Object.keys(this._windowFuncs)) {
1325         delete this.win[name];
1326       }
1327     }
1328   }
1330   /**
1331    * Emit an event to the broker, if one is present.
1332    * @param {String} name
1333    * @param {any} data
1334    */
1335   _emitEvent(name, data) {
1336     this.listener?.(this.win, name, data);
1337   }
1339   endTour(skipFadeOut = false) {
1340     // We don't want focus events that happen during teardown to affect
1341     // this.savedFocus
1342     this.win.removeEventListener("focus", this, {
1343       capture: true,
1344       passive: true,
1345     });
1346     this.win.removeEventListener("keypress", this, { capture: true });
1347     this._pageEventManager?.emit({
1348       type: "tourend",
1349       target: this._container,
1350     });
1351     this._container?.removeEventListener("popuphiding", this);
1352     this._pageEventManager?.clear();
1354     // Delete almost everything to get this ready to show a different message.
1355     this.teardownFeatureTourProgress();
1356     this.pref = null;
1357     this.ready = false;
1358     this.message = null;
1359     this.content = null;
1360     this.currentScreen = null;
1361     // wait for fade out transition
1362     this._container?.classList.toggle(
1363       "hidden",
1364       this._container?.localName !== "panel"
1365     );
1366     this._clearWindowFunctions();
1367     const onFadeOut = () => {
1368       this._container?.remove();
1369       this.renderObserver?.disconnect();
1370       this._removePositionListeners();
1371       this._removePanelConflictListeners();
1372       this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove();
1373       // Put the focus back to the last place the user focused outside of the
1374       // featureCallout windows.
1375       if (this.savedFocus) {
1376         this.savedFocus.element.focus({
1377           focusVisible: this.savedFocus.focusVisible,
1378         });
1379       }
1380       this.savedFocus = null;
1381       this._emitEvent("end");
1382     };
1383     if (this._container?.localName === "panel") {
1384       this._container.addEventListener("popuphidden", onFadeOut, {
1385         once: true,
1386       });
1387       this._container.hidePopup(!skipFadeOut);
1388     } else if (this._container) {
1389       this.win.setTimeout(onFadeOut, skipFadeOut ? 0 : TRANSITION_MS);
1390     } else {
1391       onFadeOut();
1392     }
1393   }
1395   _dismiss() {
1396     let action = this.currentScreen?.content.dismiss_button?.action;
1397     if (action?.type) {
1398       this.win.AWSendToParent("SPECIAL_ACTION", action);
1399       if (!action.dismiss) {
1400         return;
1401       }
1402     }
1403     this.endTour();
1404   }
1406   async _addScriptsAndRender() {
1407     const reactSrc = "resource://activity-stream/vendor/react.js";
1408     const domSrc = "resource://activity-stream/vendor/react-dom.js";
1409     // Add React script
1410     const getReactReady = async () => {
1411       return new Promise(resolve => {
1412         let reactScript = this.doc.createElement("script");
1413         reactScript.src = reactSrc;
1414         this.doc.head.appendChild(reactScript);
1415         reactScript.addEventListener("load", resolve);
1416       });
1417     };
1418     // Add ReactDom script
1419     const getDomReady = async () => {
1420       return new Promise(resolve => {
1421         let domScript = this.doc.createElement("script");
1422         domScript.src = domSrc;
1423         this.doc.head.appendChild(domScript);
1424         domScript.addEventListener("load", resolve);
1425       });
1426     };
1427     // Load React, then React Dom
1428     if (!this.doc.querySelector(`[src="${reactSrc}"]`)) {
1429       await getReactReady();
1430     }
1431     if (!this.doc.querySelector(`[src="${domSrc}"]`)) {
1432       await getDomReady();
1433     }
1434     // Load the bundle to render the content as configured.
1435     this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove();
1436     let bundleScript = this.doc.createElement("script");
1437     bundleScript.src = BUNDLE_SRC;
1438     this.doc.head.appendChild(bundleScript);
1439   }
1441   _observeRender(container) {
1442     this.renderObserver?.observe(container, { childList: true });
1443   }
1445   /**
1446    * Update the internal config with a new message. If a message is not
1447    * provided, try requesting one from ASRouter. The message content is stored
1448    * in this.config, which is returned by AWGetFeatureConfig. The aboutwelcome
1449    * bundle will use that function to get the content when it executes.
1450    * @param {Object} [message] ASRouter message. Omit to request a new one.
1451    * @returns {Promise<boolean>} true if a message is loaded, false if not.
1452    */
1453   async _updateConfig(message) {
1454     if (this.loadingConfig) {
1455       return false;
1456     }
1458     this.message = message || (await this._loadConfig());
1460     switch (this.message.template) {
1461       case "feature_callout":
1462         break;
1463       case "spotlight":
1464         // Special handling for spotlight messages, which can be configured as a
1465         // kind of introduction to a feature tour.
1466         this.currentScreen = "spotlight";
1467       // fall through
1468       default:
1469         return false;
1470     }
1472     this.config = this.message.content;
1474     // Set the default start screen.
1475     let newScreen = this.config?.screens?.[this.config?.startScreen || 0];
1476     // If we have a feature tour in progress, try to set the start screen to
1477     // whichever screen is configured in the feature tour pref.
1478     if (
1479       this.config.screens &&
1480       this.config?.tour_pref_name &&
1481       this.config.tour_pref_name === this.pref?.name &&
1482       this.featureTourProgress
1483     ) {
1484       const newIndex = this.config.screens.findIndex(
1485         screen => screen.id === this.featureTourProgress.screen
1486       );
1487       if (newIndex !== -1) {
1488         newScreen = this.config.screens[newIndex];
1489         if (newScreen?.id !== this.currentScreen?.id) {
1490           // This is how we tell the bundle to render the correct screen.
1491           this.config.startScreen = newIndex;
1492         }
1493       }
1494     }
1495     if (newScreen?.id === this.currentScreen?.id) {
1496       return false;
1497     }
1499     this.currentScreen = newScreen;
1500     return true;
1501   }
1503   /**
1504    * Request a message from ASRouter, targeting the `browser` and `page` values
1505    * passed to the constructor.
1506    * @returns {Promise<Object>} the requested message.
1507    */
1508   async _loadConfig() {
1509     this.loadingConfig = true;
1510     await lazy.ASRouter.waitForInitialized;
1511     let result = await lazy.ASRouter.sendTriggerMessage({
1512       browser: this.browser,
1513       // triggerId and triggerContext
1514       id: "featureCalloutCheck",
1515       context: { source: this.location },
1516     });
1517     this.loadingConfig = false;
1518     return result.message;
1519   }
1521   /**
1522    * Try to render the callout in the current document.
1523    * @returns {Promise<Boolean>} whether the callout was rendered.
1524    */
1525   async _renderCallout() {
1526     this._setupWindowFunctions();
1527     await this._addCalloutLinkElements();
1528     let container = this._createContainer();
1529     if (container) {
1530       // This results in rendering the Feature Callout
1531       await this._addScriptsAndRender();
1532       this._observeRender(container.querySelector("#" + CONTENT_BOX_ID));
1533       if (container.localName === "div") {
1534         this._addPositionListeners();
1535       }
1536       return true;
1537     }
1538     return false;
1539   }
1541   /**
1542    * For each member of the screen's page_event_listeners array, add a listener.
1543    * @param {Array<PageEventListenerConfig>} listeners
1544    *
1545    * @typedef {Object} PageEventListenerConfig
1546    * @property {PageEventListenerParams} params Event listener parameters
1547    * @property {PageEventListenerAction} action Sent when the event fires
1548    *
1549    * @typedef {Object} PageEventListenerParams See PageEventManager.sys.mjs
1550    * @property {String} type Event type string e.g. `click`
1551    * @property {String} [selectors] Target selector, e.g. `tag.class, #id[attr]`
1552    * @property {PageEventListenerOptions} [options] addEventListener options
1553    *
1554    * @typedef {Object} PageEventListenerOptions
1555    * @property {Boolean} [capture] Use event capturing phase?
1556    * @property {Boolean} [once] Remove listener after first event?
1557    * @property {Boolean} [preventDefault] Prevent default action?
1558    * @property {Number} [interval] Used only for `timeout` and `interval` event
1559    *   types. These don't set up real event listeners, but instead invoke the
1560    *   action on a timer.
1561    *
1562    * @typedef {Object} PageEventListenerAction Action sent to AboutWelcomeParent
1563    * @property {String} [type] Action type, e.g. `OPEN_URL`
1564    * @property {Object} [data] Extra data, properties depend on action type
1565    * @property {Boolean} [dismiss] Dismiss screen after performing action?
1566    * @property {Boolean} [reposition] Reposition screen after performing action?
1567    */
1568   _attachPageEventListeners(listeners) {
1569     listeners?.forEach(({ params, action }) =>
1570       this._loadPageEventManager[params.options?.once ? "once" : "on"](
1571         params,
1572         event => {
1573           this._handlePageEventAction(action, event);
1574           if (params.options?.preventDefault) {
1575             event.preventDefault?.();
1576           }
1577         }
1578       )
1579     );
1580   }
1582   /**
1583    * Perform an action in response to a page event.
1584    * @param {PageEventListenerAction} action
1585    * @param {Event} event Triggering event
1586    */
1587   _handlePageEventAction(action, event) {
1588     const page = this.location;
1589     const message_id = this.config?.id.toUpperCase();
1590     const source =
1591       typeof event.target === "string"
1592         ? event.target
1593         : this._getUniqueElementIdentifier(event.target);
1594     if (action.type) {
1595       this.win.AWSendEventTelemetry?.({
1596         event: "PAGE_EVENT",
1597         event_context: {
1598           action: action.type,
1599           reason: event.type?.toUpperCase(),
1600           source,
1601           page,
1602         },
1603         message_id,
1604       });
1605       this.win.AWSendToParent("SPECIAL_ACTION", action);
1606     }
1607     if (action.dismiss) {
1608       this.win.AWSendEventTelemetry?.({
1609         event: "DISMISS",
1610         event_context: { source: `PAGE_EVENT:${source}`, page },
1611         message_id,
1612       });
1613       this._dismiss();
1614     }
1615     if (action.reposition) {
1616       this.win.requestAnimationFrame(() => this._positionCallout());
1617     }
1618   }
1620   /**
1621    * For a given element, calculate a unique string that identifies it.
1622    * @param {Element} target Element to calculate the selector for
1623    * @returns {String} Computed event target selector, e.g. `button#next`
1624    */
1625   _getUniqueElementIdentifier(target) {
1626     let source;
1627     if (Element.isInstance(target)) {
1628       source = target.localName;
1629       if (target.className) {
1630         source += `.${[...target.classList].join(".")}`;
1631       }
1632       if (target.id) {
1633         source += `#${target.id}`;
1634       }
1635       if (target.attributes.length) {
1636         source += `${[...target.attributes]
1637           .filter(attr => ["is", "role", "open"].includes(attr.name))
1638           .map(attr => `[${attr.name}="${attr.value}"]`)
1639           .join("")}`;
1640       }
1641       if (this.doc.querySelectorAll(source).length > 1) {
1642         let uniqueAncestor = target.closest(`[id]:not(:scope, :root, body)`);
1643         if (uniqueAncestor) {
1644           source = `${this._getUniqueElementIdentifier(
1645             uniqueAncestor
1646           )} > ${source}`;
1647         }
1648       }
1649     }
1650     return source;
1651   }
1653   /**
1654    * Get the element that should be initially focused. Prioritize the primary
1655    * button, then the secondary button, then any additional button, excluding
1656    * pseudo-links and the dismiss button. If no button is found, focus the first
1657    * input element. If no affirmative action is found, focus the first button,
1658    * which is probably the dismiss button. If no button is found, focus the
1659    * container itself.
1660    * @returns {Element|null} The element to focus when the callout is shown.
1661    */
1662   getInitialFocus() {
1663     if (!this._container) {
1664       return null;
1665     }
1666     return (
1667       this._container.querySelector(
1668         ".primary:not(:disabled, [hidden], .text-link, .cta-link)"
1669       ) ||
1670       this._container.querySelector(
1671         ".secondary:not(:disabled, [hidden], .text-link, .cta-link)"
1672       ) ||
1673       this._container.querySelector(
1674         "button:not(:disabled, [hidden], .text-link, .cta-link, .dismiss-button)"
1675       ) ||
1676       this._container.querySelector("input:not(:disabled, [hidden])") ||
1677       this._container.querySelector(
1678         "button:not(:disabled, [hidden], .text-link, .cta-link)"
1679       ) ||
1680       this._container
1681     );
1682   }
1684   /**
1685    * Show a feature callout message, either by requesting one from ASRouter or
1686    * by showing a message passed as an argument.
1687    * @param {Object} [message] optional message to show instead of requesting one
1688    * @returns {Promise<Boolean>} true if a message was shown
1689    */
1690   async showFeatureCallout(message) {
1691     let updated = await this._updateConfig(message);
1693     if (!updated || !this.config?.screens?.length) {
1694       return !!this.currentScreen;
1695     }
1697     if (!this.renderObserver) {
1698       this.renderObserver = new this.win.MutationObserver(() => {
1699         // Check if the Feature Callout screen has loaded for the first time
1700         if (!this.ready && this._container.querySelector(".screen")) {
1701           const onRender = () => {
1702             this.ready = true;
1703             this._pageEventManager?.clear();
1704             this._attachPageEventListeners(
1705               this.currentScreen?.content?.page_event_listeners
1706             );
1707             this.getInitialFocus()?.focus();
1708             this.win.addEventListener("keypress", this, { capture: true });
1709             if (this._container.localName === "div") {
1710               this.win.addEventListener("focus", this, {
1711                 capture: true, // get the event before retargeting
1712                 passive: true,
1713               });
1714               this._positionCallout();
1715             } else {
1716               this._container.classList.remove("hidden");
1717             }
1718           };
1719           if (
1720             this._container.localName === "div" &&
1721             this.doc.activeElement &&
1722             !this.savedFocus
1723           ) {
1724             let element = this.doc.activeElement;
1725             this.savedFocus = {
1726               element,
1727               focusVisible: element.matches(":focus-visible"),
1728             };
1729           }
1730           // Once the screen element is added to the DOM, wait for the
1731           // animation frame after next to ensure that _positionCallout
1732           // has access to the rendered screen with the correct height
1733           if (this._container.localName === "div") {
1734             this.win.requestAnimationFrame(() => {
1735               this.win.requestAnimationFrame(onRender);
1736             });
1737           } else if (this._container.localName === "panel") {
1738             const anchor = this._getAnchor();
1739             if (!anchor) {
1740               this.endTour();
1741               return;
1742             }
1743             const position = anchor.panel_position_string;
1744             this._container.addEventListener("popupshown", onRender, {
1745               once: true,
1746             });
1747             this._container.addEventListener("popuphiding", this);
1748             this._addPanelConflictListeners();
1749             this._container.openPopup(anchor.element, { position });
1750           }
1751         }
1752       });
1753     }
1755     this._pageEventManager?.clear();
1756     this.ready = false;
1757     this._container?.remove();
1758     this.renderObserver?.disconnect();
1760     if (!this.cfrFeaturesUserPref) {
1761       this.endTour();
1762       return false;
1763     }
1765     let rendering = (await this._renderCallout()) && !!this.currentScreen;
1766     if (!rendering) {
1767       this.endTour();
1768     }
1770     if (this.message.template) {
1771       lazy.ASRouter.addImpression(this.message);
1772     }
1773     return rendering;
1774   }
1776   /**
1777    * @typedef {Object} FeatureCalloutTheme An object with a set of custom color
1778    *   schemes and/or a preset key. If both are provided, the preset will be
1779    *   applied first, then the custom themes will override the preset values.
1780    * @property {String} [preset] Key of {@link FeatureCallout.themePresets}
1781    * @property {ColorScheme} [light] Custom light scheme
1782    * @property {ColorScheme} [dark] Custom dark scheme
1783    * @property {ColorScheme} [hcm] Custom high contrast scheme
1784    * @property {ColorScheme} [all] Custom scheme that will be applied in all
1785    *   cases, but overridden by the other schemes if they are present. This is
1786    *   useful if the values are already controlled by the browser theme.
1787    * @property {Boolean} [simulateContent] Set to true if the feature callout
1788    *   exists in the browser chrome but is meant to be displayed over the
1789    *   content area to appear as if it is part of the page. This will cause the
1790    *   styles to use a media query targeting the content instead of the chrome,
1791    *   so that if the browser theme doesn't match the content color scheme, the
1792    *   callout will correctly follow the content scheme. This is currently used
1793    *   for the feature callouts displayed over the PDF.js viewer.
1794    */
1796   /**
1797    * @typedef {Object} ColorScheme An object with key-value pairs, with keys
1798    *   from {@link FeatureCallout.themePropNames}, mapped to CSS color values
1799    */
1801   /**
1802    * Combine the preset and custom themes into a single object and store it.
1803    * @param {FeatureCalloutTheme} theme
1804    */
1805   _initTheme(theme) {
1806     /** @type {FeatureCalloutTheme} */
1807     this.theme = Object.assign(
1808       {},
1809       FeatureCallout.themePresets[theme.preset],
1810       theme
1811     );
1812   }
1814   /**
1815    * Apply all the theme colors to the feature callout's root element as CSS
1816    * custom properties in inline styles. These custom properties are consumed by
1817    * _feature-callout-theme.scss, which is bundled with the other styles that
1818    * are loaded by {@link FeatureCallout.prototype._addCalloutLinkElements}.
1819    */
1820   _applyTheme() {
1821     if (this._container) {
1822       // This tells the stylesheets to use -moz-content-prefers-color-scheme
1823       // instead of prefers-color-scheme, in order to follow the content color
1824       // scheme instead of the chrome color scheme, in case of a mismatch when
1825       // the feature callout exists in the chrome but is meant to look like it's
1826       // part of the content of a page in a browser tab (like PDF.js).
1827       this._container.classList.toggle(
1828         "simulateContent",
1829         !!this.theme.simulateContent
1830       );
1831       for (const type of ["light", "dark", "hcm"]) {
1832         const scheme = this.theme[type];
1833         for (const name of FeatureCallout.themePropNames) {
1834           this._setThemeVariable(
1835             `--fc-${name}-${type}`,
1836             scheme?.[name] || this.theme.all?.[name]
1837           );
1838         }
1839       }
1840     }
1841   }
1843   /**
1844    * Set or remove a CSS custom property on the feature callout container
1845    * @param {String} name Name of the CSS custom property
1846    * @param {String|void} [value] Value of the property, or omit to remove it
1847    */
1848   _setThemeVariable(name, value) {
1849     if (value) {
1850       this._container.style.setProperty(name, value);
1851     } else {
1852       this._container.style.removeProperty(name);
1853     }
1854   }
1856   /** A list of all the theme properties that can be set */
1857   static themePropNames = [
1858     "background",
1859     "color",
1860     "border",
1861     "accent-color",
1862     "button-background",
1863     "button-color",
1864     "button-border",
1865     "button-background-hover",
1866     "button-color-hover",
1867     "button-border-hover",
1868     "button-background-active",
1869     "button-color-active",
1870     "button-border-active",
1871     "primary-button-background",
1872     "primary-button-color",
1873     "primary-button-border",
1874     "primary-button-background-hover",
1875     "primary-button-color-hover",
1876     "primary-button-border-hover",
1877     "primary-button-background-active",
1878     "primary-button-color-active",
1879     "primary-button-border-active",
1880     "link-color",
1881     "link-color-hover",
1882     "link-color-active",
1883   ];
1885   /** @type {Object<String, FeatureCalloutTheme>} */
1886   static themePresets = {
1887     // For themed system pages like New Tab and Firefox View. Themed content
1888     // colors inherit from the user's theme through contentTheme.js.
1889     "themed-content": {
1890       all: {
1891         background: "var(--newtab-background-color-secondary)",
1892         color: "var(--newtab-text-primary-color, var(--in-content-page-color))",
1893         border:
1894           "color-mix(in srgb, var(--newtab-background-color-secondary) 80%, #000)",
1895         "accent-color": "var(--in-content-primary-button-background)",
1896         "button-background": "color-mix(in srgb, transparent 93%, #000)",
1897         "button-color":
1898           "var(--newtab-text-primary-color, var(--in-content-page-color))",
1899         "button-border": "transparent",
1900         "button-background-hover": "color-mix(in srgb, transparent 88%, #000)",
1901         "button-color-hover":
1902           "var(--newtab-text-primary-color, var(--in-content-page-color))",
1903         "button-border-hover": "transparent",
1904         "button-background-active": "color-mix(in srgb, transparent 80%, #000)",
1905         "button-color-active":
1906           "var(--newtab-text-primary-color, var(--in-content-page-color))",
1907         "button-border-active": "transparent",
1908         "primary-button-background":
1909           "var(--in-content-primary-button-background)",
1910         "primary-button-color": "var(--in-content-primary-button-text-color)",
1911         "primary-button-border":
1912           "var(--in-content-primary-button-border-color)",
1913         "primary-button-background-hover":
1914           "var(--in-content-primary-button-background-hover)",
1915         "primary-button-color-hover":
1916           "var(--in-content-primary-button-text-color-hover)",
1917         "primary-button-border-hover":
1918           "var(--in-content-primary-button-border-hover)",
1919         "primary-button-background-active":
1920           "var(--in-content-primary-button-background-active)",
1921         "primary-button-color-active":
1922           "var(--in-content-primary-button-text-color-active)",
1923         "primary-button-border-active":
1924           "var(--in-content-primary-button-border-active)",
1925         "link-color": "LinkText",
1926         "link-color-hover": "LinkText",
1927         "link-color-active": "ActiveText",
1928         "link-color-visited": "VisitedText",
1929       },
1930       dark: {
1931         border:
1932           "color-mix(in srgb, var(--newtab-background-color-secondary) 80%, #FFF)",
1933         "button-background": "color-mix(in srgb, transparent 80%, #000)",
1934         "button-background-hover": "color-mix(in srgb, transparent 65%, #000)",
1935         "button-background-active": "color-mix(in srgb, transparent 55%, #000)",
1936       },
1937       hcm: {
1938         background: "-moz-dialog",
1939         color: "-moz-dialogtext",
1940         border: "-moz-dialogtext",
1941         "accent-color": "LinkText",
1942         "button-background": "ButtonFace",
1943         "button-color": "ButtonText",
1944         "button-border": "ButtonText",
1945         "button-background-hover": "ButtonText",
1946         "button-color-hover": "ButtonFace",
1947         "button-border-hover": "ButtonText",
1948         "button-background-active": "ButtonText",
1949         "button-color-active": "ButtonFace",
1950         "button-border-active": "ButtonText",
1951       },
1952     },
1953     // PDF.js colors are from toolkit/components/pdfjs/content/web/viewer.css
1954     pdfjs: {
1955       all: {
1956         background: "#FFF",
1957         color: "rgb(12, 12, 13)",
1958         border: "#CFCFD8",
1959         "accent-color": "#0A84FF",
1960         "button-background": "rgb(215, 215, 219)",
1961         "button-color": "rgb(12, 12, 13)",
1962         "button-border": "transparent",
1963         "button-background-hover": "rgb(221, 222, 223)",
1964         "button-color-hover": "rgb(12, 12, 13)",
1965         "button-border-hover": "transparent",
1966         "button-background-active": "rgb(221, 222, 223)",
1967         "button-color-active": "rgb(12, 12, 13)",
1968         "button-border-active": "transparent",
1969         // use default primary button colors in _feature-callout-theme.scss
1970         "link-color": "LinkText",
1971         "link-color-hover": "LinkText",
1972         "link-color-active": "ActiveText",
1973         "link-color-visited": "VisitedText",
1974       },
1975       dark: {
1976         background: "#1C1B22",
1977         color: "#F9F9FA",
1978         border: "#3A3944",
1979         "button-background": "rgb(74, 74, 79)",
1980         "button-color": "#F9F9FA",
1981         "button-background-hover": "rgb(102, 102, 103)",
1982         "button-color-hover": "#F9F9FA",
1983         "button-background-active": "rgb(102, 102, 103)",
1984         "button-color-active": "#F9F9FA",
1985       },
1986       hcm: {
1987         background: "-moz-dialog",
1988         color: "-moz-dialogtext",
1989         border: "CanvasText",
1990         "accent-color": "Highlight",
1991         "button-background": "ButtonFace",
1992         "button-color": "ButtonText",
1993         "button-border": "ButtonText",
1994         "button-background-hover": "Highlight",
1995         "button-color-hover": "CanvasText",
1996         "button-border-hover": "Highlight",
1997         "button-background-active": "Highlight",
1998         "button-color-active": "CanvasText",
1999         "button-border-active": "Highlight",
2000       },
2001     },
2002     newtab: {
2003       all: {
2004         background: "var(--newtab-background-color-secondary, #FFF)",
2005         color: "var(--newtab-text-primary-color, WindowText)",
2006         border:
2007           "color-mix(in srgb, var(--newtab-background-color-secondary, #FFF) 80%, #000)",
2008         "accent-color": "#0061e0",
2009         "button-background": "color-mix(in srgb, transparent 93%, #000)",
2010         "button-color": "var(--newtab-text-primary-color, WindowText)",
2011         "button-border": "transparent",
2012         "button-background-hover": "color-mix(in srgb, transparent 88%, #000)",
2013         "button-color-hover": "var(--newtab-text-primary-color, WindowText)",
2014         "button-border-hover": "transparent",
2015         "button-background-active": "color-mix(in srgb, transparent 80%, #000)",
2016         "button-color-active": "var(--newtab-text-primary-color, WindowText)",
2017         "button-border-active": "transparent",
2018         // use default primary button colors in _feature-callout-theme.scss
2019         "link-color": "rgb(0, 97, 224)",
2020         "link-color-hover": "rgb(0, 97, 224)",
2021         "link-color-active": "color-mix(in srgb, rgb(0, 97, 224) 80%, #000)",
2022         "link-color-visited": "rgb(0, 97, 224)",
2023       },
2024       dark: {
2025         "accent-color": "rgb(0, 221, 255)",
2026         background: "var(--newtab-background-color-secondary, #42414D)",
2027         border:
2028           "color-mix(in srgb, var(--newtab-background-color-secondary, #42414D) 80%, #FFF)",
2029         "button-background": "color-mix(in srgb, transparent 80%, #000)",
2030         "button-background-hover": "color-mix(in srgb, transparent 65%, #000)",
2031         "button-background-active": "color-mix(in srgb, transparent 55%, #000)",
2032         "link-color": "rgb(0, 221, 255)",
2033         "link-color-hover": "rgb(0,221,255)",
2034         "link-color-active": "color-mix(in srgb, rgb(0, 221, 255) 60%, #FFF)",
2035         "link-color-visited": "rgb(0, 221, 255)",
2036       },
2037       hcm: {
2038         background: "-moz-dialog",
2039         color: "-moz-dialogtext",
2040         border: "-moz-dialogtext",
2041         "accent-color": "SelectedItem",
2042         "button-background": "ButtonFace",
2043         "button-color": "ButtonText",
2044         "button-border": "ButtonText",
2045         "button-background-hover": "ButtonText",
2046         "button-color-hover": "ButtonFace",
2047         "button-border-hover": "ButtonText",
2048         "button-background-active": "ButtonText",
2049         "button-color-active": "ButtonFace",
2050         "button-border-active": "ButtonText",
2051         "link-color": "LinkText",
2052         "link-color-hover": "LinkText",
2053         "link-color-active": "ActiveText",
2054         "link-color-visited": "VisitedText",
2055       },
2056     },
2057     // These colors are intended to inherit the user's theme properties from the
2058     // main chrome window, for callouts to be anchored to chrome elements.
2059     // Specific schemes aren't necessary since the theme and frontend
2060     // stylesheets handle these variables' values.
2061     chrome: {
2062       all: {
2063         background: "var(--arrowpanel-background)",
2064         color: "var(--arrowpanel-color)",
2065         border: "var(--arrowpanel-border-color)",
2066         "accent-color": "var(--focus-outline-color)",
2067         "button-background": "var(--button-bgcolor)",
2068         "button-color": "var(--button-color)",
2069         "button-border": "transparent",
2070         "button-background-hover": "var(--button-hover-bgcolor)",
2071         "button-color-hover": "var(--button-color)",
2072         "button-border-hover": "transparent",
2073         "button-background-active": "var(--button-active-bgcolor)",
2074         "button-color-active": "var(--button-color)",
2075         "button-border-active": "transparent",
2076         "primary-button-background": "var(--button-primary-bgcolor)",
2077         "primary-button-color": "var(--button-primary-color)",
2078         "primary-button-border": "transparent",
2079         "primary-button-background-hover":
2080           "var(--button-primary-hover-bgcolor)",
2081         "primary-button-color-hover": "var(--button-primary-color)",
2082         "primary-button-border-hover": "transparent",
2083         "primary-button-background-active":
2084           "var(--button-primary-active-bgcolor)",
2085         "primary-button-color-active": "var(--button-primary-color)",
2086         "primary-button-border-active": "transparent",
2087         "link-color": "LinkText",
2088         "link-color-hover": "LinkText",
2089         "link-color-active": "ActiveText",
2090         "link-color-visited": "VisitedText",
2091       },
2092     },
2093   };