FeatureCalloutBroker.sys.mjs (7846B)
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/. */ 4 5 const lazy = {}; 6 7 ChromeUtils.defineESModuleGetters(lazy, { 8 FeatureCallout: "resource:///modules/asrouter/FeatureCallout.sys.mjs", 9 }); 10 11 /** 12 * @typedef {object} FeatureCalloutOptions 13 * @property {Window} win window in which messages will be rendered. 14 * @property {{name: string, defaultValue?: string}} [pref] optional pref used 15 * to track progress through a given feature tour. for example: 16 * { 17 * name: "browser.pdfjs.feature-tour", 18 * defaultValue: '{ screen: "FEATURE_CALLOUT_1", complete: false }', 19 * } 20 * or { name: "browser.pdfjs.feature-tour" } (defaultValue is optional) 21 * @property {string} [location] string to pass as the page when requesting 22 * messages from ASRouter and sending telemetry. 23 * @property {MozBrowser} [browser] <browser> element responsible for the 24 * feature callout. for content pages, this is the browser element that the 25 * callout is being shown in. for chrome, this is the active browser. 26 * @property {Function} [cleanup] callback to be invoked when the callout is 27 * removed or the window is unloaded. 28 * @property {FeatureCalloutTheme} [theme] optional dynamic color theme. 29 */ 30 31 /** @typedef {import("resource:///modules/asrouter/FeatureCallout.sys.mjs").FeatureCalloutTheme} FeatureCalloutTheme */ 32 33 /** 34 * @typedef {object} FeatureCalloutItem 35 * @property {lazy.FeatureCallout} callout instance of FeatureCallout. 36 * @property {Function} [cleanup] cleanup callback. 37 * @property {boolean} showing whether the callout is currently showing. 38 */ 39 40 export class _FeatureCalloutBroker { 41 /** 42 * Make a new FeatureCallout instance and store it in the callout map. Also 43 * add an unload listener to the window to clean up the callout when the 44 * window is unloaded. 45 * 46 * @param {FeatureCalloutOptions} config 47 */ 48 makeFeatureCallout(config) { 49 const { win, pref, location, browser, theme } = config; 50 // Use an AbortController to clean up the unload listener in case the 51 // callout is cleaned up before the window is unloaded. 52 const controller = new AbortController(); 53 const cleanup = () => { 54 this.#calloutMap.delete(win); 55 controller.abort(); 56 config.cleanup?.(); 57 }; 58 this.#calloutMap.set(win, { 59 callout: new lazy.FeatureCallout({ 60 win, 61 pref, 62 location, 63 context: "chrome", 64 browser, 65 listener: this.handleFeatureCalloutCallback.bind(this), 66 theme, 67 }), 68 cleanup, 69 showing: false, 70 }); 71 win.addEventListener("unload", cleanup, { signal: controller.signal }); 72 } 73 74 /** 75 * Show a feature callout message. For use by ASRouter, to be invoked when a 76 * trigger has matched to a feature_callout message. 77 * 78 * @param {MozBrowser} browser <browser> element associated with the trigger. 79 * @param {object} message feature_callout message from ASRouter. 80 * @see {@link FeatureCalloutMessages.sys.mjs} 81 * @returns {Promise<boolean>} whether the callout was shown. 82 */ 83 async showFeatureCallout(browser, message) { 84 // Only show one callout at a time, across all windows. 85 if (this.isCalloutShowing) { 86 return false; 87 } 88 const win = browser.ownerGlobal; 89 // Avoid showing feature callouts if a dialog or panel is showing. 90 if ( 91 win.gDialogBox?.dialog || 92 [...win.document.querySelectorAll("panel")].some(p => p.state === "open") 93 ) { 94 return false; 95 } 96 const currentCallout = this.#calloutMap.get(win); 97 // If a custom callout was previously showing, but is no longer showing, 98 // tear down the FeatureCallout instance. We avoid tearing them down when 99 // they stop showing because they may be shown again, and we want to avoid 100 // the overhead of creating a new FeatureCallout instance. But the custom 101 // callout instance may be incompatible with the new ASRouter message, so 102 // we tear it down and create a new one. 103 if (currentCallout && currentCallout.callout.location !== "chrome") { 104 currentCallout.cleanup(); 105 } 106 let item = this.#calloutMap.get(win); 107 let callout = item?.callout; 108 if (item) { 109 // If a callout previously showed in this instance, but the new message's 110 // tour_pref_name is different, update the old instance's tour properties. 111 callout.teardownFeatureTourProgress(); 112 if (message.content.tour_pref_name) { 113 callout.pref = { 114 name: message.content.tour_pref_name, 115 defaultValue: message.content.tour_pref_default_value, 116 }; 117 callout.setupFeatureTourProgress(); 118 } else { 119 callout.pref = null; 120 } 121 } else { 122 const options = { 123 win, 124 location: "chrome", 125 browser, 126 theme: { preset: "chrome" }, 127 }; 128 if (message.content.tour_pref_name) { 129 options.pref = { 130 name: message.content.tour_pref_name, 131 defaultValue: message.content.tour_pref_default_value, 132 }; 133 } 134 this.makeFeatureCallout(options); 135 item = this.#calloutMap.get(win); 136 callout = item.callout; 137 } 138 // Set this to true for now so that we can't be interrupted by another 139 // invocation. We'll set it to false below if it ended up not showing. 140 item.showing = true; 141 item.showing = await callout.showFeatureCallout(message).catch(() => { 142 item.cleanup(); 143 return false; 144 }); 145 return item.showing; 146 } 147 148 /** 149 * Make a new FeatureCallout instance specific to a special location, tearing 150 * down the existing generic FeatureCallout if it exists, and (if no message 151 * is passed) requesting a feature callout message to show. Does nothing if a 152 * callout is already in progress. This allows the PDF.js feature tour, which 153 * simulates content, to be shown in the chrome window without interfering 154 * with chrome feature callouts. 155 * 156 * @param {FeatureCalloutOptions} config 157 * @param {object} message feature_callout message from ASRouter. 158 * @see {@link FeatureCalloutMessages.sys.mjs} 159 * @returns {FeatureCalloutItem|null} the callout item, if one was created. 160 */ 161 showCustomFeatureCallout(config, message) { 162 if (this.isCalloutShowing) { 163 return null; 164 } 165 const { win, pref, location } = config; 166 const currentCallout = this.#calloutMap.get(win); 167 if (currentCallout && currentCallout.location !== location) { 168 currentCallout.cleanup(); 169 } 170 let item = this.#calloutMap.get(win); 171 let callout = item?.callout; 172 if (item) { 173 callout.teardownFeatureTourProgress(); 174 callout.pref = pref; 175 if (pref) { 176 callout.setupFeatureTourProgress(); 177 } 178 } else { 179 this.makeFeatureCallout(config); 180 item = this.#calloutMap.get(win); 181 callout = item.callout; 182 } 183 item.showing = true; 184 // In this case, callers are not necessarily async, so we don't await. 185 callout 186 .showFeatureCallout(message) 187 .then(showing => { 188 item.showing = showing; 189 }) 190 .catch(() => { 191 item.cleanup(); 192 item.showing = false; 193 }); 194 /** @type {FeatureCalloutItem} */ 195 return item; 196 } 197 198 handleFeatureCalloutCallback(win, event) { 199 switch (event) { 200 case "end": { 201 const item = this.#calloutMap.get(win); 202 if (item) { 203 item.showing = false; 204 } 205 break; 206 } 207 } 208 } 209 210 /** @returns {boolean} whether a callout is currently showing. */ 211 get isCalloutShowing() { 212 return [...this.#calloutMap.values()].some(({ showing }) => showing); 213 } 214 215 /** @type {Map<Window, FeatureCalloutItem>} */ 216 #calloutMap = new Map(); 217 } 218 219 export const FeatureCalloutBroker = new _FeatureCalloutBroker();