tor-browser

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

UrlbarEventBufferer.sys.mjs (13160B)


      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 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
      7 
      8 const lazy = XPCOMUtils.declareLazy({
      9  UrlbarUtils: "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs",
     10  clearTimeout: "resource://gre/modules/Timer.sys.mjs",
     11  setTimeout: "resource://gre/modules/Timer.sys.mjs",
     12  logger: () => lazy.UrlbarUtils.getLogger({ prefix: "EventBufferer" }),
     13 });
     14 
     15 /**
     16 * Array of keyCodes to defer.
     17 *
     18 * @type {Set<number>}
     19 */
     20 const DEFERRED_KEY_CODES = new Set([
     21  KeyboardEvent.DOM_VK_RETURN,
     22  KeyboardEvent.DOM_VK_DOWN,
     23  KeyboardEvent.DOM_VK_TAB,
     24 ]);
     25 
     26 /**
     27 * Status of the current or last query.
     28 */
     29 const QUERY_STATUS = Object.freeze({
     30  UKNOWN: 0,
     31  RUNNING: 1,
     32  RUNNING_GOT_ALL_HEURISTIC_RESULTS: 2,
     33  COMPLETE: 3,
     34 });
     35 
     36 /**
     37 * The UrlbarEventBufferer can queue up events and replay them later, to make
     38 * the urlbar results more predictable.
     39 *
     40 * Search results arrive asynchronously, which means that keydown events may
     41 * arrive before results do, and therefore not have the effect the user intends.
     42 * That's especially likely to happen with the down arrow and enter keys, due to
     43 * the one-off search buttons: if the user very quickly pastes something in the
     44 * input, presses the down arrow key, and then hits enter, they are probably
     45 * expecting to visit the first result.  But if there are no results, then
     46 * pressing down and enter will trigger the first one-off button.
     47 * To prevent that undesirable behavior, certain keys are buffered and deferred
     48 * until more results arrive, at which time they're replayed.
     49 */
     50 export class UrlbarEventBufferer {
     51  // Maximum time events can be deferred for. In automation providers can be
     52  // quite slow, thus we need a longer timeout to avoid intermittent failures.
     53  // Note: to avoid handling events too early, this timer should be larger than
     54  // UrlbarProvidersManager.CHUNK_RESULTS_DELAY_MS.
     55  static DEFERRING_TIMEOUT_MS = Cu.isInAutomation ? 1500 : 300;
     56 
     57  /**
     58   * Initialises the class.
     59   *
     60   * @param {UrlbarInput} input
     61   *   The urlbar input object.
     62   */
     63  constructor(input) {
     64    this.input = input;
     65    this.input.inputField.addEventListener("blur", this);
     66 
     67    this.#lastQuery = {
     68      // The time at which the current or last search was started. This is used
     69      // to check how much time passed while deferring the user's actions. Must
     70      // be set using the monotonic ChromeUtils.now() helper.
     71      startDate: ChromeUtils.now(),
     72      // Status of the query; one of QUERY_STATUS.*
     73      status: QUERY_STATUS.UKNOWN,
     74      // The query context.
     75      context: null,
     76    };
     77 
     78    // Start listening for queries.
     79    this.input.controller.addListener(this);
     80  }
     81 
     82  // UrlbarController listener methods.
     83 
     84  /**
     85   * Handles when a query is started.
     86   *
     87   * @param {UrlbarQueryContext} queryContext
     88   */
     89  onQueryStarted(queryContext) {
     90    this.#lastQuery = {
     91      startDate: ChromeUtils.now(),
     92      status: QUERY_STATUS.RUNNING,
     93      context: queryContext,
     94    };
     95    if (this.#deferringTimeout) {
     96      lazy.clearTimeout(this.#deferringTimeout);
     97      this.#deferringTimeout = null;
     98    }
     99  }
    100 
    101  onQueryCancelled() {
    102    this.#lastQuery.status = QUERY_STATUS.COMPLETE;
    103  }
    104 
    105  onQueryFinished() {
    106    this.#lastQuery.status = QUERY_STATUS.COMPLETE;
    107  }
    108 
    109  /**
    110   * Handles results of the query.
    111   *
    112   * @param {UrlbarQueryContext} queryContext
    113   */
    114  onQueryResults(queryContext) {
    115    if (queryContext.pendingHeuristicProviders.size) {
    116      return;
    117    }
    118    this.#lastQuery.status = QUERY_STATUS.RUNNING_GOT_ALL_HEURISTIC_RESULTS;
    119    // Ensure this runs after other results handling code.
    120    Services.tm.dispatchToMainThread(() => {
    121      this.replayDeferredEvents(true);
    122    });
    123  }
    124 
    125  /**
    126   * Handles DOM events.
    127   *
    128   * @param {Event} event
    129   *   DOM event from the input.
    130   */
    131  handleEvent(event) {
    132    if (event.type == "blur") {
    133      lazy.logger.debug("Clearing queue on blur");
    134      // The input field was blurred, pending events don't matter anymore.
    135      // Clear the timeout and the queue.
    136      this.#eventsQueue.length = 0;
    137      if (this.#deferringTimeout) {
    138        lazy.clearTimeout(this.#deferringTimeout);
    139        this.#deferringTimeout = null;
    140      }
    141    }
    142  }
    143 
    144  /**
    145   * Receives DOM events, eventually queues them up, and calls back when it's
    146   * the right time to handle the event.
    147   *
    148   * @param {KeyboardEvent} event DOM event from the input.
    149   * @param {() => void} callback to be invoked when it's the right time to handle
    150   *        the event.
    151   */
    152  maybeDeferEvent(event, callback) {
    153    if (!callback) {
    154      throw new Error("Must provide a callback");
    155    }
    156    if (this.shouldDeferEvent(event)) {
    157      this.deferEvent(event, callback);
    158      return;
    159    }
    160    // If it has not been deferred, handle the callback immediately.
    161    callback();
    162  }
    163 
    164  /**
    165   * Adds a deferrable event to the deferred event queue.
    166   *
    167   * @param {KeyboardEvent} event The event to defer.
    168   * @param {() => void} callback to be invoked when it's the right time to handle
    169   *        the event.
    170   */
    171  deferEvent(event, callback) {
    172    // Check we don't try to defer events more than once.
    173    if (this.#eventsQueue.find(item => item.event == event)) {
    174      throw new Error(`Event ${event.type}:${event.keyCode} already deferred!`);
    175    }
    176    lazy.logger.debug(`Deferring ${event.type}:${event.keyCode} event`);
    177    this.#eventsQueue.push({
    178      event,
    179      callback,
    180      // Also store the current search string, as an added safety check. If the
    181      // string will differ later, the event is stale and should be dropped.
    182      searchString: this.#lastQuery.context.searchString,
    183    });
    184 
    185    if (!this.#deferringTimeout) {
    186      let elapsed = ChromeUtils.now() - this.#lastQuery.startDate;
    187      let remaining = UrlbarEventBufferer.DEFERRING_TIMEOUT_MS - elapsed;
    188      this.#deferringTimeout = lazy.setTimeout(
    189        () => {
    190          this.replayDeferredEvents(false);
    191          this.#deferringTimeout = null;
    192        },
    193        Math.max(0, remaining)
    194      );
    195    }
    196  }
    197 
    198  /**
    199   * Replays deferred key events.
    200   *
    201   * @param {boolean} onlyIfSafe replays only if it's a safe time to do so.
    202   *        Setting this to false will replay all the queue events, without any
    203   *        checks, that is something we want to do only if the deferring
    204   *        timeout elapsed, and we don't want to appear ignoring user's input.
    205   */
    206  replayDeferredEvents(onlyIfSafe) {
    207    if (typeof onlyIfSafe != "boolean") {
    208      throw new Error("Must provide a boolean argument");
    209    }
    210    if (!this.#eventsQueue.length) {
    211      return;
    212    }
    213 
    214    let { event, callback, searchString } = this.#eventsQueue[0];
    215    if (onlyIfSafe && !this.isSafeToPlayDeferredEvent(event)) {
    216      return;
    217    }
    218 
    219    // Remove the event from the queue and play it.
    220    this.#eventsQueue.shift();
    221    // Safety check: handle only if the search string didn't change meanwhile.
    222    if (searchString == this.#lastQuery.context.searchString) {
    223      callback();
    224    }
    225    Services.tm.dispatchToMainThread(() => {
    226      this.replayDeferredEvents(onlyIfSafe);
    227    });
    228  }
    229 
    230  /**
    231   * Checks whether a given event should be deferred
    232   *
    233   * @param {KeyboardEvent} event The event that should maybe be deferred.
    234   * @returns {boolean} Whether the event should be deferred.
    235   */
    236  shouldDeferEvent(event) {
    237    // If any event has been deferred for this search, then defer all subsequent
    238    // events so that the user does not experience them out of order.
    239    // All events will be replayed when #deferringTimeout fires.
    240    if (this.#eventsQueue.length) {
    241      return true;
    242    }
    243 
    244    // At this point, no events have been deferred for this search; we must
    245    // figure out if this event should be deferred.
    246    let isMacNavigation =
    247      AppConstants.platform == "macosx" &&
    248      event.ctrlKey &&
    249      this.input.view.isOpen &&
    250      (event.key === "n" || event.key === "p");
    251    if (!DEFERRED_KEY_CODES.has(event.keyCode) && !isMacNavigation) {
    252      return false;
    253    }
    254 
    255    if (DEFERRED_KEY_CODES.has(event.keyCode)) {
    256      // Defer while the user is composing.
    257      if (this.input.editor.composing) {
    258        return true;
    259      }
    260      if (this.input.controller.keyEventMovesCaret(event)) {
    261        return false;
    262      }
    263    }
    264 
    265    // This is an event that we'd defer, but if enough time has passed since the
    266    // start of the search, we don't want to block the user's workflow anymore.
    267    if (
    268      this.#lastQuery.startDate + UrlbarEventBufferer.DEFERRING_TIMEOUT_MS <=
    269      ChromeUtils.now()
    270    ) {
    271      return false;
    272    }
    273 
    274    if (
    275      event.keyCode == KeyEvent.DOM_VK_TAB &&
    276      !this.input.view.isOpen &&
    277      !this.waitingDeferUserSelectionProviders
    278    ) {
    279      // The view is closed and the user pressed the Tab key.  The focus should
    280      // move out of the urlbar immediately.
    281      return false;
    282    }
    283 
    284    return !this.isSafeToPlayDeferredEvent(event);
    285  }
    286 
    287  /**
    288   * Checks if the bufferer is deferring events.
    289   *
    290   * @returns {boolean} Whether the bufferer is deferring events.
    291   */
    292  get isDeferringEvents() {
    293    return !!this.#eventsQueue.length;
    294  }
    295 
    296  /**
    297   * Checks if any of the current query provider asked to defer user selection
    298   * events.
    299   *
    300   * @returns {boolean} Whether a provider asked to defer events.
    301   */
    302  get waitingDeferUserSelectionProviders() {
    303    return !!this.#lastQuery.context?.deferUserSelectionProviders.size;
    304  }
    305 
    306  /**
    307   * Returns true if the given deferred event can be played now without possibly
    308   * surprising the user.  This depends on the state of the view, the results,
    309   * and the type of event.
    310   * Use this method only after determining that the event should be deferred,
    311   * or after it has been deferred and you want to know if it can be played now.
    312   *
    313   * @param {KeyboardEvent} event The event.
    314   * @returns {boolean} Whether the event can be played.
    315   */
    316  isSafeToPlayDeferredEvent(event) {
    317    if (
    318      this.#lastQuery.status == QUERY_STATUS.COMPLETE ||
    319      this.#lastQuery.status == QUERY_STATUS.UKNOWN
    320    ) {
    321      // The view can't get any more results, so there's no need to further
    322      // defer events.
    323      return true;
    324    }
    325    let waitingHeuristicResults =
    326      this.#lastQuery.status == QUERY_STATUS.RUNNING;
    327    if (event.keyCode == KeyEvent.DOM_VK_RETURN) {
    328      // Check if we're waiting for providers that requested deferring.
    329      if (this.waitingDeferUserSelectionProviders) {
    330        return false;
    331      }
    332      // Play a deferred Enter if the heuristic result is not selected, or we
    333      // are not waiting for heuristic results yet.
    334      let selectedResult = this.input.view.selectedResult;
    335      return (
    336        (selectedResult && !selectedResult.heuristic) ||
    337        !waitingHeuristicResults
    338      );
    339    }
    340 
    341    if (
    342      waitingHeuristicResults ||
    343      !this.input.view.isOpen ||
    344      this.waitingDeferUserSelectionProviders
    345    ) {
    346      // We're still waiting on some results, or the popup hasn't opened yet.
    347      return false;
    348    }
    349 
    350    let isMacDownNavigation =
    351      AppConstants.platform == "macosx" &&
    352      event.ctrlKey &&
    353      this.input.view.isOpen &&
    354      event.key === "n";
    355    if (event.keyCode == KeyEvent.DOM_VK_DOWN || isMacDownNavigation) {
    356      // Don't play the event if the last result is selected so that the user
    357      // doesn't accidentally arrow down into the one-off buttons when they
    358      // didn't mean to. Note TAB is unaffected because it only navigates
    359      // results, not one-offs.
    360      return !this.lastResultIsSelected;
    361    }
    362 
    363    return true;
    364  }
    365 
    366  get lastResultIsSelected() {
    367    // TODO Bug 1536818: Once one-off buttons are fully implemented, it would be
    368    // nice to have a better way to check if the next down will focus one-off buttons.
    369    let results = this.#lastQuery.context.results;
    370    return (
    371      results.length &&
    372      results[results.length - 1] == this.input.view.selectedResult
    373    );
    374  }
    375 
    376  /**
    377   * A queue of deferred events.
    378   * The callback is invoked when it's the right time to handle the event,
    379   * but it may also never be invoked, if the context changed and the event
    380   * became obsolete.
    381   *
    382   * @type {{event: KeyboardEvent, callback: () => void, searchString: string}[]}
    383   */
    384  #eventsQueue = [];
    385 
    386  /**
    387   * If this timer fires, we will unconditionally replay all the deferred
    388   * events so that, after a certain point, we don't keep blocking the user's
    389   * actions, when nothing else has caused the events to be replayed.
    390   * At that point we won't check whether it's safe to replay the events,
    391   * because otherwise it may look like we ignored the user's actions.
    392   *
    393   * @type {?number}
    394   */
    395  #deferringTimeout = null;
    396 
    397  /**
    398   * Tracks the current or last query status.
    399   *
    400   * @type {{ startDate: number, status: Values<typeof QUERY_STATUS>, context: UrlbarQueryContext}}
    401   */
    402  #lastQuery;
    403 }