Bug 1944627 - update sidebar button checked state for non-revamped sidebar cases...
[gecko.git] / browser / components / urlbar / UrlbarController.sys.mjs
blobccc9d52fd027c3b5dc3be4bc8ba032e134013ead
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/. */
5 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
7 const lazy = {};
9 ChromeUtils.defineESModuleGetters(lazy, {
10   BrowserSearchTelemetry: "resource:///modules/BrowserSearchTelemetry.sys.mjs",
11   UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
12   UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs",
13   UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs",
14   UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
15 });
17 const TELEMETRY_1ST_RESULT = "PLACES_AUTOCOMPLETE_1ST_RESULT_TIME_MS";
18 const TELEMETRY_6_FIRST_RESULTS = "PLACES_AUTOCOMPLETE_6_FIRST_RESULTS_TIME_MS";
20 const NOTIFICATIONS = {
21   QUERY_STARTED: "onQueryStarted",
22   QUERY_RESULTS: "onQueryResults",
23   QUERY_RESULT_REMOVED: "onQueryResultRemoved",
24   QUERY_CANCELLED: "onQueryCancelled",
25   QUERY_FINISHED: "onQueryFinished",
26   VIEW_OPEN: "onViewOpen",
27   VIEW_CLOSE: "onViewClose",
30 /**
31  * The address bar controller handles queries from the address bar, obtains
32  * results and returns them to the UI for display.
33  *
34  * Listeners may be added to listen for the results. They may support the
35  * following methods which may be called when a query is run:
36  *
37  * - onQueryStarted(queryContext)
38  * - onQueryResults(queryContext)
39  * - onQueryCancelled(queryContext)
40  * - onQueryFinished(queryContext)
41  * - onQueryResultRemoved(index)
42  * - onViewOpen()
43  * - onViewClose()
44  */
45 export class UrlbarController {
46   /**
47    * Initialises the class. The manager may be overridden here, this is for
48    * test purposes.
49    *
50    * @param {object} options
51    *   The initial options for UrlbarController.
52    * @param {UrlbarInput} options.input
53    *   The input this controller is operating with.
54    * @param {object} [options.manager]
55    *   Optional fake providers manager to override the built-in providers manager.
56    *   Intended for use in unit tests only.
57    */
58   constructor(options = {}) {
59     if (!options.input) {
60       throw new Error("Missing options: input");
61     }
62     if (!options.input.window) {
63       throw new Error("input is missing 'window' property.");
64     }
65     if (
66       !options.input.window.location ||
67       options.input.window.location.href != AppConstants.BROWSER_CHROME_URL
68     ) {
69       throw new Error("input.window should be an actual browser window.");
70     }
71     if (!("isPrivate" in options.input)) {
72       throw new Error("input.isPrivate must be set.");
73     }
75     this.input = options.input;
76     this.browserWindow = options.input.window;
78     this.manager = options.manager || lazy.UrlbarProvidersManager;
80     this._listeners = new Set();
81     this._userSelectionBehavior = "none";
83     this.engagementEvent = new TelemetryEvent(
84       this,
85       options.eventTelemetryCategory
86     );
88     ChromeUtils.defineLazyGetter(this, "logger", () =>
89       lazy.UrlbarUtils.getLogger({ prefix: "Controller" })
90     );
91   }
93   get NOTIFICATIONS() {
94     return NOTIFICATIONS;
95   }
97   /**
98    * Hooks up the controller with a view.
99    *
100    * @param {UrlbarView} view
101    *   The UrlbarView instance associated with this controller.
102    */
103   setView(view) {
104     this.view = view;
105   }
107   /**
108    * Takes a query context and starts the query based on the user input.
109    *
110    * @param {UrlbarQueryContext} queryContext The query details.
111    * @returns {UrlbarQueryContext}
112    *   The updated query context.
113    */
114   async startQuery(queryContext) {
115     // Cancel any running query.
116     this.cancelQuery();
118     // Wrap the external queryContext, to track a unique object, in case
119     // the external consumer reuses the same context multiple times.
120     // This also allows to add properties without polluting the context.
121     // Note this can't be null-ed or deleted once a query is done, because it's
122     // used by #dismissSelectedResult and handleKeyNavigation, that can run after
123     // a query is cancelled or finished.
124     let contextWrapper = (this._lastQueryContextWrapper = { queryContext });
126     queryContext.lastResultCount = 0;
127     TelemetryStopwatch.start(TELEMETRY_1ST_RESULT, queryContext);
128     TelemetryStopwatch.start(TELEMETRY_6_FIRST_RESULTS, queryContext);
130     // For proper functionality we must ensure this notification is fired
131     // synchronously, as soon as startQuery is invoked, but after any
132     // notifications related to the previous query.
133     this.notify(NOTIFICATIONS.QUERY_STARTED, queryContext);
134     await this.manager.startQuery(queryContext, this);
136     // If the query has been cancelled, onQueryFinished was notified already.
137     // Note this._lastQueryContextWrapper may have changed in the meanwhile.
138     if (
139       contextWrapper === this._lastQueryContextWrapper &&
140       !contextWrapper.done
141     ) {
142       contextWrapper.done = true;
143       // TODO (Bug 1549936) this is necessary to avoid leaks in PB tests.
144       this.manager.cancelQuery(queryContext);
145       this.notify(NOTIFICATIONS.QUERY_FINISHED, queryContext);
146     }
148     return queryContext;
149   }
151   /**
152    * Cancels an in-progress query. Note, queries may continue running if they
153    * can't be cancelled.
154    */
155   cancelQuery() {
156     // If the query finished already, don't handle cancel.
157     if (!this._lastQueryContextWrapper || this._lastQueryContextWrapper.done) {
158       return;
159     }
161     this._lastQueryContextWrapper.done = true;
163     let { queryContext } = this._lastQueryContextWrapper;
164     TelemetryStopwatch.cancel(TELEMETRY_1ST_RESULT, queryContext);
165     TelemetryStopwatch.cancel(TELEMETRY_6_FIRST_RESULTS, queryContext);
166     this.manager.cancelQuery(queryContext);
167     this.notify(NOTIFICATIONS.QUERY_CANCELLED, queryContext);
168     this.notify(NOTIFICATIONS.QUERY_FINISHED, queryContext);
169   }
171   /**
172    * Receives results from a query.
173    *
174    * @param {UrlbarQueryContext} queryContext The query details.
175    */
176   receiveResults(queryContext) {
177     if (queryContext.lastResultCount < 1 && queryContext.results.length >= 1) {
178       TelemetryStopwatch.finish(TELEMETRY_1ST_RESULT, queryContext);
179     }
180     if (queryContext.lastResultCount < 6 && queryContext.results.length >= 6) {
181       TelemetryStopwatch.finish(TELEMETRY_6_FIRST_RESULTS, queryContext);
182     }
184     if (queryContext.firstResultChanged) {
185       // Notify the input so it can make adjustments based on the first result.
186       if (this.input.onFirstResult(queryContext.results[0])) {
187         // The input canceled the query and started a new one.
188         return;
189       }
191       // The first time we receive results try to connect to the heuristic
192       // result.
193       this.speculativeConnect(
194         queryContext.results[0],
195         queryContext,
196         "resultsadded"
197       );
198     }
200     this.notify(NOTIFICATIONS.QUERY_RESULTS, queryContext);
201     // Update lastResultCount after notifying, so the view can use it.
202     queryContext.lastResultCount = queryContext.results.length;
203   }
205   /**
206    * Adds a listener for query actions and results.
207    *
208    * @param {object} listener The listener to add.
209    * @throws {TypeError} Throws if the listener is not an object.
210    */
211   addQueryListener(listener) {
212     if (!listener || typeof listener != "object") {
213       throw new TypeError("Expected listener to be an object");
214     }
215     this._listeners.add(listener);
216   }
218   /**
219    * Removes a query listener.
220    *
221    * @param {object} listener The listener to add.
222    */
223   removeQueryListener(listener) {
224     this._listeners.delete(listener);
225   }
227   /**
228    * Checks whether a keyboard event that would normally open the view should
229    * instead be handled natively by the input field.
230    * On certain platforms, the up and down keys can be used to move the caret,
231    * in which case we only want to open the view if the caret is at the
232    * start or end of the input.
233    *
234    * @param {KeyboardEvent} event
235    *   The DOM KeyboardEvent.
236    * @returns {boolean}
237    *   Returns true if the event should move the caret instead of opening the
238    *   view.
239    */
240   keyEventMovesCaret(event) {
241     if (this.view.isOpen) {
242       return false;
243     }
244     if (AppConstants.platform != "macosx" && AppConstants.platform != "linux") {
245       return false;
246     }
247     let isArrowUp = event.keyCode == KeyEvent.DOM_VK_UP;
248     let isArrowDown = event.keyCode == KeyEvent.DOM_VK_DOWN;
249     if (!isArrowUp && !isArrowDown) {
250       return false;
251     }
252     let start = this.input.selectionStart;
253     let end = this.input.selectionEnd;
254     if (
255       end != start ||
256       (isArrowUp && start > 0) ||
257       (isArrowDown && end < this.input.value.length)
258     ) {
259       return true;
260     }
261     return false;
262   }
264   /**
265    * Receives keyboard events from the input and handles those that should
266    * navigate within the view or pick the currently selected item.
267    *
268    * @param {KeyboardEvent} event
269    *   The DOM KeyboardEvent.
270    * @param {boolean} executeAction
271    *   Whether the event should actually execute the associated action, or just
272    *   be managed (at a preventDefault() level). This is used when the event
273    *   will be deferred by the event bufferer, but preventDefault() and friends
274    *   should still happen synchronously.
275    */
276   // eslint-disable-next-line complexity
277   handleKeyNavigation(event, executeAction = true) {
278     const isMac = AppConstants.platform == "macosx";
279     // Handle readline/emacs-style navigation bindings on Mac.
280     if (
281       isMac &&
282       this.view.isOpen &&
283       event.ctrlKey &&
284       (event.key == "n" || event.key == "p")
285     ) {
286       if (executeAction) {
287         this.view.selectBy(1, { reverse: event.key == "p" });
288       }
289       event.preventDefault();
290       return;
291     }
293     if (this.view.isOpen && executeAction && this._lastQueryContextWrapper) {
294       // In native inputs on most platforms, Shift+Up/Down moves the caret to the
295       // start/end of the input and changes its selection, so in that case defer
296       // handling to the input instead of changing the view's selection.
297       if (
298         event.shiftKey &&
299         (event.keyCode === KeyEvent.DOM_VK_UP ||
300           event.keyCode === KeyEvent.DOM_VK_DOWN)
301       ) {
302         return;
303       }
305       let { queryContext } = this._lastQueryContextWrapper;
306       let handled = this.view.oneOffSearchButtons.handleKeyDown(
307         event,
308         this.view.visibleRowCount,
309         this.view.allowEmptySelection,
310         queryContext.searchString
311       );
312       if (handled) {
313         return;
314       }
315     }
317     switch (event.keyCode) {
318       case KeyEvent.DOM_VK_ESCAPE:
319         if (executeAction) {
320           if (this.view.isOpen) {
321             this.view.close();
322           } else if (
323             lazy.UrlbarPrefs.get("focusContentDocumentOnEsc") &&
324             !this.input.searchMode &&
325             (this.input.getAttribute("pageproxystate") == "valid" ||
326               (this.input.value == "" &&
327                 this.browserWindow.isBlankPageURL(
328                   this.browserWindow.gBrowser.currentURI.spec
329                 )))
330           ) {
331             this.browserWindow.gBrowser.selectedBrowser.focus();
332           } else {
333             this.input.handleRevert();
334           }
335         }
336         event.preventDefault();
337         break;
338       case KeyEvent.DOM_VK_SPACE:
339         if (!this.view.shouldSpaceActivateSelectedElement()) {
340           break;
341         }
342       // Fall through, we want the SPACE key to activate this element.
343       case KeyEvent.DOM_VK_RETURN:
344         this.logger.debug(`Enter pressed${executeAction ? "" : " delayed"}`);
345         if (executeAction) {
346           this.input.handleCommand(event);
347         }
348         event.preventDefault();
349         break;
350       case KeyEvent.DOM_VK_TAB: {
351         if (!this.view.visibleRowCount) {
352           // Leave it to the default behaviour if there are not results.
353           break;
354         }
356         // Change the tab behavior when urlbar view is open.
357         if (
358           lazy.UrlbarPrefs.get("scotchBonnet.enableOverride") &&
359           this.view.isOpen &&
360           !event.ctrlKey &&
361           !event.altKey
362         ) {
363           if (
364             (event.shiftKey &&
365               this.view.selectedElement ==
366                 this.view.getFirstSelectableElement()) ||
367             (!event.shiftKey &&
368               this.view.selectedElement == this.view.getLastSelectableElement())
369           ) {
370             // If pressing tab + shift when the first or pressing tab when last
371             // element has been selected, move the focus to the Unified Search
372             // Button. Then make urlbar results selectable by tab + shift.
373             event.preventDefault();
374             this.view.selectedRowIndex = -1;
375             this.#focusOnUnifiedSearchButton();
376             break;
377           } else if (
378             !this.view.selectedElement &&
379             this.input.focusedViaMousedown
380           ) {
381             if (event.shiftKey) {
382               this.#focusOnUnifiedSearchButton();
383             } else {
384               this.view.selectBy(1, {
385                 userPressedTab: true,
386               });
387             }
388             event.preventDefault();
389             break;
390           }
391         }
393         // It's always possible to tab through results when the urlbar was
394         // focused with the mouse or has a search string, or when the view
395         // already has a selection.
396         // We allow tabbing without a search string when in search mode preview,
397         // since that means the user has interacted with the Urlbar since
398         // opening it.
399         // When there's no search string and no view selection, we want to focus
400         // the next toolbar item instead, for accessibility reasons.
401         let allowTabbingThroughResults =
402           this.input.focusedViaMousedown ||
403           this.input.searchMode?.isPreview ||
404           this.view.selectedElement ||
405           (this.input.value &&
406             this.input.getAttribute("pageproxystate") != "valid");
407         if (
408           // Even if the view is closed, we may be waiting results, and in
409           // such a case we don't want to tab out of the urlbar.
410           (this.view.isOpen || !executeAction) &&
411           !event.ctrlKey &&
412           !event.altKey &&
413           allowTabbingThroughResults
414         ) {
415           if (executeAction) {
416             this.userSelectionBehavior = "tab";
417             this.view.selectBy(1, {
418               reverse: event.shiftKey,
419               userPressedTab: true,
420             });
421           }
422           event.preventDefault();
423         }
424         break;
425       }
426       case KeyEvent.DOM_VK_PAGE_DOWN:
427       case KeyEvent.DOM_VK_PAGE_UP:
428         if (event.ctrlKey) {
429           break;
430         }
431       // eslint-disable-next-lined no-fallthrough
432       case KeyEvent.DOM_VK_DOWN:
433       case KeyEvent.DOM_VK_UP:
434         if (event.altKey) {
435           break;
436         }
437         if (this.view.isOpen) {
438           if (executeAction) {
439             this.userSelectionBehavior = "arrow";
440             this.view.selectBy(
441               event.keyCode == KeyEvent.DOM_VK_PAGE_DOWN ||
442                 event.keyCode == KeyEvent.DOM_VK_PAGE_UP
443                 ? lazy.UrlbarUtils.PAGE_UP_DOWN_DELTA
444                 : 1,
445               {
446                 reverse:
447                   event.keyCode == KeyEvent.DOM_VK_UP ||
448                   event.keyCode == KeyEvent.DOM_VK_PAGE_UP,
449               }
450             );
451           }
452         } else {
453           if (this.keyEventMovesCaret(event)) {
454             break;
455           }
456           if (executeAction) {
457             this.userSelectionBehavior = "arrow";
458             this.input.startQuery({
459               searchString: this.input.value,
460               event,
461             });
462           }
463         }
464         event.preventDefault();
465         break;
466       case KeyEvent.DOM_VK_RIGHT:
467       case KeyEvent.DOM_VK_END:
468         this.input.maybeConfirmSearchModeFromResult({
469           entry: "typed",
470           startQuery: true,
471         });
472       // Fall through.
473       case KeyEvent.DOM_VK_LEFT:
474       case KeyEvent.DOM_VK_HOME:
475         this.view.removeAccessibleFocus();
476         break;
477       case KeyEvent.DOM_VK_BACK_SPACE:
478         if (
479           this.input.searchMode &&
480           this.input.selectionStart == 0 &&
481           this.input.selectionEnd == 0 &&
482           !event.shiftKey
483         ) {
484           this.input.searchMode = null;
485           this.input.view.oneOffSearchButtons.selectedButton = null;
486           this.input.startQuery({
487             allowAutofill: false,
488             event,
489           });
490         }
491       // Fall through.
492       case KeyEvent.DOM_VK_DELETE:
493         if (!this.view.isOpen) {
494           break;
495         }
496         if (event.shiftKey) {
497           if (!executeAction || this.#dismissSelectedResult(event)) {
498             event.preventDefault();
499           }
500         } else if (executeAction) {
501           this.userSelectionBehavior = "none";
502         }
503         break;
504     }
505   }
507   /**
508    * Tries to initialize a speculative connection on a result.
509    * Speculative connections are only supported for a subset of all the results.
510    *
511    * Speculative connect to:
512    *  - Search engine heuristic results
513    *  - autofill results
514    *  - http/https results
515    *
516    * @param {UrlbarResult} result The result to speculative connect to.
517    * @param {UrlbarQueryContext} context The queryContext
518    * @param {string} reason Reason for the speculative connect request.
519    */
520   speculativeConnect(result, context, reason) {
521     // Never speculative connect in private contexts.
522     if (!this.input || context.isPrivate || !context.results.length) {
523       return;
524     }
526     switch (reason) {
527       case "resultsadded": {
528         // We should connect to an heuristic result, if it exists.
529         if (
530           (result == context.results[0] && result.heuristic) ||
531           result.autofill
532         ) {
533           if (result.type == lazy.UrlbarUtils.RESULT_TYPE.SEARCH) {
534             // Speculative connect only if search suggestions are enabled.
535             if (
536               lazy.UrlbarPrefs.get("suggest.searches") &&
537               lazy.UrlbarPrefs.get("browser.search.suggest.enabled")
538             ) {
539               let engine = Services.search.getEngineByName(
540                 result.payload.engine
541               );
542               lazy.UrlbarUtils.setupSpeculativeConnection(
543                 engine,
544                 this.browserWindow
545               );
546             }
547           } else if (result.autofill) {
548             const { url } = lazy.UrlbarUtils.getUrlFromResult(result);
549             if (!url) {
550               return;
551             }
553             lazy.UrlbarUtils.setupSpeculativeConnection(
554               url,
555               this.browserWindow
556             );
557           }
558         }
559         return;
560       }
561       case "mousedown": {
562         const { url } = lazy.UrlbarUtils.getUrlFromResult(result);
563         if (!url) {
564           return;
565         }
567         // On mousedown, connect only to http/https urls.
568         if (url.startsWith("http")) {
569           lazy.UrlbarUtils.setupSpeculativeConnection(url, this.browserWindow);
570         }
571         return;
572       }
573       default: {
574         throw new Error("Invalid speculative connection reason");
575       }
576     }
577   }
579   /**
580    * Stores the selection behavior that the user has used to select a result.
581    *
582    * @param {"arrow"|"tab"|"none"} behavior
583    *   The behavior the user used.
584    */
585   set userSelectionBehavior(behavior) {
586     // Don't change the behavior to arrow if tab has already been recorded,
587     // as we want to know that the tab was used first.
588     if (behavior == "arrow" && this._userSelectionBehavior == "tab") {
589       return;
590     }
591     this._userSelectionBehavior = behavior;
592   }
594   /**
595    * Records details of the selected result in telemetry. We only record the
596    * selection behavior, type and index.
597    *
598    * @param {Event} event
599    *   The event which triggered the result to be selected.
600    * @param {UrlbarResult} result
601    *   The selected result.
602    */
603   recordSelectedResult(event, result) {
604     let resultIndex = result ? result.rowIndex : -1;
605     let selectedResult = -1;
606     if (resultIndex >= 0) {
607       // Except for the history popup, the urlbar always has a selection.  The
608       // first result at index 0 is the "heuristic" result that indicates what
609       // will happen when you press the Enter key.  Treat it as no selection.
610       selectedResult = resultIndex > 0 || !result.heuristic ? resultIndex : -1;
611     }
612     lazy.BrowserSearchTelemetry.recordSearchSuggestionSelectionMethod(
613       event,
614       "urlbar",
615       selectedResult,
616       this._userSelectionBehavior
617     );
619     if (!result) {
620       return;
621     }
623     // Do not modify existing telemetry types.  To add a new type:
624     //
625     // * Set telemetryType appropriately. Since telemetryType is used as the
626     //   probe name, it must be alphanumeric with optional underscores.
627     // * Add a new keyed scalar probe into the urlbar.picked category for the
628     //   newly added telemetryType.
629     // * Add a new matching Glean metric in browser/components/urlbar/metrics.yaml.
630     // * Add a test named browser_UsageTelemetry_urlbar_newType.js to
631     //   browser/modules/test/browser.
632     //
633     // The "topsite" type overrides the other ones, because it starts from a
634     // unique user interaction, that we want to count apart. We do this here
635     // rather than in telemetryTypeFromResult because other consumers, like
636     // events telemetry, are reporting this information separately.
637     let telemetryType =
638       result.providerName == "UrlbarProviderTopSites"
639         ? "topsite"
640         : lazy.UrlbarUtils.telemetryTypeFromResult(result, true);
641     Glean.urlbarPicked[telemetryType]?.[resultIndex].add(1);
642     if (this.input.searchMode && !this.input.searchMode.isPreview) {
643       let name = this.input.searchMode.entry.replace(/_([a-z])/g, (m, p) =>
644         p.toUpperCase()
645       );
646       Glean.urlbarPickedSearchmode[name]?.[resultIndex].add(1);
647     }
648   }
650   /**
651    * Triggers a "dismiss" engagement for the selected result if one is selected
652    * and it's not the heuristic. Providers that can respond to dismissals of
653    * their results should implement `onEngagement()`, handle the
654    * dismissal, and call `controller.removeResult()`.
655    *
656    * @param {Event} event
657    *   The event that triggered dismissal.
658    * @returns {boolean}
659    *   Whether providers were notified about the engagement. Providers will not
660    *   be notified if there is no selected result or the selected result is the
661    *   heuristic, since the heuristic result cannot be dismissed.
662    */
663   #dismissSelectedResult(event) {
664     if (!this._lastQueryContextWrapper) {
665       console.error("Cannot dismiss selected result, last query not present");
666       return false;
667     }
668     let { queryContext } = this._lastQueryContextWrapper;
670     let { selectedElement } = this.input.view;
671     if (selectedElement?.classList.contains("urlbarView-button")) {
672       // For results with buttons, delete them only when the main part of the
673       // row is selected, not a button.
674       return false;
675     }
677     let result = this.input.view.selectedResult;
678     if (!result || result.heuristic) {
679       return false;
680     }
682     this.engagementEvent.record(event, {
683       result,
684       selType: "dismiss",
685       searchString: queryContext.searchString,
686     });
688     return true;
689   }
691   /**
692    * Removes a result from the current query context and notifies listeners.
693    * Heuristic results cannot be removed.
694    *
695    * @param {UrlbarResult} result
696    *   The result to remove.
697    */
698   removeResult(result) {
699     if (!result || result.heuristic) {
700       return;
701     }
703     if (!this._lastQueryContextWrapper) {
704       console.error("Cannot remove result, last query not present");
705       return;
706     }
707     let { queryContext } = this._lastQueryContextWrapper;
709     let index = queryContext.results.indexOf(result);
710     if (index < 0) {
711       console.error("Failed to find the selected result in the results");
712       return;
713     }
715     queryContext.results.splice(index, 1);
716     this.notify(NOTIFICATIONS.QUERY_RESULT_REMOVED, index);
717   }
719   /**
720    * Set the query context cache.
721    *
722    * @param {UrlbarQueryContext} queryContext the object to cache.
723    */
724   setLastQueryContextCache(queryContext) {
725     this._lastQueryContextWrapper = { queryContext };
726   }
728   /**
729    * Clear the previous query context cache.
730    */
731   clearLastQueryContextCache() {
732     this._lastQueryContextWrapper = null;
733   }
735   /**
736    * Notifies listeners of results.
737    *
738    * @param {string} name Name of the notification.
739    * @param {object} params Parameters to pass with the notification.
740    */
741   notify(name, ...params) {
742     for (let listener of this._listeners) {
743       // Can't use "in" because some tests proxify these.
744       if (typeof listener[name] != "undefined") {
745         try {
746           listener[name](...params);
747         } catch (ex) {
748           console.error(ex);
749         }
750       }
751     }
752   }
754   #focusOnUnifiedSearchButton() {
755     this.input.toggleAttribute("unifiedsearchbutton-available", true);
757     const switcher = this.input.document.getElementById(
758       "urlbar-searchmode-switcher"
759     );
760     // Set tabindex to be focusable.
761     switcher.setAttribute("tabindex", "-1");
762     // Remove blur listener to avoid closing urlbar view panel.
763     this.input.removeEventListener("blur", this.input);
764     // Move the focus.
765     switcher.focus();
766     // Restore all.
767     this.input.addEventListener("blur", this.input);
768     switcher.addEventListener(
769       "blur",
770       e => {
771         switcher.removeAttribute("tabindex");
772         if (
773           this.input.hasAttribute("focused") &&
774           !e.relatedTarget?.closest("#urlbar")
775         ) {
776           // If the focus is not back to urlbar, fire blur event explicitly to
777           // clear the urlbar. Because the input field has been losing an
778           // opportunity to lose the focus since we removed blur listener once.
779           this.input.inputField.dispatchEvent(
780             new FocusEvent("blur", {
781               relatedTarget: e.relatedTarget,
782             })
783           );
784         }
785       },
786       { once: true }
787     );
788   }
792  * Tracks and records telemetry events for the given category, if provided,
793  * otherwise it's a no-op.
794  * It is currently designed around the "urlbar" category, even if it can
795  * potentially be extended to other categories.
796  * To record an event, invoke start() with a starting event, then either
797  * invoke record() with a final event, or discard() to drop the recording.
799  * @see Events.yaml
800  */
801 class TelemetryEvent {
802   constructor(controller, category) {
803     this._controller = controller;
804     this._category = category;
805     this.#beginObservingPingPrefs();
806   }
808   /**
809    * Start measuring the elapsed time from a user-generated event.
810    * After this has been invoked, any subsequent calls to start() are ignored,
811    * until either record() or discard() are invoked. Thus, it is safe to keep
812    * invoking this on every input event as the user is typing, for example.
813    *
814    * @param {event} event A DOM event.
815    * @param {UrlbarQueryContext} queryContext A queryContext.
816    * @param {string} [searchString] Pass a search string related to the event if
817    *        you have one.  The event by itself sometimes isn't enough to
818    *        determine the telemetry details we should record.
819    * @throws This should never throw, or it may break the urlbar.
820    * @see {@link https://firefox-source-docs.mozilla.org/browser/urlbar/telemetry.html}
821    */
822   start(event, queryContext, searchString = null) {
823     if (this._startEventInfo) {
824       if (this._startEventInfo.interactionType == "topsites") {
825         // If the most recent event came from opening the results pane with an
826         // empty string replace the interactionType (that would be "topsites")
827         // with one for the current event to better measure the user flow.
828         this._startEventInfo.interactionType = this._getStartInteractionType(
829           event,
830           searchString
831         );
832         this._startEventInfo.searchString = searchString;
833       } else if (
834         this._startEventInfo.interactionType == "returned" &&
835         (!searchString ||
836           this._startEventInfo.searchString[0] != searchString[0])
837       ) {
838         // In case of a "returned" interaction ongoing, the user may either
839         // continue the search, or restart with a new search string. In that case
840         // we want to change the interaction type to "restarted".
841         // Detecting all the possible ways of clearing the input would be tricky,
842         // thus this makes a guess by just checking the first char matches; even if
843         // the user backspaces a part of the string, we still count that as a
844         // "returned" interaction.
845         this._startEventInfo.interactionType = "restarted";
846       }
848       // start is invoked on a user-generated event, but we only count the first
849       // one.  Once an engagement or abandoment happens, we clear _startEventInfo.
850       return;
851     }
853     if (!this._category) {
854       return;
855     }
856     if (!event) {
857       console.error("Must always provide an event");
858       return;
859     }
860     const validEvents = [
861       "click",
862       "command",
863       "drop",
864       "input",
865       "keydown",
866       "mousedown",
867       "tabswitch",
868       "focus",
869     ];
870     if (!validEvents.includes(event.type)) {
871       console.error("Can't start recording from event type: ", event.type);
872       return;
873     }
875     this._startEventInfo = {
876       timeStamp: event.timeStamp || Cu.now(),
877       interactionType: this._getStartInteractionType(event, searchString),
878       searchString,
879     };
880   }
882   /**
883    * Record an engagement telemetry event.
884    * When the user picks a result from a search through the mouse or keyboard,
885    * an engagement event is recorded. If instead the user abandons a search, by
886    * blurring the input field, an abandonment event is recorded.
887    *
888    * On return, `details.isSessionOngoing` will be set to true if the engagement
889    * did not end the search session. Not all engagements end the session. The
890    * session remains ongoing when certain commands are picked (like dismissal)
891    * and results that enter search mode are picked.
892    *
893    * @param {event} [event]
894    *        A DOM event.
895    *        Note: event can be null, that usually happens for paste&go or drop&go.
896    *        If there's no _startEventInfo this is a no-op.
897    * @param {object} details An object describing action details.
898    * @param {string} [details.searchString] The user's search string. Note that
899    *        this string is not sent with telemetry data. It is only used
900    *        locally to discern other data, such as the number of characters and
901    *        words in the string.
902    * @param {string} [details.selType] type of the selected element, undefined
903    *        for "blur". One of "unknown", "autofill", "visiturl", "bookmark",
904    *        "help", "history", "keyword", "searchengine", "searchsuggestion",
905    *        "switchtab", "remotetab", "extension", "oneoff", "dismiss".
906    * @param {UrlbarResult} [details.result] The engaged result. This should be
907    *        set to the result related to the picked element.
908    * @param {DOMElement} [details.element] The picked view element.
909    */
910   record(event, details) {
911     // Prevent re-entering `record()`. This can happen because
912     // `#internalRecord()` will notify an engagement to the provider, that may
913     // execute an action blurring the input field. Then both an engagement
914     // and an abandonment would be recorded for the same session.
915     // Nulling out `_startEventInfo` doesn't save us in this case, because it
916     // happens after `#internalRecord()`, and `isSessionOngoing` must be
917     // calculated inside it.
918     if (this.#handlingRecord) {
919       return;
920     }
922     // This should never throw, or it may break the urlbar.
923     try {
924       this.#handlingRecord = true;
925       this.#internalRecord(event, details);
926     } catch (ex) {
927       console.error("Could not record event: ", ex);
928     } finally {
929       this.#handlingRecord = false;
931       // Reset the start event info except for engagements that do not end the
932       // search session. In that case, the view stays open and further
933       // engagements are possible and should be recorded when they occur.
934       // (`details.isSessionOngoing` is not a param; rather, it's set by
935       // `#internalRecord()`.)
936       if (!details.isSessionOngoing) {
937         this._startEventInfo = null;
938         this._discarded = false;
939       }
940     }
941   }
943   #internalRecord(event, details) {
944     const startEventInfo = this._startEventInfo;
946     if (!this._category || !startEventInfo) {
947       return;
948     }
949     if (
950       !event &&
951       startEventInfo.interactionType != "pasted" &&
952       startEventInfo.interactionType != "dropped"
953     ) {
954       // If no event is passed, we must be executing either paste&go or drop&go.
955       throw new Error("Event must be defined, unless input was pasted/dropped");
956     }
957     if (!details) {
958       throw new Error("Invalid event details: " + details);
959     }
961     let action;
962     let skipLegacyTelemetry = false;
963     if (!event) {
964       action =
965         startEventInfo.interactionType == "dropped" ? "drop_go" : "paste_go";
966     } else if (event.type == "blur") {
967       action = "blur";
968     } else if (event.type == "tabswitch") {
969       action = "tab_switch";
970     } else if (
971       details.element?.dataset.command &&
972       // The "help" selType is recognized by legacy telemetry, and `action`
973       // should be set to either "click" or "enter" depending on whether the
974       // event is a mouse event, so ignore "help" here.
975       details.element.dataset.command != "help"
976     ) {
977       action = details.element.dataset.command;
978       skipLegacyTelemetry = true;
979     } else if (details.selType == "dismiss") {
980       action = "dismiss";
981       skipLegacyTelemetry = true;
982     } else if (MouseEvent.isInstance(event)) {
983       action = event.target.classList.contains("urlbar-go-button")
984         ? "go_button"
985         : "click";
986     } else {
987       action = "enter";
988     }
990     let method =
991       action == "blur" || action == "tab_switch" ? "abandonment" : "engagement";
993     if (method == "engagement") {
994       // Not all engagements end the search session. The session remains ongoing
995       // when certain commands are picked (like dismissal) and results that
996       // enter search mode are picked. We should find a generalized way to
997       // determine this instead of listing all the cases like this.
998       details.isSessionOngoing = !!(
999         ["dismiss", "inaccurate_location", "show_less_frequently"].includes(
1000           details.selType
1001         ) || details.result?.payload.providesSearchMode
1002       );
1003     }
1005     // numWords is not a perfect measurement, since it will return an incorrect
1006     // value for languages that do not use spaces or URLs containing spaces in
1007     // its query parameters, for example.
1008     let { numChars, numWords, searchWords } = this._parseSearchString(
1009       details.searchString
1010     );
1012     details.provider = details.result?.providerName;
1013     details.selIndex = details.result?.rowIndex ?? -1;
1015     let { queryContext } = this._controller._lastQueryContextWrapper || {};
1017     this._recordSearchEngagementTelemetry(
1018       queryContext,
1019       method,
1020       startEventInfo,
1021       {
1022         action,
1023         numChars,
1024         numWords,
1025         searchWords,
1026         provider: details.provider,
1027         searchSource: details.searchSource,
1028         searchMode: details.searchMode,
1029         selectedElement: details.element,
1030         selIndex: details.selIndex,
1031         selType: details.selType,
1032       }
1033     );
1035     if (!details.isSessionOngoing) {
1036       this.#recordExposures(queryContext);
1037     }
1039     if (!skipLegacyTelemetry) {
1040       if (method == "engagement") {
1041         Glean.urlbar.engagementCount.add(1);
1042       } else {
1043         Glean.urlbar.abandonmentCount.add(1);
1044       }
1046       let firstVisibleResult = this._controller.view?.visibleResults?.[0];
1047       if (
1048         method === "engagement" &&
1049         firstVisibleResult?.autofill &&
1050         firstVisibleResult?.type == lazy.UrlbarUtils.RESULT_TYPE.URL
1051       ) {
1052         // Record autofill impressions upon engagement.
1053         const type = lazy.UrlbarUtils.telemetryTypeFromResult(
1054           firstVisibleResult,
1055           true
1056         );
1057         Glean.urlbarImpression[type]?.add(1);
1058       }
1059     }
1061     try {
1062       this._controller.manager.notifyEngagementChange(
1063         method,
1064         queryContext,
1065         details,
1066         this._controller
1067       );
1068     } catch (error) {
1069       // We handle and report any error here to avoid hitting the record()
1070       // handler, that would look like we didn't send telemetry at all.
1071       console.error(error);
1072     }
1073   }
1075   _recordSearchEngagementTelemetry(
1076     queryContext,
1077     method,
1078     startEventInfo,
1079     {
1080       action,
1081       numWords,
1082       numChars,
1083       provider,
1084       reason,
1085       searchWords,
1086       searchSource,
1087       searchMode,
1088       selIndex,
1089       selType,
1090     }
1091   ) {
1092     const browserWindow = this._controller.browserWindow;
1093     let sap = "urlbar";
1094     if (searchSource === "urlbar-handoff") {
1095       sap = "handoff";
1096     } else if (
1097       browserWindow.isBlankPageURL(browserWindow.gBrowser.currentURI.spec)
1098     ) {
1099       sap = "urlbar_newtab";
1100     } else if (browserWindow.gBrowser.currentURI.schemeIs("moz-extension")) {
1101       sap = "urlbar_addonpage";
1102     }
1104     searchMode = searchMode ?? this._controller.input.searchMode;
1106     // Distinguish user typed search strings from persisted search terms.
1107     const interaction = this.#getInteractionType(
1108       method,
1109       startEventInfo,
1110       searchSource,
1111       searchWords,
1112       searchMode
1113     );
1114     const search_mode = this.#getSearchMode(searchMode);
1115     const currentResults = this._controller.view?.visibleResults ?? [];
1116     let numResults = currentResults.length;
1117     let groups = currentResults
1118       .map(r => lazy.UrlbarUtils.searchEngagementTelemetryGroup(r))
1119       .join(",");
1120     let results = currentResults
1121       .map(r => lazy.UrlbarUtils.searchEngagementTelemetryType(r))
1122       .join(",");
1123     let actions = currentResults
1124       .map((r, i) => lazy.UrlbarUtils.searchEngagementTelemetryAction(r, i))
1125       .filter(v => v)
1126       .join(",");
1127     const search_engine_default_id = Services.search.defaultEngine.telemetryId;
1129     let eventInfo;
1130     if (method === "engagement") {
1131       let selected_result = lazy.UrlbarUtils.searchEngagementTelemetryType(
1132         currentResults[selIndex],
1133         selType
1134       );
1136       if (selType == "action") {
1137         let actionKey = lazy.UrlbarUtils.searchEngagementTelemetryAction(
1138           currentResults[selIndex],
1139           selIndex
1140         );
1141         selected_result = `action_${actionKey}`;
1142       }
1144       if (selected_result === "input_field" && !this._controller.view?.isOpen) {
1145         numResults = 0;
1146         groups = "";
1147         results = "";
1148       }
1150       eventInfo = {
1151         sap,
1152         interaction,
1153         search_mode,
1154         n_chars: numChars,
1155         n_words: numWords,
1156         n_results: numResults,
1157         selected_position: selIndex + 1,
1158         selected_result,
1159         provider,
1160         engagement_type:
1161           selType === "help" || selType === "dismiss" ? selType : action,
1162         search_engine_default_id,
1163         groups,
1164         results,
1165         actions,
1166       };
1167     } else if (method === "abandonment") {
1168       eventInfo = {
1169         abandonment_type: action,
1170         sap,
1171         interaction,
1172         search_mode,
1173         n_chars: numChars,
1174         n_words: numWords,
1175         n_results: numResults,
1176         search_engine_default_id,
1177         groups,
1178         results,
1179         actions,
1180       };
1181     } else if (method === "impression") {
1182       eventInfo = {
1183         reason,
1184         sap,
1185         interaction,
1186         search_mode,
1187         n_chars: numChars,
1188         n_words: numWords,
1189         n_results: numResults,
1190         search_engine_default_id,
1191         groups,
1192         results,
1193         actions,
1194       };
1195     } else {
1196       console.error(`Unknown telemetry event method: ${method}`);
1197       return;
1198     }
1200     this._controller.logger.info(`${method} event:`, eventInfo);
1202     Glean.urlbar[method].record(eventInfo);
1203   }
1205   #recordExposures(queryContext) {
1206     let exposures = this.#exposures;
1207     this.#exposures = [];
1208     this.#tentativeExposures = [];
1209     if (!exposures.length) {
1210       return;
1211     }
1213     let terminalByType = new Map();
1214     let keywordExposureRecorded = false;
1215     for (let { weakResult, resultType, keyword } of exposures) {
1216       let terminal = false;
1217       let result = weakResult.get();
1218       if (result) {
1219         this.#exposureResults.delete(result);
1221         let endResults = result.isHiddenExposure
1222           ? queryContext.results
1223           : this._controller.view?.visibleResults;
1224         terminal = endResults?.includes(result);
1225       }
1227       terminalByType.set(resultType, terminal);
1229       // Record the `keyword_exposure` event if there's a keyword.
1230       if (keyword) {
1231         let data = { keyword, terminal, result: resultType };
1232         this._controller.logger.debug("Recording keyword_exposure event", data);
1233         Glean.urlbar.keywordExposure.record(data);
1234         keywordExposureRecorded = true;
1235       }
1236     }
1238     // Record the `exposure` event.
1239     let tuples = [...terminalByType].sort((a, b) => a[0].localeCompare(b[0]));
1240     let exposure = {
1241       results: tuples.map(t => t[0]).join(","),
1242       terminal: tuples.map(t => t[1]).join(","),
1243     };
1244     this._controller.logger.debug("Recording exposure event", exposure);
1245     Glean.urlbar.exposure.record(exposure);
1247     // Submit the `urlbar-keyword-exposure` ping if any keyword exposure events
1248     // were recorded above.
1249     if (keywordExposureRecorded) {
1250       GleanPings.urlbarKeywordExposure.submit();
1251     }
1252   }
1254   /**
1255    * Registers an exposure for a result in the current urlbar session, if the
1256    * result should record exposure telemetry. All exposures that are added
1257    * during a session are recorded in the `exposure` event at the end of the
1258    * session. If keyword exposures are enabled, they will be recorded in the
1259    * `urlbar-keyword-exposure` ping at the end of the session as well. Exposures
1260    * are cleared at the end of each session and do not carry over.
1261    *
1262    * @param {UrlbarResult} result An exposure will be added for this result if
1263    *        exposures are enabled for its result type.
1264    * @param {UrlbarQueryContext} queryContext The query context associated with
1265    *        the result.
1266    */
1267   addExposure(result, queryContext) {
1268     if (result.exposureTelemetry) {
1269       this.#addExposureInternal(result, queryContext);
1270     }
1271   }
1273   /**
1274    * Registers a tentative exposure for a result in the current urlbar session.
1275    * Exposures that remain tentative at the end of the session are discarded and
1276    * are not recorded in the exposure event.
1277    *
1278    * @param {UrlbarResult} result A tentative exposure will be added for this
1279    *        result if exposures are enabled for its result type.
1280    * @param {UrlbarQueryContext} queryContext The query context associated with
1281    *        the result.
1282    */
1283   addTentativeExposure(result, queryContext) {
1284     if (result.exposureTelemetry) {
1285       this.#tentativeExposures.push({
1286         weakResult: Cu.getWeakReference(result),
1287         weakQueryContext: Cu.getWeakReference(queryContext),
1288       });
1289     }
1290   }
1292   /**
1293    * Converts all tentative exposures that were added and not yet discarded
1294    * during the current urlbar session into actual exposures that will be
1295    * recorded at the end of the session.
1296    */
1297   acceptTentativeExposures() {
1298     if (this.#tentativeExposures.length) {
1299       for (let { weakResult, weakQueryContext } of this.#tentativeExposures) {
1300         let result = weakResult.get();
1301         let queryContext = weakQueryContext.get();
1302         if (result && queryContext) {
1303           this.#addExposureInternal(result, queryContext);
1304         }
1305       }
1306       this.#tentativeExposures = [];
1307     }
1308   }
1310   /**
1311    * Discards all tentative exposures that were added and not yet accepted
1312    * during the current urlbar session.
1313    */
1314   discardTentativeExposures() {
1315     if (this.#tentativeExposures.length) {
1316       this.#tentativeExposures = [];
1317     }
1318   }
1320   #addExposureInternal(result, queryContext) {
1321     // If we haven't added an exposure for this result, add it now. The view can
1322     // add exposures for the same results again and again due to the nature of
1323     // its update process, but we should record at most one exposure per result.
1324     if (!this.#exposureResults.has(result)) {
1325       this.#exposureResults.add(result);
1326       let resultType = lazy.UrlbarUtils.searchEngagementTelemetryType(result);
1327       this.#exposures.push({
1328         resultType,
1329         weakResult: Cu.getWeakReference(result),
1330         keyword:
1331           !queryContext.isPrivate &&
1332           lazy.UrlbarPrefs.get("keywordExposureResults").has(resultType)
1333             ? queryContext.trimmedLowerCaseSearchString
1334             : null,
1335       });
1336     }
1337   }
1339   #getInteractionType(
1340     method,
1341     startEventInfo,
1342     searchSource,
1343     searchWords,
1344     searchMode
1345   ) {
1346     if (searchMode?.entry === "topsites_newtab") {
1347       return "topsite_search";
1348     }
1350     let interaction = startEventInfo.interactionType;
1351     if (
1352       (interaction === "returned" || interaction === "restarted") &&
1353       this._isRefined(new Set(searchWords), this.#previousSearchWordsSet)
1354     ) {
1355       interaction = "refined";
1356     }
1358     if (searchSource === "urlbar-persisted") {
1359       switch (interaction) {
1360         case "returned": {
1361           interaction = "persisted_search_terms";
1362           break;
1363         }
1364         case "restarted":
1365         case "refined": {
1366           interaction = `persisted_search_terms_${interaction}`;
1367           break;
1368         }
1369       }
1370     }
1372     if (
1373       (method === "engagement" &&
1374         lazy.UrlbarPrefs.isPersistedSearchTermsEnabled()) ||
1375       method === "abandonment"
1376     ) {
1377       this.#previousSearchWordsSet = new Set(searchWords);
1378     } else if (method === "engagement") {
1379       this.#previousSearchWordsSet = null;
1380     }
1382     return interaction;
1383   }
1385   #getSearchMode(searchMode) {
1386     if (!searchMode) {
1387       return "";
1388     }
1390     if (searchMode.engineName) {
1391       return "search_engine";
1392     }
1394     const source = lazy.UrlbarUtils.LOCAL_SEARCH_MODES.find(
1395       m => m.source == searchMode.source
1396     )?.telemetryLabel;
1397     return source ?? "unknown";
1398   }
1400   _parseSearchString(searchString) {
1401     let numChars = searchString.length.toString();
1402     let searchWords = searchString
1403       .substring(0, lazy.UrlbarUtils.MAX_TEXT_LENGTH)
1404       .trim()
1405       .split(lazy.UrlbarTokenizer.REGEXP_SPACES)
1406       .filter(t => t);
1407     let numWords = searchWords.length.toString();
1409     return {
1410       numChars,
1411       numWords,
1412       searchWords,
1413     };
1414   }
1416   /**
1417    * Checks whether re-searched by modifying some of the keywords from the
1418    * previous search. Concretely, returns true if there is intersects between
1419    * both keywords, otherwise returns false. Also, returns false even if both
1420    * are the same.
1421    *
1422    * @param {Set} currentSet The current keywords.
1423    * @param {Set} [previousSet] The previous keywords.
1424    * @returns {boolean} true if current searching are refined.
1425    */
1426   _isRefined(currentSet, previousSet = null) {
1427     if (!previousSet) {
1428       return false;
1429     }
1431     const intersect = (setA, setB) => {
1432       let count = 0;
1433       for (const word of setA.values()) {
1434         if (setB.has(word)) {
1435           count += 1;
1436         }
1437       }
1438       return count > 0 && count != setA.size;
1439     };
1441     return (
1442       intersect(currentSet, previousSet) || intersect(previousSet, currentSet)
1443     );
1444   }
1446   _getStartInteractionType(event, searchString) {
1447     if (event.interactionType) {
1448       return event.interactionType;
1449     } else if (event.type == "input") {
1450       return lazy.UrlbarUtils.isPasteEvent(event) ? "pasted" : "typed";
1451     } else if (event.type == "drop") {
1452       return "dropped";
1453     } else if (searchString) {
1454       return "typed";
1455     }
1456     return "topsites";
1457   }
1459   /**
1460    * Resets the currently tracked user-generated event that was registered via
1461    * start(), so it won't be recorded.  If there's no tracked event, this is a
1462    * no-op.
1463    */
1464   discard() {
1465     if (this._startEventInfo) {
1466       this._startEventInfo = null;
1467       this._discarded = true;
1468     }
1469   }
1471   /**
1472    * Extracts a telemetry type from a result and the element being interacted
1473    * with for event telemetry.
1474    *
1475    * @param {object} result The element to analyze.
1476    * @param {Element} element The element to analyze.
1477    * @returns {string} a string type for the telemetry event.
1478    */
1479   typeFromElement(result, element) {
1480     if (!element) {
1481       return "none";
1482     }
1483     if (element.dataset.command == "help") {
1484       return result?.type == lazy.UrlbarUtils.RESULT_TYPE.TIP
1485         ? "tiphelp"
1486         : "help";
1487     }
1488     if (element.dataset.command == "dismiss") {
1489       return "block";
1490     }
1491     // Now handle the result.
1492     return lazy.UrlbarUtils.telemetryTypeFromResult(result);
1493   }
1495   /**
1496    * Reset the internal state. This function is used for only when testing.
1497    */
1498   reset() {
1499     this.#previousSearchWordsSet = null;
1500   }
1502   #PING_PREFS = {
1503     maxRichResults: Glean.urlbar.prefMaxResults,
1504     "quicksuggest.dataCollection.enabled":
1505       Glean.urlbar.prefSuggestDataCollection,
1506     "suggest.quicksuggest.nonsponsored": Glean.urlbar.prefSuggestNonsponsored,
1507     "suggest.quicksuggest.sponsored": Glean.urlbar.prefSuggestSponsored,
1508     "suggest.topsites": Glean.urlbar.prefSuggestTopsites,
1509   };
1511   #beginObservingPingPrefs() {
1512     for (const p of Object.keys(this.#PING_PREFS)) {
1513       this.onPrefChanged(p);
1514     }
1515     lazy.UrlbarPrefs.addObserver(this);
1516   }
1518   onPrefChanged(pref) {
1519     const metric = this.#PING_PREFS[pref];
1520     if (metric) {
1521       metric.set(lazy.UrlbarPrefs.get(pref));
1522     }
1523   }
1525   // Used to avoid re-entering `record()`.
1526   #handlingRecord = false;
1528   #previousSearchWordsSet = null;
1530   // These properties are used to record exposure telemetry. For general info on
1531   // exposures, see [1]. For keyword exposures, see [2] and [3]. Here's a
1532   // summary of how a result flows through the exposure telemetry code path:
1533   //
1534   // 1. The view makes the result's row visible and calls `addExposure()` for
1535   //    it. (Or, if the result is a hidden exposure, the view would have made
1536   //    its row visible.)
1537   // 2. If exposure telemetry should be recorded for the result, we push its
1538   //    telemetry type and some other data onto `#exposures`. If keyword
1539   //    exposures are enabled, we also include the search string in the data. We
1540   //    use `#exposureResults` to efficiently make sure we add at most one
1541   //    exposure per result to `#exposures`.
1542   // 3. At the end of a session, we record a single `exposure` event that
1543   //    includes all unique telemetry types in the `#exposures` data. We also
1544   //    record one `keyword_exposure` event per search string in the data, with
1545   //    each search string recorded as the `keyword` for that exposure. We clear
1546   //    `#exposures` so that the data does not carry over into the next session.
1547   //
1548   // `#tentativeExposures` supports hidden exposures and is necessary due to how
1549   // the view updates itself. When the view creates a row for a normal result,
1550   // the row can start out hidden, and it's only unhidden if the query finishes
1551   // without being canceled. When the view encounters a hidden-exposure result,
1552   // it doesn't actually create a row for it, but if the hypothetical row would
1553   // have started out visible, the view will call `addExposure()`. If the
1554   // hypothetical row would have started out hidden, the view will call
1555   // `addTentativeExposure()` and we'll add the result to `#tentativeExposures`.
1556   // Once the query finishes and the view unhides its rows, it will call
1557   // `acceptTentativeExposures()`, finally registering exposures for all such
1558   // hidden-exposure results in the query. If instead the query is canceled, the
1559   // view will remove its hidden rows and call `discardTentativeExposures()`.
1560   //
1561   // [1] https://dictionary.telemetry.mozilla.org/apps/firefox_desktop/metrics/urlbar_exposure
1562   // [2] https://dictionary.telemetry.mozilla.org/apps/firefox_desktop/pings/urlbar-keyword-exposure
1563   // [3] https://dictionary.telemetry.mozilla.org/apps/firefox_desktop/metrics/urlbar_keyword_exposure
1564   #exposures = [];
1565   #tentativeExposures = [];
1566   #exposureResults = new WeakSet();