diff options
Diffstat (limited to 'browser/components/asrouter/modules/FeatureCalloutBroker.sys.mjs')
-rw-r--r-- | browser/components/asrouter/modules/FeatureCalloutBroker.sys.mjs | 215 |
1 files changed, 215 insertions, 0 deletions
diff --git a/browser/components/asrouter/modules/FeatureCalloutBroker.sys.mjs b/browser/components/asrouter/modules/FeatureCalloutBroker.sys.mjs new file mode 100644 index 0000000000..7ede6c9bf8 --- /dev/null +++ b/browser/components/asrouter/modules/FeatureCalloutBroker.sys.mjs @@ -0,0 +1,215 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FeatureCallout: "resource:///modules/asrouter/FeatureCallout.sys.mjs", +}); + +/** + * @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 {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} [cleanup] callback to be invoked when the callout is + * removed or the window is unloaded. + * @property {FeatureCalloutTheme} [theme] optional dynamic color theme. + */ + +/** @typedef {import("resource:///modules/asrouter/FeatureCallout.sys.mjs").FeatureCalloutTheme} FeatureCalloutTheme */ + +/** + * @typedef {Object} FeatureCalloutItem + * @property {lazy.FeatureCallout} callout instance of FeatureCallout. + * @property {Function} [cleanup] cleanup callback. + * @property {Boolean} showing whether the callout is currently showing. + */ + +export class _FeatureCalloutBroker { + /** + * Make a new FeatureCallout instance and store it in the callout map. Also + * add an unload listener to the window to clean up the callout when the + * window is unloaded. + * @param {FeatureCalloutOptions} config + */ + makeFeatureCallout(config) { + const { win, pref, location, browser, theme } = config; + // Use an AbortController to clean up the unload listener in case the + // callout is cleaned up before the window is unloaded. + const controller = new AbortController(); + const cleanup = () => { + this.#calloutMap.delete(win); + controller.abort(); + config.cleanup?.(); + }; + this.#calloutMap.set(win, { + callout: new lazy.FeatureCallout({ + win, + pref, + location, + context: "chrome", + browser, + listener: this.handleFeatureCalloutCallback.bind(this), + theme, + }), + cleanup, + showing: false, + }); + win.addEventListener("unload", cleanup, { signal: controller.signal }); + } + + /** + * Show a feature callout message. For use by ASRouter, to be invoked when a + * trigger has matched to a feature_callout message. + * @param {MozBrowser} browser <browser> element associated with the trigger. + * @param {Object} message feature_callout message from ASRouter. + * @see {@link FeatureCalloutMessages.sys.mjs} + * @returns {Promise<Boolean>} whether the callout was shown. + */ + async showFeatureCallout(browser, message) { + // Only show one callout at a time, across all windows. + if (this.isCalloutShowing) { + return false; + } + const win = browser.ownerGlobal; + // Avoid showing feature callouts if a dialog or panel is showing. + if ( + win.gDialogBox?.dialog || + [...win.document.querySelectorAll("panel")].some(p => p.state === "open") + ) { + return false; + } + const currentCallout = this.#calloutMap.get(win); + // If a custom callout was previously showing, but is no longer showing, + // tear down the FeatureCallout instance. We avoid tearing them down when + // they stop showing because they may be shown again, and we want to avoid + // the overhead of creating a new FeatureCallout instance. But the custom + // callout instance may be incompatible with the new ASRouter message, so + // we tear it down and create a new one. + if (currentCallout && currentCallout.callout.location !== "chrome") { + currentCallout.cleanup(); + } + let item = this.#calloutMap.get(win); + let callout = item?.callout; + if (item) { + // If a callout previously showed in this instance, but the new message's + // tour_pref_name is different, update the old instance's tour properties. + callout.teardownFeatureTourProgress(); + if (message.content.tour_pref_name) { + callout.pref = { + name: message.content.tour_pref_name, + defaultValue: message.content.tour_pref_default_value, + }; + callout.setupFeatureTourProgress(); + } else { + callout.pref = null; + } + } else { + const options = { + win, + location: "chrome", + browser, + theme: { preset: "chrome" }, + }; + if (message.content.tour_pref_name) { + options.pref = { + name: message.content.tour_pref_name, + defaultValue: message.content.tour_pref_default_value, + }; + } + this.makeFeatureCallout(options); + item = this.#calloutMap.get(win); + callout = item.callout; + } + // Set this to true for now so that we can't be interrupted by another + // invocation. We'll set it to false below if it ended up not showing. + item.showing = true; + item.showing = await callout.showFeatureCallout(message).catch(() => { + item.cleanup(); + return false; + }); + return item.showing; + } + + /** + * Make a new FeatureCallout instance specific to a special location, tearing + * down the existing generic FeatureCallout if it exists, and (if no message + * is passed) requesting a feature callout message to show. Does nothing if a + * callout is already in progress. This allows the PDF.js feature tour, which + * simulates content, to be shown in the chrome window without interfering + * with chrome feature callouts. + * @param {FeatureCalloutOptions} config + * @param {Object} message feature_callout message from ASRouter. + * @see {@link FeatureCalloutMessages.sys.mjs} + * @returns {FeatureCalloutItem|null} the callout item, if one was created. + */ + showCustomFeatureCallout(config, message) { + if (this.isCalloutShowing) { + return null; + } + const { win, pref, location } = config; + const currentCallout = this.#calloutMap.get(win); + if (currentCallout && currentCallout.location !== location) { + currentCallout.cleanup(); + } + let item = this.#calloutMap.get(win); + let callout = item?.callout; + if (item) { + callout.teardownFeatureTourProgress(); + callout.pref = pref; + if (pref) { + callout.setupFeatureTourProgress(); + } + } else { + this.makeFeatureCallout(config); + item = this.#calloutMap.get(win); + callout = item.callout; + } + item.showing = true; + // In this case, callers are not necessarily async, so we don't await. + callout + .showFeatureCallout(message) + .then(showing => { + item.showing = showing; + }) + .catch(() => { + item.cleanup(); + item.showing = false; + }); + /** @type {FeatureCalloutItem} */ + return item; + } + + handleFeatureCalloutCallback(win, event, data) { + switch (event) { + case "end": + const item = this.#calloutMap.get(win); + if (item) { + item.showing = false; + } + break; + } + } + + /** @returns {Boolean} whether a callout is currently showing. */ + get isCalloutShowing() { + return [...this.#calloutMap.values()].some(({ showing }) => showing); + } + + /** @type {Map<Window, FeatureCalloutItem>} */ + #calloutMap = new Map(); +} + +export const FeatureCalloutBroker = new _FeatureCalloutBroker(); |