tor-browser

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

UrlbarController.sys.mjs (63360B)


      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 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
      6 
      7 /**
      8 * @import {ProvidersManager} from "moz-src:///browser/components/urlbar/UrlbarProvidersManager.sys.mjs"
      9 * @import {UrlbarView} from "moz-src:///browser/components/urlbar/UrlbarView.sys.mjs"
     10 */
     11 
     12 const lazy = {};
     13 
     14 ChromeUtils.defineESModuleGetters(lazy, {
     15  ExtensionUtils: "resource://gre/modules/ExtensionUtils.sys.mjs",
     16  Interactions: "moz-src:///browser/components/places/Interactions.sys.mjs",
     17  SearchbarProvidersManager:
     18    "moz-src:///browser/components/urlbar/UrlbarProvidersManager.sys.mjs",
     19  UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs",
     20  UrlbarProvidersManager:
     21    "moz-src:///browser/components/urlbar/UrlbarProvidersManager.sys.mjs",
     22  UrlbarUtils: "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs",
     23  UrlUtils: "resource://gre/modules/UrlUtils.sys.mjs",
     24 });
     25 
     26 ChromeUtils.defineLazyGetter(lazy, "logger", () =>
     27  lazy.UrlbarUtils.getLogger({ prefix: "Controller" })
     28 );
     29 
     30 const NOTIFICATIONS = {
     31  QUERY_STARTED: "onQueryStarted",
     32  QUERY_RESULTS: "onQueryResults",
     33  QUERY_RESULT_REMOVED: "onQueryResultRemoved",
     34  QUERY_CANCELLED: "onQueryCancelled",
     35  QUERY_FINISHED: "onQueryFinished",
     36  VIEW_OPEN: "onViewOpen",
     37  VIEW_CLOSE: "onViewClose",
     38 };
     39 
     40 /**
     41 * The address bar controller handles queries from the address bar, obtains
     42 * results and returns them to the UI for display.
     43 *
     44 * Listeners may be added to listen for the results. They may support the
     45 * following methods which may be called when a query is run:
     46 *
     47 * - onQueryStarted(queryContext)
     48 * - onQueryResults(queryContext)
     49 * - onQueryCancelled(queryContext)
     50 * - onQueryFinished(queryContext)
     51 * - onQueryResultRemoved(index)
     52 * - onViewOpen()
     53 * - onViewClose()
     54 */
     55 export class UrlbarController {
     56  /**
     57   * Initialises the class. The manager may be overridden here, this is for
     58   * test purposes.
     59   *
     60   * @param {object} options
     61   *   The initial options for UrlbarController.
     62   * @param {UrlbarInput} options.input
     63   *   The input this controller is operating with.
     64   * @param {object} [options.manager]
     65   *   Optional fake providers manager to override the built-in providers manager.
     66   *   Intended for use in unit tests only.
     67   */
     68  constructor(options) {
     69    if (!options.input) {
     70      throw new Error("Missing options: input");
     71    }
     72    if (!options.input.window) {
     73      throw new Error("input is missing 'window' property.");
     74    }
     75    if (
     76      !options.input.window.location ||
     77      options.input.window.location.href != AppConstants.BROWSER_CHROME_URL
     78    ) {
     79      throw new Error("input.window should be an actual browser window.");
     80    }
     81    if (!("isPrivate" in options.input)) {
     82      throw new Error("input.isPrivate must be set.");
     83    }
     84 
     85    this.input = options.input;
     86    this.browserWindow = options.input.window;
     87 
     88    /**
     89     * @type {ProvidersManager}
     90     */
     91    this.manager =
     92      options.manager ||
     93      (this.input.sapName == "searchbar"
     94        ? lazy.SearchbarProvidersManager
     95        : lazy.UrlbarProvidersManager);
     96 
     97    this._listeners = new Set();
     98    this._userSelectionBehavior = "none";
     99 
    100    this.engagementEvent = new TelemetryEvent(this);
    101  }
    102 
    103  get NOTIFICATIONS() {
    104    return NOTIFICATIONS;
    105  }
    106 
    107  /**
    108   * Hooks up the controller with a view.
    109   *
    110   * @param {UrlbarView} view
    111   *   The UrlbarView instance associated with this controller.
    112   */
    113  setView(view) {
    114    this.view = view;
    115  }
    116 
    117  /**
    118   * Takes a query context and starts the query based on the user input.
    119   *
    120   * @param {UrlbarQueryContext} queryContext The query details.
    121   * @returns {Promise<UrlbarQueryContext>}
    122   *   The updated query context.
    123   */
    124  async startQuery(queryContext) {
    125    // Cancel any running query.
    126    this.cancelQuery();
    127 
    128    // Wrap the external queryContext, to track a unique object, in case
    129    // the external consumer reuses the same context multiple times.
    130    // This also allows to add properties without polluting the context.
    131    // Note this can't be null-ed or deleted once a query is done, because it's
    132    // used by #dismissSelectedResult and handleKeyNavigation, that can run after
    133    // a query is cancelled or finished.
    134    let contextWrapper = (this._lastQueryContextWrapper = { queryContext });
    135 
    136    queryContext.lastResultCount = 0;
    137    queryContext.firstTimerId =
    138      Glean.urlbar.autocompleteFirstResultTime.start();
    139    queryContext.sixthTimerId =
    140      Glean.urlbar.autocompleteSixthResultTime.start();
    141 
    142    // For proper functionality we must ensure this notification is fired
    143    // synchronously, as soon as startQuery is invoked, but after any
    144    // notifications related to the previous query.
    145    this.notify(NOTIFICATIONS.QUERY_STARTED, queryContext);
    146    await this.manager.startQuery(queryContext, this);
    147 
    148    // If the query has been cancelled, onQueryFinished was notified already.
    149    // Note this._lastQueryContextWrapper may have changed in the meanwhile.
    150    if (
    151      contextWrapper === this._lastQueryContextWrapper &&
    152      !contextWrapper.done
    153    ) {
    154      contextWrapper.done = true;
    155      // TODO (Bug 1549936) this is necessary to avoid leaks in PB tests.
    156      this.manager.cancelQuery(queryContext);
    157      this.notify(NOTIFICATIONS.QUERY_FINISHED, queryContext);
    158    }
    159 
    160    return queryContext;
    161  }
    162 
    163  /**
    164   * Cancels an in-progress query. Note, queries may continue running if they
    165   * can't be cancelled.
    166   */
    167  cancelQuery() {
    168    // If the query finished already, don't handle cancel.
    169    if (!this._lastQueryContextWrapper || this._lastQueryContextWrapper.done) {
    170      return;
    171    }
    172 
    173    this._lastQueryContextWrapper.done = true;
    174 
    175    let { queryContext } = this._lastQueryContextWrapper;
    176 
    177    Glean.urlbar.autocompleteFirstResultTime.cancel(queryContext.firstTimerId);
    178    queryContext.firstTimerId = 0;
    179    Glean.urlbar.autocompleteSixthResultTime.cancel(queryContext.sixthTimerId);
    180    queryContext.sixthTimerId = 0;
    181 
    182    this.manager.cancelQuery(queryContext);
    183    this.notify(NOTIFICATIONS.QUERY_CANCELLED, queryContext);
    184    this.notify(NOTIFICATIONS.QUERY_FINISHED, queryContext);
    185  }
    186 
    187  /**
    188   * Receives results from a query.
    189   *
    190   * @param {UrlbarQueryContext} queryContext The query details.
    191   */
    192  receiveResults(queryContext) {
    193    if (queryContext.lastResultCount < 1 && queryContext.results.length >= 1) {
    194      Glean.urlbar.autocompleteFirstResultTime.stopAndAccumulate(
    195        queryContext.firstTimerId
    196      );
    197      queryContext.firstTimerId = 0;
    198    }
    199    if (queryContext.lastResultCount < 6 && queryContext.results.length >= 6) {
    200      Glean.urlbar.autocompleteSixthResultTime.stopAndAccumulate(
    201        queryContext.sixthTimerId
    202      );
    203      queryContext.sixthTimerId = 0;
    204    }
    205 
    206    if (queryContext.firstResultChanged) {
    207      // Notify the input so it can make adjustments based on the first result.
    208      if (this.input.onFirstResult(queryContext.results[0])) {
    209        // The input canceled the query and started a new one.
    210        return;
    211      }
    212 
    213      // The first time we receive results try to connect to the heuristic
    214      // result.
    215      this.speculativeConnect(
    216        queryContext.results[0],
    217        queryContext,
    218        "resultsadded"
    219      );
    220    }
    221 
    222    this.notify(NOTIFICATIONS.QUERY_RESULTS, queryContext);
    223    // Update lastResultCount after notifying, so the view can use it.
    224    queryContext.lastResultCount = queryContext.results.length;
    225  }
    226 
    227  /**
    228   * Adds a listener for Urlbar result notifications.
    229   *
    230   * @param {object} listener The listener to add.
    231   * @throws {TypeError} Throws if the listener is not an object.
    232   */
    233  addListener(listener) {
    234    if (!listener || typeof listener != "object") {
    235      throw new TypeError("Expected listener to be an object");
    236    }
    237    this._listeners.add(listener);
    238  }
    239 
    240  /**
    241   * Removes a listener for Urlbar result notifications.
    242   *
    243   * @param {object} listener The listener to remove.
    244   */
    245  removeListener(listener) {
    246    this._listeners.delete(listener);
    247  }
    248 
    249  /**
    250   * Checks whether a keyboard event that would normally open the view should
    251   * instead be handled natively by the input field.
    252   * On certain platforms, the up and down keys can be used to move the caret,
    253   * in which case we only want to open the view if the caret is at the
    254   * start or end of the input.
    255   *
    256   * @param {KeyboardEvent} event
    257   *   The DOM KeyboardEvent.
    258   * @returns {boolean}
    259   *   Returns true if the event should move the caret instead of opening the
    260   *   view.
    261   */
    262  keyEventMovesCaret(event) {
    263    if (this.view.isOpen) {
    264      return false;
    265    }
    266    if (AppConstants.platform != "macosx" && AppConstants.platform != "linux") {
    267      return false;
    268    }
    269    let isArrowUp = event.keyCode == KeyEvent.DOM_VK_UP;
    270    let isArrowDown = event.keyCode == KeyEvent.DOM_VK_DOWN;
    271    if (!isArrowUp && !isArrowDown) {
    272      return false;
    273    }
    274    let start = this.input.selectionStart;
    275    let end = this.input.selectionEnd;
    276    if (
    277      end != start ||
    278      (isArrowUp && start > 0) ||
    279      (isArrowDown && end < this.input.value.length)
    280    ) {
    281      return true;
    282    }
    283    return false;
    284  }
    285 
    286  /**
    287   * Receives keyboard events from the input and handles those that should
    288   * navigate within the view or pick the currently selected item.
    289   *
    290   * @param {KeyboardEvent} event
    291   *   The DOM KeyboardEvent.
    292   * @param {boolean} executeAction
    293   *   Whether the event should actually execute the associated action, or just
    294   *   be managed (at a preventDefault() level). This is used when the event
    295   *   will be deferred by the event bufferer, but preventDefault() and friends
    296   *   should still happen synchronously.
    297   */
    298  // eslint-disable-next-line complexity
    299  handleKeyNavigation(event, executeAction = true) {
    300    const isMac = AppConstants.platform == "macosx";
    301    // Handle readline/emacs-style navigation bindings on Mac.
    302    if (
    303      isMac &&
    304      this.view.isOpen &&
    305      event.ctrlKey &&
    306      (event.key == "n" || event.key == "p")
    307    ) {
    308      if (executeAction) {
    309        this.view.selectBy(1, { reverse: event.key == "p" });
    310      }
    311      event.preventDefault();
    312      return;
    313    }
    314 
    315    if (executeAction) {
    316      // In native inputs on most platforms, Shift+Up/Down moves the caret to the
    317      // start/end of the input and changes its selection, so in that case defer
    318      // handling to the input instead of changing the view's selection.
    319      if (
    320        event.shiftKey &&
    321        (event.keyCode === KeyEvent.DOM_VK_UP ||
    322          event.keyCode === KeyEvent.DOM_VK_DOWN)
    323      ) {
    324        return;
    325      }
    326 
    327      let handled = false;
    328      if (lazy.UrlbarPrefs.get("scotchBonnet.enableOverride")) {
    329        handled = this.input.searchModeSwitcher.handleKeyDown(event);
    330      } else if (this.view.isOpen && this._lastQueryContextWrapper) {
    331        let { queryContext } = this._lastQueryContextWrapper;
    332        handled = this.view.oneOffSearchButtons?.handleKeyDown(
    333          event,
    334          this.view.visibleRowCount,
    335          this.view.allowEmptySelection,
    336          queryContext.searchString
    337        );
    338      }
    339      if (handled) {
    340        return;
    341      }
    342    }
    343 
    344    switch (event.keyCode) {
    345      case KeyEvent.DOM_VK_ESCAPE:
    346        if (executeAction) {
    347          if (this.view.isOpen) {
    348            this.view.close();
    349          } else if (
    350            lazy.UrlbarPrefs.get("focusContentDocumentOnEsc") &&
    351            !this.input.searchMode &&
    352            (this.input.getAttribute("pageproxystate") == "valid" ||
    353              (this.input.value == "" &&
    354                this.browserWindow.isBlankPageURL(
    355                  this.browserWindow.gBrowser.currentURI.spec
    356                )))
    357          ) {
    358            this.browserWindow.gBrowser.selectedBrowser.focus();
    359          } else {
    360            this.input.handleRevert();
    361          }
    362        }
    363        event.preventDefault();
    364        break;
    365      case KeyEvent.DOM_VK_SPACE:
    366        if (!this.view.shouldSpaceActivateSelectedElement()) {
    367          break;
    368        }
    369      // Fall through, we want the SPACE key to activate this element.
    370      case KeyEvent.DOM_VK_RETURN:
    371        lazy.logger.debug(`Enter pressed${executeAction ? "" : " delayed"}`);
    372        if (executeAction) {
    373          this.input.handleCommand(event);
    374        }
    375        event.preventDefault();
    376        break;
    377      case KeyEvent.DOM_VK_TAB: {
    378        if (!this.view.visibleRowCount) {
    379          // Leave it to the default behaviour if there are not results.
    380          break;
    381        }
    382 
    383        // Change the tab behavior when urlbar view is open.
    384        if (
    385          lazy.UrlbarPrefs.get("scotchBonnet.enableOverride") &&
    386          this.view.isOpen &&
    387          !event.ctrlKey &&
    388          !event.altKey
    389        ) {
    390          if (
    391            (event.shiftKey &&
    392              this.view.selectedElement ==
    393                this.view.getFirstSelectableElement()) ||
    394            (!event.shiftKey &&
    395              this.view.selectedElement == this.view.getLastSelectableElement())
    396          ) {
    397            // If pressing tab + shift when the first or pressing tab when last
    398            // element has been selected, move the focus to the Unified Search
    399            // Button. Then make urlbar results selectable by tab + shift.
    400            event.preventDefault();
    401            this.view.selectedRowIndex = -1;
    402            this.focusOnUnifiedSearchButton();
    403            break;
    404          } else if (
    405            !this.view.selectedElement &&
    406            this.input.focusedViaMousedown
    407          ) {
    408            if (event.shiftKey) {
    409              this.focusOnUnifiedSearchButton();
    410            } else {
    411              this.view.selectBy(1, {
    412                userPressedTab: true,
    413              });
    414            }
    415            event.preventDefault();
    416            break;
    417          }
    418        }
    419 
    420        // It's always possible to tab through results when the urlbar was
    421        // focused with the mouse or has a search string, or when the view
    422        // already has a selection.
    423        // We allow tabbing without a search string when in search mode preview,
    424        // since that means the user has interacted with the Urlbar since
    425        // opening it.
    426        // When there's no search string and no view selection, we want to focus
    427        // the next toolbar item instead, for accessibility reasons.
    428        let allowTabbingThroughResults =
    429          this.input.focusedViaMousedown ||
    430          this.input.searchMode?.isPreview ||
    431          this.input.searchMode?.source ==
    432            lazy.UrlbarUtils.RESULT_SOURCE.ACTIONS ||
    433          this.view.selectedElement ||
    434          (this.input.value &&
    435            this.input.getAttribute("pageproxystate") != "valid");
    436        if (
    437          // Even if the view is closed, we may be waiting results, and in
    438          // such a case we don't want to tab out of the urlbar.
    439          (this.view.isOpen || !executeAction) &&
    440          !event.ctrlKey &&
    441          !event.altKey &&
    442          allowTabbingThroughResults
    443        ) {
    444          if (executeAction) {
    445            this.userSelectionBehavior = "tab";
    446            this.view.selectBy(1, {
    447              reverse: event.shiftKey,
    448              userPressedTab: true,
    449            });
    450          }
    451          event.preventDefault();
    452        }
    453        break;
    454      }
    455      case KeyEvent.DOM_VK_PAGE_DOWN:
    456      case KeyEvent.DOM_VK_PAGE_UP:
    457        if (event.ctrlKey) {
    458          break;
    459        }
    460      // eslint-disable-next-lined no-fallthrough
    461      case KeyEvent.DOM_VK_DOWN:
    462      case KeyEvent.DOM_VK_UP:
    463        if (event.altKey) {
    464          break;
    465        }
    466        if (this.view.isOpen) {
    467          if (executeAction) {
    468            this.userSelectionBehavior = "arrow";
    469            this.view.selectBy(
    470              event.keyCode == KeyEvent.DOM_VK_PAGE_DOWN ||
    471                event.keyCode == KeyEvent.DOM_VK_PAGE_UP
    472                ? lazy.UrlbarUtils.PAGE_UP_DOWN_DELTA
    473                : 1,
    474              {
    475                reverse:
    476                  event.keyCode == KeyEvent.DOM_VK_UP ||
    477                  event.keyCode == KeyEvent.DOM_VK_PAGE_UP,
    478              }
    479            );
    480          }
    481        } else {
    482          if (this.keyEventMovesCaret(event)) {
    483            break;
    484          }
    485          if (executeAction) {
    486            this.userSelectionBehavior = "arrow";
    487            this.input.startQuery({
    488              searchString: this.input.value,
    489              event,
    490            });
    491          }
    492        }
    493        event.preventDefault();
    494        break;
    495      case KeyEvent.DOM_VK_RIGHT:
    496      case KeyEvent.DOM_VK_END:
    497        this.input.maybeConfirmSearchModeFromResult({
    498          entry: "typed",
    499          startQuery: true,
    500        });
    501      // Fall through.
    502      case KeyEvent.DOM_VK_LEFT:
    503      case KeyEvent.DOM_VK_HOME:
    504        this.view.removeAccessibleFocus();
    505        break;
    506      case KeyEvent.DOM_VK_BACK_SPACE:
    507        if (
    508          this.input.searchMode &&
    509          this.input.selectionStart == 0 &&
    510          this.input.selectionEnd == 0 &&
    511          !event.shiftKey
    512        ) {
    513          this.input.searchMode = null;
    514          if (this.input.view.oneOffSearchButtons) {
    515            this.input.view.oneOffSearchButtons.selectedButton = null;
    516          }
    517          this.input.startQuery({
    518            allowAutofill: false,
    519            event,
    520          });
    521        }
    522      // Fall through.
    523      case KeyEvent.DOM_VK_DELETE:
    524        if (!this.view.isOpen) {
    525          break;
    526        }
    527        if (event.shiftKey) {
    528          if (!executeAction || this.#dismissSelectedResult(event)) {
    529            event.preventDefault();
    530          }
    531        } else if (executeAction) {
    532          this.userSelectionBehavior = "none";
    533        }
    534        break;
    535    }
    536  }
    537 
    538  /**
    539   * Tries to initialize a speculative connection on a result.
    540   * Speculative connections are only supported for a subset of all the results.
    541   *
    542   * Speculative connect to:
    543   *  - Search engine heuristic results
    544   *  - autofill results
    545   *  - http/https results
    546   *
    547   * @param {UrlbarResult} result The result to speculative connect to.
    548   * @param {UrlbarQueryContext} context The queryContext
    549   * @param {string} reason Reason for the speculative connect request.
    550   */
    551  speculativeConnect(result, context, reason) {
    552    // Never speculative connect in private contexts.
    553    if (!this.input || context.isPrivate || !context.results.length) {
    554      return;
    555    }
    556 
    557    switch (reason) {
    558      case "resultsadded": {
    559        // We should connect to an heuristic result, if it exists.
    560        if (
    561          (result == context.results[0] && result.heuristic) ||
    562          result.autofill
    563        ) {
    564          if (result.type == lazy.UrlbarUtils.RESULT_TYPE.SEARCH) {
    565            // Speculative connect only if search suggestions are enabled.
    566            if (
    567              (lazy.UrlbarPrefs.get("suggest.searches") ||
    568                context.sapName == "searchbar") &&
    569              lazy.UrlbarPrefs.get("browser.search.suggest.enabled")
    570            ) {
    571              let engine = Services.search.getEngineByName(
    572                result.payload.engine
    573              );
    574              lazy.UrlbarUtils.setupSpeculativeConnection(
    575                engine,
    576                this.browserWindow
    577              );
    578            }
    579          } else if (result.autofill) {
    580            const { url } = lazy.UrlbarUtils.getUrlFromResult(result);
    581            if (!url) {
    582              return;
    583            }
    584 
    585            lazy.UrlbarUtils.setupSpeculativeConnection(
    586              url,
    587              this.browserWindow
    588            );
    589          }
    590        }
    591        return;
    592      }
    593      case "mousedown": {
    594        const { url } = lazy.UrlbarUtils.getUrlFromResult(result);
    595        if (!url) {
    596          return;
    597        }
    598 
    599        // On mousedown, connect only to http/https urls.
    600        if (url.startsWith("http")) {
    601          lazy.UrlbarUtils.setupSpeculativeConnection(url, this.browserWindow);
    602        }
    603        return;
    604      }
    605      default: {
    606        throw new Error("Invalid speculative connection reason");
    607      }
    608    }
    609  }
    610 
    611  /**
    612   * Stores the selection behavior that the user has used to select a result.
    613   *
    614   * @param {"arrow"|"tab"|"none"} behavior
    615   *   The behavior the user used.
    616   */
    617  set userSelectionBehavior(behavior) {
    618    // Don't change the behavior to arrow if tab has already been recorded,
    619    // as we want to know that the tab was used first.
    620    if (behavior == "arrow" && this._userSelectionBehavior == "tab") {
    621      return;
    622    }
    623    this._userSelectionBehavior = behavior;
    624  }
    625 
    626  /**
    627   * Triggers a "dismiss" engagement for the selected result if one is selected
    628   * and it's not the heuristic. Providers that can respond to dismissals of
    629   * their results should implement `onEngagement()`, handle the
    630   * dismissal, and call `controller.removeResult()`.
    631   *
    632   * @param {Event} event
    633   *   The event that triggered dismissal.
    634   * @returns {boolean}
    635   *   Whether providers were notified about the engagement. Providers will not
    636   *   be notified if there is no selected result or the selected result is the
    637   *   heuristic, since the heuristic result cannot be dismissed.
    638   */
    639  #dismissSelectedResult(event) {
    640    if (!this._lastQueryContextWrapper) {
    641      console.error("Cannot dismiss selected result, last query not present");
    642      return false;
    643    }
    644    let { queryContext } = this._lastQueryContextWrapper;
    645 
    646    let { selectedElement } = this.input.view;
    647    if (selectedElement?.classList.contains("urlbarView-button")) {
    648      // For results with buttons, delete them only when the main part of the
    649      // row is selected, not a button.
    650      return false;
    651    }
    652 
    653    let result = this.input.view.selectedResult;
    654    if (!result || result.heuristic) {
    655      return false;
    656    }
    657 
    658    this.engagementEvent.record(event, {
    659      result,
    660      selType: "dismiss",
    661      searchString: queryContext.searchString,
    662      searchSource: this.input.getSearchSource(event),
    663    });
    664 
    665    return true;
    666  }
    667 
    668  /**
    669   * Removes a result from the current query context and notifies listeners.
    670   * Heuristic results cannot be removed.
    671   *
    672   * @param {UrlbarResult} result
    673   *   The result to remove.
    674   */
    675  removeResult(result) {
    676    if (!result || result.heuristic) {
    677      return;
    678    }
    679 
    680    if (!this._lastQueryContextWrapper) {
    681      console.error("Cannot remove result, last query not present");
    682      return;
    683    }
    684    let { queryContext } = this._lastQueryContextWrapper;
    685 
    686    let index = queryContext.results.indexOf(result);
    687    if (index < 0) {
    688      console.error("Failed to find the selected result in the results");
    689      return;
    690    }
    691 
    692    queryContext.results.splice(index, 1);
    693    this.notify(NOTIFICATIONS.QUERY_RESULT_REMOVED, index);
    694  }
    695 
    696  /**
    697   * Set the query context cache.
    698   *
    699   * @param {UrlbarQueryContext} queryContext the object to cache.
    700   */
    701  setLastQueryContextCache(queryContext) {
    702    this._lastQueryContextWrapper = { queryContext };
    703  }
    704 
    705  /**
    706   * Clear the previous query context cache.
    707   */
    708  clearLastQueryContextCache() {
    709    this._lastQueryContextWrapper = null;
    710  }
    711 
    712  /**
    713   * Notifies listeners of results.
    714   *
    715   * @param {string} name Name of the notification.
    716   * @param {object} params Parameters to pass with the notification.
    717   */
    718  notify(name, ...params) {
    719    for (let listener of this._listeners) {
    720      // Can't use "in" because some tests proxify these.
    721      if (typeof listener[name] != "undefined") {
    722        try {
    723          listener[name](...params);
    724        } catch (ex) {
    725          console.error(ex);
    726        }
    727      }
    728    }
    729  }
    730 
    731  focusOnUnifiedSearchButton() {
    732    this.input.setUnifiedSearchButtonAvailability(true);
    733 
    734    /** @type {HTMLElement} */
    735    const switcher = this.input.querySelector(".searchmode-switcher");
    736    // Set tabindex to be focusable.
    737    switcher.setAttribute("tabindex", "-1");
    738    // Remove blur listener to avoid closing urlbar view panel.
    739    this.input.inputField.removeEventListener("blur", this.input);
    740    // Move the focus.
    741    switcher.focus();
    742    // Restore all.
    743    this.input.inputField.addEventListener("blur", this.input);
    744    switcher.addEventListener(
    745      "blur",
    746      /** @type {(e: FocusEvent) => void} */
    747      e => {
    748        switcher.removeAttribute("tabindex");
    749 
    750        let relatedTarget = /** @type {HTMLElement} */ (e.relatedTarget);
    751        if (
    752          this.input.hasAttribute("focused") &&
    753          !this.input.contains(relatedTarget)
    754        ) {
    755          // If the focus is not back to urlbar, fire blur event explicitly to
    756          // clear the urlbar. Because the input field has been losing an
    757          // opportunity to lose the focus since we removed blur listener once.
    758          this.input.inputField.dispatchEvent(
    759            new FocusEvent("blur", {
    760              relatedTarget: e.relatedTarget,
    761            })
    762          );
    763        }
    764      },
    765      { once: true }
    766    );
    767  }
    768 }
    769 
    770 /**
    771 * Tracks and records telemetry events for the given category, if provided,
    772 * otherwise it's a no-op.
    773 * It is currently designed around the "urlbar" category, even if it can
    774 * potentially be extended to other categories.
    775 * To record an event, invoke start() with a starting event, then either
    776 * invoke record() with a final event, or discard() to drop the recording.
    777 *
    778 * @see Events.yaml
    779 */
    780 class TelemetryEvent {
    781  constructor(controller) {
    782    this._controller = controller;
    783    lazy.UrlbarPrefs.addObserver(this);
    784    this.#readPingPrefs();
    785    this._lastSearchDetailsForDisableSuggestTracking = null;
    786  }
    787 
    788  /**
    789   * Start measuring the elapsed time from a user-generated event.
    790   * After this has been invoked, any subsequent calls to start() are ignored,
    791   * until either record() or discard() are invoked. Thus, it is safe to keep
    792   * invoking this on every input event as the user is typing, for example.
    793   *
    794   * @param {event} event A DOM event.
    795   * @param {UrlbarQueryContext} queryContext A queryContext.
    796   * @param {string} [searchString] Pass a search string related to the event if
    797   *        you have one.  The event by itself sometimes isn't enough to
    798   *        determine the telemetry details we should record.
    799   * @throws This should never throw, or it may break the urlbar.
    800   * @see {@link https://firefox-source-docs.mozilla.org/browser/urlbar/telemetry.html}
    801   */
    802  start(event, queryContext, searchString = null) {
    803    if (this._startEventInfo) {
    804      if (this._startEventInfo.interactionType == "topsites") {
    805        // If the most recent event came from opening the results pane with an
    806        // empty string replace the interactionType (that would be "topsites")
    807        // with one for the current event to better measure the user flow.
    808        this._startEventInfo.interactionType = this._getStartInteractionType(
    809          event,
    810          searchString
    811        );
    812        this._startEventInfo.searchString = searchString;
    813      } else if (
    814        this._startEventInfo.interactionType == "returned" &&
    815        (!searchString ||
    816          this._startEventInfo.searchString[0] != searchString[0])
    817      ) {
    818        // In case of a "returned" interaction ongoing, the user may either
    819        // continue the search, or restart with a new search string. In that case
    820        // we want to change the interaction type to "restarted".
    821        // Detecting all the possible ways of clearing the input would be tricky,
    822        // thus this makes a guess by just checking the first char matches; even if
    823        // the user backspaces a part of the string, we still count that as a
    824        // "returned" interaction.
    825        this._startEventInfo.interactionType = "restarted";
    826      }
    827 
    828      // start is invoked on a user-generated event, but we only count the first
    829      // one.  Once an engagement or abandonment happens, we clear _startEventInfo.
    830      return;
    831    }
    832 
    833    if (!event) {
    834      console.error("Must always provide an event");
    835      return;
    836    }
    837    const validEvents = [
    838      "click",
    839      "command",
    840      "drop",
    841      "input",
    842      "keydown",
    843      "mousedown",
    844      "paste",
    845      "tabswitch",
    846      "focus",
    847    ];
    848    if (!validEvents.includes(event.type)) {
    849      console.error("Can't start recording from event type: ", event.type);
    850      return;
    851    }
    852 
    853    this._startEventInfo = {
    854      timeStamp: event.timeStamp || ChromeUtils.now(),
    855      interactionType: this._getStartInteractionType(event, searchString),
    856      searchString,
    857    };
    858  }
    859 
    860  /**
    861   * @typedef {object} ActionDetails
    862   *   An object describing action details that are recorded in an event.
    863   * @property {HTMLElement} [element]
    864   *   The picked view element.
    865   * @property {UrlbarResult} [result]
    866   *   The engaged result. This should be set to the result related to the
    867   *   picked element.
    868   * @property {boolean} [isSessionOngoing]
    869   *   Set to true if the search session is still ongoing.
    870   * @property {object} [searchMode]
    871   *   The searchMode object to record.
    872   * @property {string} searchSource
    873   *   The source of the search event.
    874   * @property {string} [searchString]
    875   *   The user's search string. Note that this string is not sent with telemetry
    876   *   data. It is only used locally to discern other data, such as the number
    877   *   of characters and words in the string.
    878   * @property {string} [selType]
    879   *   The Type of the selected element, undefined for "blur".
    880   *   One of "unknown", "autofill", "visiturl", "bookmark", "help", "history",
    881   *   "keyword", "searchengine", "searchsuggestion", "switchtab", "remotetab",
    882   *   "extension", "oneoff", "dismiss".
    883   */
    884 
    885  /**
    886   * @typedef {object} AdditionalActionDetails
    887   * @property {string} provider
    888   *   The name of the `UrlbarProvider` that provided the selected result.
    889   * @property {number} selIndex
    890   *   The index of the selected result.
    891   */
    892 
    893  /**
    894   * @typedef {ActionDetails & AdditionalActionDetails} InternalActionDetails
    895   */
    896 
    897  /**
    898   * Record an engagement telemetry event.
    899   * When the user picks a result from a search through the mouse or keyboard,
    900   * an engagement event is recorded. If instead the user abandons a search, by
    901   * blurring the input field, an abandonment event is recorded.
    902   *
    903   * On return, `details.isSessionOngoing` will be set to true if the engagement
    904   * did not end the search session. Not all engagements end the session. The
    905   * session remains ongoing when certain commands are picked (like dismissal)
    906   * and results that enter search mode are picked.
    907   *
    908   * @param {?event} event
    909   *   A DOM event. Note: event can be null, that usually happens for paste&go
    910   *   or drop&go. If there's no _startEventInfo this is a no-op.
    911   * @param {ActionDetails} details
    912   */
    913  record(event, details) {
    914    // Prevent re-entering `record()`. This can happen because
    915    // `#internalRecord()` will notify an engagement to the provider, that may
    916    // execute an action blurring the input field. Then both an engagement
    917    // and an abandonment would be recorded for the same session.
    918    // Nulling out `_startEventInfo` doesn't save us in this case, because it
    919    // happens after `#internalRecord()`, and `isSessionOngoing` must be
    920    // calculated inside it.
    921    if (this.#handlingRecord) {
    922      return;
    923    }
    924 
    925    // This should never throw, or it may break the urlbar.
    926    try {
    927      this.#handlingRecord = true;
    928      this.#internalRecord(event, details);
    929    } catch (ex) {
    930      console.error("Could not record event: ", ex);
    931    } finally {
    932      this.#handlingRecord = false;
    933 
    934      // Reset the start event info except for engagements that do not end the
    935      // search session. In that case, the view stays open and further
    936      // engagements are possible and should be recorded when they occur.
    937      // (`details.isSessionOngoing` is not a param; rather, it's set by
    938      // `#internalRecord()`.)
    939      if (!details.isSessionOngoing) {
    940        this._startEventInfo = null;
    941        this._discarded = false;
    942      }
    943    }
    944  }
    945 
    946  /**
    947   * Internal record method, see the record function.
    948   *
    949   * @param {Event} event
    950   * @param {ActionDetails} details
    951   */
    952  #internalRecord(event, details) {
    953    const startEventInfo = this._startEventInfo;
    954 
    955    if (!startEventInfo) {
    956      return;
    957    }
    958    if (
    959      !event &&
    960      startEventInfo.interactionType != "pasted" &&
    961      startEventInfo.interactionType != "dropped"
    962    ) {
    963      // If no event is passed, we must be executing either paste&go or drop&go.
    964      throw new Error("Event must be defined, unless input was pasted/dropped");
    965    }
    966    if (!details) {
    967      throw new Error("Invalid event details: " + details);
    968    }
    969 
    970    let action = this.#getActionFromEvent(
    971      event,
    972      details,
    973      startEventInfo.interactionType
    974    );
    975 
    976    /** @type {"abandonment" | "engagement"} */
    977    let method =
    978      action == "blur" || action == "tab_switch" ? "abandonment" : "engagement";
    979 
    980    if (method == "engagement") {
    981      // Not all engagements end the search session. The session remains ongoing
    982      // when certain commands are picked (like dismissal) and results that
    983      // enter search mode are picked. We should find a generalized way to
    984      // determine this instead of listing all the cases like this.
    985      details.isSessionOngoing = !!(
    986        [
    987          "dismiss",
    988          "inaccurate_location",
    989          "not_interested",
    990          "not_now",
    991          "opt_in",
    992          "show_less_frequently",
    993        ].includes(details.selType) ||
    994        details.result?.payload.providesSearchMode
    995      );
    996    }
    997 
    998    // numWords is not a perfect measurement, since it will return an incorrect
    999    // value for languages that do not use spaces or URLs containing spaces in
   1000    // its query parameters, for example.
   1001    let { numChars, numWords, searchWords } = this._parseSearchString(
   1002      details.searchString
   1003    );
   1004 
   1005    let internalDetails = {
   1006      ...details,
   1007      provider: details.result?.providerName,
   1008      selIndex: details.result?.rowIndex ?? -1,
   1009    };
   1010 
   1011    let { queryContext } = this._controller._lastQueryContextWrapper || {};
   1012 
   1013    this.#recordSearchEngagementTelemetry(method, startEventInfo, {
   1014      action,
   1015      numChars,
   1016      numWords,
   1017      searchWords,
   1018      provider: internalDetails.provider,
   1019      searchSource: internalDetails.searchSource,
   1020      searchMode: internalDetails.searchMode,
   1021      selIndex: internalDetails.selIndex,
   1022      selType: internalDetails.selType,
   1023    });
   1024 
   1025    if (!internalDetails.isSessionOngoing) {
   1026      this.#recordExposures(queryContext);
   1027    }
   1028 
   1029    const visibleResults = this._controller.view?.visibleResults ?? [];
   1030 
   1031    // Start tracking for a disable event if there was a Suggest result
   1032    // during an engagement or abandonment event.
   1033    if (
   1034      (method == "engagement" || method == "abandonment") &&
   1035      visibleResults.some(r => r.providerName == "UrlbarProviderQuickSuggest")
   1036    ) {
   1037      this.startTrackingDisableSuggest(event, internalDetails);
   1038    }
   1039 
   1040    try {
   1041      this._controller.manager.notifyEngagementChange(
   1042        method,
   1043        queryContext,
   1044        internalDetails,
   1045        this._controller
   1046      );
   1047    } catch (error) {
   1048      // We handle and report any error here to avoid hitting the record()
   1049      // handler, that would look like we didn't send telemetry at all.
   1050      console.error(error);
   1051    }
   1052  }
   1053 
   1054  /**
   1055   * Records the relevant telemetry information for the given parameters.
   1056   *
   1057   * @param {"abandonment" | "engagement" | "disable" | "bounce"} method
   1058   * @param {{interactionType: any, searchString: string }} startEventInfo
   1059   * @param {object} details
   1060   * @param {string} details.action
   1061   *   The type of action that caused this event. This may be recorded in the
   1062   *   engagement_type field of the event.
   1063   * @param {number} details.numWords
   1064   *   The length of words used for the search.
   1065   * @param {number} details.numChars
   1066   *   The length of string used for the search. It includes whitespaces.
   1067   * @param {string} details.provider
   1068   *   The name of the `UrlbarProvider` that provided the selected result.
   1069   * @param {string[]} details.searchWords
   1070   *   The search words entered, used to determine if the search has been refined.
   1071   * @param {string} details.searchSource
   1072   *   The source of the search event.
   1073   * @param {object} details.searchMode
   1074   *   The searchMode object to record.
   1075   * @param {number} details.selIndex
   1076   *   The index of the selected result.
   1077   * @param {string} details.selType
   1078   *   The Type of the selected element, undefined for "blur".
   1079   *   One of "unknown", "autofill", "visiturl", "bookmark", "help", "history",
   1080   *   "keyword", "searchengine", "searchsuggestion", "switchtab", "remotetab",
   1081   *   "extension", "oneoff", "dismiss".
   1082   * @param {number} [details.viewTime]
   1083   *   The length of the view time in milliseconds.
   1084   */
   1085  #recordSearchEngagementTelemetry(
   1086    method,
   1087    startEventInfo,
   1088    {
   1089      action,
   1090      numWords,
   1091      numChars,
   1092      provider,
   1093      searchWords,
   1094      searchSource,
   1095      searchMode,
   1096      selIndex,
   1097      selType,
   1098      viewTime = 0,
   1099    }
   1100  ) {
   1101    const browserWindow = this._controller.browserWindow;
   1102    let sap = "urlbar";
   1103    if (searchSource === "urlbar-handoff") {
   1104      sap = "handoff";
   1105    } else if (searchSource === "searchbar") {
   1106      sap = "searchbar";
   1107    } else if (
   1108      browserWindow.isBlankPageURL(browserWindow.gBrowser.currentURI.spec)
   1109    ) {
   1110      sap = "urlbar_newtab";
   1111    } else if (
   1112      lazy.ExtensionUtils.isExtensionUrl(browserWindow.gBrowser.currentURI)
   1113    ) {
   1114      sap = "urlbar_addonpage";
   1115    }
   1116 
   1117    searchMode = searchMode ?? this._controller.input.searchMode;
   1118 
   1119    // Distinguish user typed search strings from persisted search terms.
   1120    const interaction = this.#getInteractionType(
   1121      method,
   1122      startEventInfo,
   1123      searchSource,
   1124      searchWords,
   1125      searchMode
   1126    );
   1127    const search_mode = this.#getSearchMode(searchMode);
   1128    const currentResults = this._controller.view?.visibleResults ?? [];
   1129    let numResults = currentResults.length;
   1130    let groups = currentResults
   1131      .map(r => lazy.UrlbarUtils.searchEngagementTelemetryGroup(r))
   1132      .join(",");
   1133    let results = currentResults
   1134      .map(r => lazy.UrlbarUtils.searchEngagementTelemetryType(r))
   1135      .join(",");
   1136    let actions = currentResults
   1137      .map((r, i) => lazy.UrlbarUtils.searchEngagementTelemetryAction(r, i))
   1138      .filter(v => v)
   1139      .join(",");
   1140    let available_semantic_sources = this.#getAvailableSemanticSources().join();
   1141    const search_engine_default_id = Services.search.defaultEngine.telemetryId;
   1142 
   1143    switch (method) {
   1144      case "engagement": {
   1145        let selected_result = lazy.UrlbarUtils.searchEngagementTelemetryType(
   1146          currentResults[selIndex],
   1147          selType
   1148        );
   1149 
   1150        if (selType == "action") {
   1151          let actionKey = lazy.UrlbarUtils.searchEngagementTelemetryAction(
   1152            currentResults[selIndex],
   1153            selIndex
   1154          );
   1155          selected_result = `action_${actionKey}`;
   1156        }
   1157 
   1158        if (
   1159          selected_result === "input_field" &&
   1160          !this._controller.view?.isOpen
   1161        ) {
   1162          numResults = 0;
   1163          groups = "";
   1164          results = "";
   1165        }
   1166 
   1167        let eventInfo = {
   1168          sap,
   1169          interaction,
   1170          search_mode,
   1171          n_chars: numChars.toString(),
   1172          n_words: numWords.toString(),
   1173          n_results: numResults,
   1174          selected_position: (selIndex + 1).toString(),
   1175          selected_result,
   1176          provider,
   1177          engagement_type:
   1178            selType === "help" || selType === "dismiss" ? selType : action,
   1179          search_engine_default_id,
   1180          groups,
   1181          results,
   1182          actions,
   1183          available_semantic_sources,
   1184        };
   1185        lazy.logger.info(`engagement event:`, eventInfo);
   1186        Glean.urlbar.engagement.record(eventInfo);
   1187        break;
   1188      }
   1189      case "abandonment": {
   1190        let eventInfo = {
   1191          abandonment_type: action,
   1192          sap,
   1193          interaction,
   1194          search_mode,
   1195          n_chars: numChars.toString(),
   1196          n_words: numWords.toString(),
   1197          n_results: numResults,
   1198          search_engine_default_id,
   1199          groups,
   1200          results,
   1201          actions,
   1202          available_semantic_sources,
   1203        };
   1204        lazy.logger.info(`abandonment event:`, eventInfo);
   1205        Glean.urlbar.abandonment.record(eventInfo);
   1206        break;
   1207      }
   1208      case "disable": {
   1209        const previousEvent =
   1210          action == "blur" || action == "tab_switch"
   1211            ? "abandonment"
   1212            : "engagement";
   1213        let selected_result = "none";
   1214        if (previousEvent == "engagement") {
   1215          selected_result = lazy.UrlbarUtils.searchEngagementTelemetryType(
   1216            currentResults[selIndex],
   1217            selType
   1218          );
   1219        }
   1220        let eventInfo = {
   1221          sap,
   1222          interaction,
   1223          search_mode,
   1224          search_engine_default_id,
   1225          n_chars: numChars.toString(),
   1226          n_words: numWords.toString(),
   1227          n_results: numResults,
   1228          selected_result,
   1229          results,
   1230          feature: "suggest",
   1231        };
   1232        lazy.logger.info(`disable event:`, eventInfo);
   1233        Glean.urlbar.disable.record(eventInfo);
   1234        break;
   1235      }
   1236      case "bounce": {
   1237        let selected_result = lazy.UrlbarUtils.searchEngagementTelemetryType(
   1238          currentResults[selIndex],
   1239          selType
   1240        );
   1241        let eventInfo = {
   1242          sap,
   1243          interaction,
   1244          search_mode,
   1245          search_engine_default_id,
   1246          n_chars: numChars.toString(),
   1247          n_words: numWords.toString(),
   1248          n_results: numResults,
   1249          selected_result,
   1250          selected_position: (selIndex + 1).toString(),
   1251          provider,
   1252          engagement_type:
   1253            selType === "help" || selType === "dismiss" ? selType : action,
   1254          results,
   1255          view_time: viewTime.toString(),
   1256          threshold: lazy.UrlbarPrefs.get(
   1257            "events.bounce.maxSecondsFromLastSearch"
   1258          ),
   1259        };
   1260        lazy.logger.info(`bounce event:`, eventInfo);
   1261        Glean.urlbar.bounce.record(eventInfo);
   1262        break;
   1263      }
   1264      default: {
   1265        console.error(`Unknown telemetry event method: ${method}`);
   1266      }
   1267    }
   1268  }
   1269 
   1270  /**
   1271   * Retrieves available semantic search sources.
   1272   * Ensure it is the provider initializing the semantic manager, since it
   1273   * provides the right configuration for the singleton.
   1274   *
   1275   * @returns {Array<string>} Array of found sources, will contain just "none"
   1276   *   if no sources were found.
   1277   */
   1278  #getAvailableSemanticSources() {
   1279    let sources = [];
   1280    if (!sources.length) {
   1281      sources.push("none");
   1282    }
   1283    return sources;
   1284  }
   1285 
   1286  #recordExposures(queryContext) {
   1287    let exposures = this.#exposures;
   1288    this.#exposures = [];
   1289    this.#tentativeExposures = [];
   1290    if (!exposures.length) {
   1291      return;
   1292    }
   1293 
   1294    let terminalByType = new Map();
   1295    let keywordExposureRecorded = false;
   1296    for (let { weakResult, resultType, keyword } of exposures) {
   1297      let terminal = false;
   1298      let result = weakResult.get();
   1299      if (result) {
   1300        this.#exposureResults.delete(result);
   1301 
   1302        let endResults = result.isHiddenExposure
   1303          ? queryContext.results
   1304          : this._controller.view?.visibleResults;
   1305        terminal = endResults?.includes(result);
   1306      }
   1307 
   1308      terminalByType.set(resultType, terminal);
   1309 
   1310      // Record the `keyword_exposure` event if there's a keyword.
   1311      if (keyword) {
   1312        let data = {
   1313          keyword,
   1314          terminal: terminal.toString(),
   1315          result: resultType,
   1316        };
   1317        lazy.logger.debug("Recording keyword_exposure event", data);
   1318        Glean.urlbar.keywordExposure.record(data);
   1319        keywordExposureRecorded = true;
   1320      }
   1321    }
   1322 
   1323    // Record the `exposure` event.
   1324    let tuples = [...terminalByType].sort((a, b) => a[0].localeCompare(b[0]));
   1325    let exposure = {
   1326      results: tuples.map(t => t[0]).join(","),
   1327      terminal: tuples.map(t => t[1]).join(","),
   1328    };
   1329    lazy.logger.debug("Recording exposure event", exposure);
   1330    Glean.urlbar.exposure.record(exposure);
   1331 
   1332    // Submit the `urlbar-keyword-exposure` ping if any keyword exposure events
   1333    // were recorded above.
   1334    if (keywordExposureRecorded) {
   1335      GleanPings.urlbarKeywordExposure.submit();
   1336    }
   1337  }
   1338 
   1339  /**
   1340   * Registers an exposure for a result in the current urlbar session, if the
   1341   * result should record exposure telemetry. All exposures that are added
   1342   * during a session are recorded in the `exposure` event at the end of the
   1343   * session. If keyword exposures are enabled, they will be recorded in the
   1344   * `urlbar-keyword-exposure` ping at the end of the session as well. Exposures
   1345   * are cleared at the end of each session and do not carry over.
   1346   *
   1347   * @param {UrlbarResult} result An exposure will be added for this result if
   1348   *        exposures are enabled for its result type.
   1349   * @param {UrlbarQueryContext} queryContext The query context associated with
   1350   *        the result.
   1351   */
   1352  addExposure(result, queryContext) {
   1353    if (result.exposureTelemetry) {
   1354      this.#addExposureInternal(result, queryContext);
   1355    }
   1356  }
   1357 
   1358  /**
   1359   * Registers a tentative exposure for a result in the current urlbar session.
   1360   * Exposures that remain tentative at the end of the session are discarded and
   1361   * are not recorded in the exposure event.
   1362   *
   1363   * @param {UrlbarResult} result A tentative exposure will be added for this
   1364   *        result if exposures are enabled for its result type.
   1365   * @param {UrlbarQueryContext} queryContext The query context associated with
   1366   *        the result.
   1367   */
   1368  addTentativeExposure(result, queryContext) {
   1369    if (result.exposureTelemetry) {
   1370      this.#tentativeExposures.push({
   1371        weakResult: Cu.getWeakReference(result),
   1372        weakQueryContext: Cu.getWeakReference(queryContext),
   1373      });
   1374    }
   1375  }
   1376 
   1377  /**
   1378   * Converts all tentative exposures that were added and not yet discarded
   1379   * during the current urlbar session into actual exposures that will be
   1380   * recorded at the end of the session.
   1381   */
   1382  acceptTentativeExposures() {
   1383    if (this.#tentativeExposures.length) {
   1384      for (let { weakResult, weakQueryContext } of this.#tentativeExposures) {
   1385        let result = weakResult.get();
   1386        let queryContext = weakQueryContext.get();
   1387        if (result && queryContext) {
   1388          this.#addExposureInternal(result, queryContext);
   1389        }
   1390      }
   1391      this.#tentativeExposures = [];
   1392    }
   1393  }
   1394 
   1395  /**
   1396   * Discards all tentative exposures that were added and not yet accepted
   1397   * during the current urlbar session.
   1398   */
   1399  discardTentativeExposures() {
   1400    if (this.#tentativeExposures.length) {
   1401      this.#tentativeExposures = [];
   1402    }
   1403  }
   1404 
   1405  #addExposureInternal(result, queryContext) {
   1406    // If we haven't added an exposure for this result, add it now. The view can
   1407    // add exposures for the same results again and again due to the nature of
   1408    // its update process, but we should record at most one exposure per result.
   1409    if (!this.#exposureResults.has(result)) {
   1410      this.#exposureResults.add(result);
   1411      let resultType = lazy.UrlbarUtils.searchEngagementTelemetryType(result);
   1412      this.#exposures.push({
   1413        resultType,
   1414        weakResult: Cu.getWeakReference(result),
   1415        keyword:
   1416          !queryContext.isPrivate &&
   1417          lazy.UrlbarPrefs.get("keywordExposureResults").has(resultType)
   1418            ? queryContext.trimmedLowerCaseSearchString
   1419            : null,
   1420      });
   1421    }
   1422  }
   1423 
   1424  #getInteractionType(
   1425    method,
   1426    startEventInfo,
   1427    searchSource,
   1428    searchWords,
   1429    searchMode
   1430  ) {
   1431    if (searchMode?.entry === "topsites_newtab") {
   1432      return "topsite_search";
   1433    }
   1434 
   1435    let interaction = startEventInfo.interactionType;
   1436    if (
   1437      (interaction === "returned" || interaction === "restarted") &&
   1438      this._isRefined(new Set(searchWords), this.#previousSearchWordsSet)
   1439    ) {
   1440      interaction = "refined";
   1441    }
   1442 
   1443    if (searchSource === "urlbar-persisted") {
   1444      switch (interaction) {
   1445        case "returned": {
   1446          interaction = "persisted_search_terms";
   1447          break;
   1448        }
   1449        case "restarted":
   1450        case "refined": {
   1451          interaction = `persisted_search_terms_${interaction}`;
   1452          break;
   1453        }
   1454      }
   1455    }
   1456 
   1457    if (
   1458      (method === "engagement" &&
   1459        lazy.UrlbarPrefs.isPersistedSearchTermsEnabled()) ||
   1460      method === "abandonment"
   1461    ) {
   1462      this.#previousSearchWordsSet = new Set(searchWords);
   1463    } else if (method === "engagement") {
   1464      this.#previousSearchWordsSet = null;
   1465    }
   1466 
   1467    return interaction;
   1468  }
   1469 
   1470  #getSearchMode(searchMode) {
   1471    if (!searchMode) {
   1472      return "";
   1473    }
   1474 
   1475    if (searchMode.engineName) {
   1476      return "search_engine";
   1477    }
   1478 
   1479    const source = lazy.UrlbarUtils.LOCAL_SEARCH_MODES.find(
   1480      m => m.source == searchMode.source
   1481    )?.telemetryLabel;
   1482    return source ?? "unknown";
   1483  }
   1484 
   1485  #getActionFromEvent(event, details, defaultInteractionType) {
   1486    if (!event) {
   1487      return defaultInteractionType === "dropped" ? "drop_go" : "paste_go";
   1488    }
   1489    if (event.type === "blur") {
   1490      return "blur";
   1491    }
   1492    if (event.type === "tabswitch") {
   1493      return "tab_switch";
   1494    }
   1495    if (
   1496      details.element?.dataset.command &&
   1497      details.element.dataset.command !== "help"
   1498    ) {
   1499      return details.element.dataset.command;
   1500    }
   1501    if (details.selType === "dismiss") {
   1502      return "dismiss";
   1503    }
   1504    if (MouseEvent.isInstance(event)) {
   1505      return /** @type {HTMLElement} */ (event.target).classList.contains(
   1506        "urlbar-go-button"
   1507      )
   1508        ? "go_button"
   1509        : "click";
   1510    }
   1511    return "enter";
   1512  }
   1513 
   1514  _parseSearchString(searchString) {
   1515    let numChars = searchString.length.toString();
   1516    let searchWords = searchString
   1517      .substring(0, lazy.UrlbarUtils.MAX_TEXT_LENGTH)
   1518      .trim()
   1519      .split(lazy.UrlUtils.REGEXP_SPACES)
   1520      .filter(t => t);
   1521    let numWords = searchWords.length.toString();
   1522 
   1523    return {
   1524      numChars,
   1525      numWords,
   1526      searchWords,
   1527    };
   1528  }
   1529 
   1530  /**
   1531   * Checks whether re-searched by modifying some of the keywords from the
   1532   * previous search. Concretely, returns true if there is intersects between
   1533   * both keywords, otherwise returns false. Also, returns false even if both
   1534   * are the same.
   1535   *
   1536   * @param {Set} currentSet The current keywords.
   1537   * @param {Set} [previousSet] The previous keywords.
   1538   * @returns {boolean} true if current searching are refined.
   1539   */
   1540  _isRefined(currentSet, previousSet = null) {
   1541    if (!previousSet) {
   1542      return false;
   1543    }
   1544 
   1545    const intersect = (setA, setB) => {
   1546      let count = 0;
   1547      for (const word of setA.values()) {
   1548        if (setB.has(word)) {
   1549          count += 1;
   1550        }
   1551      }
   1552      return count > 0 && count != setA.size;
   1553    };
   1554 
   1555    return (
   1556      intersect(currentSet, previousSet) || intersect(previousSet, currentSet)
   1557    );
   1558  }
   1559 
   1560  _getStartInteractionType(event, searchString) {
   1561    if (event.interactionType) {
   1562      return event.interactionType;
   1563    } else if (event.type == "input") {
   1564      return lazy.UrlbarUtils.isPasteEvent(event) ? "pasted" : "typed";
   1565    } else if (event.type == "drop") {
   1566      return "dropped";
   1567    } else if (event.type == "paste") {
   1568      return "pasted";
   1569    } else if (searchString) {
   1570      return "typed";
   1571    }
   1572    return "topsites";
   1573  }
   1574 
   1575  /**
   1576   * Resets the currently tracked user-generated event that was registered via
   1577   * start(), so it won't be recorded.  If there's no tracked event, this is a
   1578   * no-op.
   1579   */
   1580  discard() {
   1581    if (this._startEventInfo) {
   1582      this._startEventInfo = null;
   1583      this._discarded = true;
   1584    }
   1585  }
   1586 
   1587  /**
   1588   * Extracts a telemetry type from a result and the element being interacted
   1589   * with for event telemetry.
   1590   *
   1591   * @param {object} result The element to analyze.
   1592   * @param {HTMLElement} element The element to analyze.
   1593   * @returns {string} a string type for the telemetry event.
   1594   */
   1595  typeFromElement(result, element) {
   1596    if (!element) {
   1597      return "none";
   1598    }
   1599    if (
   1600      element.dataset.command == "help" ||
   1601      element.dataset.l10nName == "learn-more-link"
   1602    ) {
   1603      return "help";
   1604    }
   1605    if (element.dataset.command == "dismiss") {
   1606      return "block";
   1607    }
   1608    // Now handle the result.
   1609    return lazy.UrlbarUtils.telemetryTypeFromResult(result);
   1610  }
   1611 
   1612  /**
   1613   * Reset the internal state. This function is used for only when testing.
   1614   */
   1615  reset() {
   1616    this.#previousSearchWordsSet = null;
   1617  }
   1618 
   1619  #PING_PREFS = {
   1620    maxRichResults: Glean.urlbar.prefMaxResults,
   1621    "quicksuggest.online.available": Glean.urlbar.prefSuggestOnlineAvailable,
   1622    "quicksuggest.online.enabled": Glean.urlbar.prefSuggestOnlineEnabled,
   1623    "suggest.quicksuggest.all": Glean.urlbar.prefSuggestAll,
   1624    "suggest.quicksuggest.sponsored": Glean.urlbar.prefSuggestSponsored,
   1625    "suggest.topsites": Glean.urlbar.prefSuggestTopsites,
   1626  };
   1627 
   1628  // Used to record telemetry for prefs that are fallbacks for Nimbus variables.
   1629  // `onNimbusChanged` is called for these variables rather than `onPrefChanged`
   1630  // but we want to record telemetry as if the prefs themselves changed. This
   1631  // object maps Nimbus variable names to their fallback prefs.
   1632  #PING_NIMBUS_VARIABLES = {
   1633    quickSuggestOnlineAvailable: "quicksuggest.online.available",
   1634  };
   1635 
   1636  #readPingPrefs() {
   1637    for (const p of Object.keys(this.#PING_PREFS)) {
   1638      this.#recordPref(p);
   1639    }
   1640  }
   1641 
   1642  #recordPref(pref, newValue = undefined) {
   1643    const metric = this.#PING_PREFS[pref];
   1644    const prefValue = newValue ?? lazy.UrlbarPrefs.get(pref);
   1645    if (metric) {
   1646      metric.set(prefValue);
   1647    }
   1648    switch (pref) {
   1649      case "suggest.quicksuggest.all":
   1650      case "suggest.quicksuggest.sponsored":
   1651      case "quicksuggest.enabled":
   1652        if (!prefValue) {
   1653          this.handleDisableSuggest();
   1654        }
   1655    }
   1656  }
   1657 
   1658  onPrefChanged(pref) {
   1659    this.#recordPref(pref);
   1660  }
   1661 
   1662  onNimbusChanged(name, newValue) {
   1663    if (this.#PING_NIMBUS_VARIABLES.hasOwnProperty(name)) {
   1664      this.#recordPref(this.#PING_NIMBUS_VARIABLES[name], newValue);
   1665    }
   1666  }
   1667 
   1668  // Used to avoid re-entering `record()`.
   1669  #handlingRecord = false;
   1670 
   1671  #previousSearchWordsSet = null;
   1672 
   1673  // These properties are used to record exposure telemetry. For general info on
   1674  // exposures, see [1]. For keyword exposures, see [2] and [3]. Here's a
   1675  // summary of how a result flows through the exposure telemetry code path:
   1676  //
   1677  // 1. The view makes the result's row visible and calls `addExposure()` for
   1678  //    it. (Or, if the result is a hidden exposure, the view would have made
   1679  //    its row visible.)
   1680  // 2. If exposure telemetry should be recorded for the result, we push its
   1681  //    telemetry type and some other data onto `#exposures`. If keyword
   1682  //    exposures are enabled, we also include the search string in the data. We
   1683  //    use `#exposureResults` to efficiently make sure we add at most one
   1684  //    exposure per result to `#exposures`.
   1685  // 3. At the end of a session, we record a single `exposure` event that
   1686  //    includes all unique telemetry types in the `#exposures` data. We also
   1687  //    record one `keyword_exposure` event per search string in the data, with
   1688  //    each search string recorded as the `keyword` for that exposure. We clear
   1689  //    `#exposures` so that the data does not carry over into the next session.
   1690  //
   1691  // `#tentativeExposures` supports hidden exposures and is necessary due to how
   1692  // the view updates itself. When the view creates a row for a normal result,
   1693  // the row can start out hidden, and it's only unhidden if the query finishes
   1694  // without being canceled. When the view encounters a hidden-exposure result,
   1695  // it doesn't actually create a row for it, but if the hypothetical row would
   1696  // have started out visible, the view will call `addExposure()`. If the
   1697  // hypothetical row would have started out hidden, the view will call
   1698  // `addTentativeExposure()` and we'll add the result to `#tentativeExposures`.
   1699  // Once the query finishes and the view unhides its rows, it will call
   1700  // `acceptTentativeExposures()`, finally registering exposures for all such
   1701  // hidden-exposure results in the query. If instead the query is canceled, the
   1702  // view will remove its hidden rows and call `discardTentativeExposures()`.
   1703  //
   1704  // [1] https://dictionary.telemetry.mozilla.org/apps/firefox_desktop/metrics/urlbar_exposure
   1705  // [2] https://dictionary.telemetry.mozilla.org/apps/firefox_desktop/pings/urlbar-keyword-exposure
   1706  // [3] https://dictionary.telemetry.mozilla.org/apps/firefox_desktop/metrics/urlbar_keyword_exposure
   1707  #exposures = [];
   1708  #tentativeExposures = [];
   1709  #exposureResults = new WeakSet();
   1710 
   1711  /**
   1712   * Start tracking a potential disable suggest event after user has seen a
   1713   * suggest result.
   1714   *
   1715   * @param {event} event
   1716   *   A DOM event.
   1717   * @param {InternalActionDetails} details
   1718   *   An object describing interaction details.
   1719   */
   1720  startTrackingDisableSuggest(event, details) {
   1721    this._lastSearchDetailsForDisableSuggestTracking = {
   1722      // The time when a user interacts a suggest result, either through
   1723      // an engagement or an abandonment.
   1724      interactionTime: this.getCurrentTime(),
   1725      event,
   1726      details,
   1727    };
   1728  }
   1729 
   1730  handleDisableSuggest() {
   1731    let state = this._lastSearchDetailsForDisableSuggestTracking;
   1732    if (
   1733      !state ||
   1734      this.getCurrentTime() - state.interactionTime >
   1735        lazy.UrlbarPrefs.get("events.disableSuggest.maxSecondsFromLastSearch") *
   1736          1000
   1737    ) {
   1738      this._lastSearchDetailsForDisableSuggestTracking = null;
   1739      return;
   1740    }
   1741 
   1742    let event = state.event;
   1743    let details = state.details;
   1744 
   1745    let startEventInfo = {
   1746      interactionType: this._getStartInteractionType(
   1747        event,
   1748        details.searchString
   1749      ),
   1750      searchString: details.searchString,
   1751    };
   1752 
   1753    if (
   1754      !event &&
   1755      startEventInfo.interactionType != "pasted" &&
   1756      startEventInfo.interactionType != "dropped"
   1757    ) {
   1758      // If no event is passed, we must be executing either paste&go or drop&go.
   1759      throw new Error("Event must be defined, unless input was pasted/dropped");
   1760    }
   1761    if (!details) {
   1762      throw new Error("Invalid event details: " + details);
   1763    }
   1764 
   1765    let action = this.#getActionFromEvent(
   1766      event,
   1767      details,
   1768      startEventInfo.interactionType
   1769    );
   1770 
   1771    let { numChars, numWords, searchWords } = this._parseSearchString(
   1772      details.searchString
   1773    );
   1774 
   1775    details.provider = details.result?.providerName;
   1776    details.selIndex = details.result?.rowIndex ?? -1;
   1777 
   1778    this.#recordSearchEngagementTelemetry("disable", startEventInfo, {
   1779      action,
   1780      numChars,
   1781      numWords,
   1782      searchWords,
   1783      provider: details.provider,
   1784      searchSource: details.searchSource,
   1785      searchMode: details.searchMode,
   1786      selIndex: details.selIndex,
   1787      selType: details.selType,
   1788    });
   1789 
   1790    this._lastSearchDetailsForDisableSuggestTracking = null;
   1791  }
   1792 
   1793  getCurrentTime() {
   1794    return ChromeUtils.now();
   1795  }
   1796 
   1797  /**
   1798   * Start tracking a potential bounce event after the user has engaged
   1799   * with a URL bar result.
   1800   *
   1801   * @param {object} browser
   1802   *   The browser object.
   1803   * @param {event} event
   1804   *   A DOM event.
   1805   * @param {ActionDetails} details
   1806   *   An object describing interaction details.
   1807   */
   1808  async startTrackingBounceEvent(browser, event, details) {
   1809    let state = this._controller.input.getBrowserState(browser);
   1810    let startEventInfo = this._startEventInfo;
   1811 
   1812    // If we are already tracking a bounce, then another engagement
   1813    // could possibly lead to a bounce.
   1814    if (state.bounceEventTracking) {
   1815      await this.handleBounceEventTrigger(browser);
   1816    }
   1817 
   1818    state.bounceEventTracking = {
   1819      startTime: Date.now(),
   1820      pickEvent: event,
   1821      resultDetails: details,
   1822      startEventInfo,
   1823    };
   1824  }
   1825 
   1826  /**
   1827   * Handle a bounce event trigger.
   1828   * These include closing the tab/window and navigating away via
   1829   * browser chrome (this includes clicking on history or bookmark entries,
   1830   * and engaging with the URL bar).
   1831   *
   1832   * @param {object} browser
   1833   *   The browser object.
   1834   */
   1835  async handleBounceEventTrigger(browser) {
   1836    let state = this._controller.input.getBrowserState(browser);
   1837    if (state.bounceEventTracking) {
   1838      const interactions =
   1839        (await lazy.Interactions.getRecentInteractionsForBrowser(browser)) ??
   1840        [];
   1841 
   1842      // handleBounceEventTrigger() can run concurrently, so we bail out
   1843      // if a prior async invocation has already cleared bounceEventTracking.
   1844      if (!state.bounceEventTracking) {
   1845        return;
   1846      }
   1847 
   1848      let totalViewTime = 0;
   1849      for (let interaction of interactions) {
   1850        if (interaction.created_at >= state.bounceEventTracking.startTime) {
   1851          totalViewTime += interaction.totalViewTime || 0;
   1852        }
   1853      }
   1854 
   1855      // If the total view time when the user navigates away after a
   1856      // URL bar interaction is less than the threshold of
   1857      // events.bounce.maxSecondsFromLastSearch, we record a bounce event.
   1858      // If totalViewTime is 0, that means the page didn't load yet, so
   1859      // we wouldn't record a bounce event.
   1860      if (
   1861        totalViewTime != 0 &&
   1862        totalViewTime <
   1863          lazy.UrlbarPrefs.get("events.bounce.maxSecondsFromLastSearch") * 1000
   1864      ) {
   1865        this.recordBounceEvent(browser, totalViewTime);
   1866      }
   1867 
   1868      state.bounceEventTracking = null;
   1869    }
   1870  }
   1871 
   1872  /**
   1873   * Record a bounce event
   1874   *
   1875   * @param {object} browser
   1876   *   The browser object.
   1877   * @param {number} viewTime
   1878   *  The time spent on a tab after a URL bar engagement before
   1879   *  navigating away via browser chrome or closing the tab.
   1880   */
   1881  recordBounceEvent(browser, viewTime) {
   1882    let state = this._controller.input.getBrowserState(browser);
   1883    let event = state.bounceEventTracking.pickEvent;
   1884    let details = state.bounceEventTracking.resultDetails;
   1885 
   1886    let startEventInfo = state.bounceEventTracking.startEventInfo;
   1887 
   1888    if (!startEventInfo) {
   1889      return;
   1890    }
   1891 
   1892    if (
   1893      !event &&
   1894      startEventInfo.interactionType != "pasted" &&
   1895      startEventInfo.interactionType != "dropped"
   1896    ) {
   1897      // If no event is passed, we must be executing either paste&go or drop&go.
   1898      throw new Error("Event must be defined, unless input was pasted/dropped");
   1899    }
   1900    if (!details) {
   1901      throw new Error("Invalid event details: " + details);
   1902    }
   1903 
   1904    let action = this.#getActionFromEvent(
   1905      event,
   1906      details,
   1907      startEventInfo.interactionType
   1908    );
   1909 
   1910    let { numChars, numWords, searchWords } = this._parseSearchString(
   1911      details.searchString
   1912    );
   1913 
   1914    details.provider = details.result?.providerName;
   1915    details.selIndex = details.result?.rowIndex ?? -1;
   1916 
   1917    this.#recordSearchEngagementTelemetry("bounce", startEventInfo, {
   1918      action,
   1919      numChars,
   1920      numWords,
   1921      searchWords,
   1922      provider: details.provider,
   1923      searchSource: details.searchSource,
   1924      searchMode: details.searchMode,
   1925      selIndex: details.selIndex,
   1926      selType: details.selType,
   1927      viewTime: viewTime / 1000,
   1928    });
   1929  }
   1930 }