tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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();