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";
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",
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",
31 * The address bar controller handles queries from the address bar, obtains
32 * results and returns them to the UI for display.
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:
37 * - onQueryStarted(queryContext)
38 * - onQueryResults(queryContext)
39 * - onQueryCancelled(queryContext)
40 * - onQueryFinished(queryContext)
41 * - onQueryResultRemoved(index)
45 export class UrlbarController {
47 * Initialises the class. The manager may be overridden here, this is for
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.
58 constructor(options = {}) {
60 throw new Error("Missing options: input");
62 if (!options.input.window) {
63 throw new Error("input is missing 'window' property.");
66 !options.input.window.location ||
67 options.input.window.location.href != AppConstants.BROWSER_CHROME_URL
69 throw new Error("input.window should be an actual browser window.");
71 if (!("isPrivate" in options.input)) {
72 throw new Error("input.isPrivate must be set.");
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(
85 options.eventTelemetryCategory
88 ChromeUtils.defineLazyGetter(this, "logger", () =>
89 lazy.UrlbarUtils.getLogger({ prefix: "Controller" })
98 * Hooks up the controller with a view.
100 * @param {UrlbarView} view
101 * The UrlbarView instance associated with this controller.
108 * Takes a query context and starts the query based on the user input.
110 * @param {UrlbarQueryContext} queryContext The query details.
111 * @returns {UrlbarQueryContext}
112 * The updated query context.
114 async startQuery(queryContext) {
115 // Cancel any running query.
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.
139 contextWrapper === this._lastQueryContextWrapper &&
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);
152 * Cancels an in-progress query. Note, queries may continue running if they
153 * can't be cancelled.
156 // If the query finished already, don't handle cancel.
157 if (!this._lastQueryContextWrapper || this._lastQueryContextWrapper.done) {
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);
172 * Receives results from a query.
174 * @param {UrlbarQueryContext} queryContext The query details.
176 receiveResults(queryContext) {
177 if (queryContext.lastResultCount < 1 && queryContext.results.length >= 1) {
178 TelemetryStopwatch.finish(TELEMETRY_1ST_RESULT, queryContext);
180 if (queryContext.lastResultCount < 6 && queryContext.results.length >= 6) {
181 TelemetryStopwatch.finish(TELEMETRY_6_FIRST_RESULTS, queryContext);
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.
191 // The first time we receive results try to connect to the heuristic
193 this.speculativeConnect(
194 queryContext.results[0],
200 this.notify(NOTIFICATIONS.QUERY_RESULTS, queryContext);
201 // Update lastResultCount after notifying, so the view can use it.
202 queryContext.lastResultCount = queryContext.results.length;
206 * Adds a listener for query actions and results.
208 * @param {object} listener The listener to add.
209 * @throws {TypeError} Throws if the listener is not an object.
211 addQueryListener(listener) {
212 if (!listener || typeof listener != "object") {
213 throw new TypeError("Expected listener to be an object");
215 this._listeners.add(listener);
219 * Removes a query listener.
221 * @param {object} listener The listener to add.
223 removeQueryListener(listener) {
224 this._listeners.delete(listener);
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.
234 * @param {KeyboardEvent} event
235 * The DOM KeyboardEvent.
237 * Returns true if the event should move the caret instead of opening the
240 keyEventMovesCaret(event) {
241 if (this.view.isOpen) {
244 if (AppConstants.platform != "macosx" && AppConstants.platform != "linux") {
247 let isArrowUp = event.keyCode == KeyEvent.DOM_VK_UP;
248 let isArrowDown = event.keyCode == KeyEvent.DOM_VK_DOWN;
249 if (!isArrowUp && !isArrowDown) {
252 let start = this.input.selectionStart;
253 let end = this.input.selectionEnd;
256 (isArrowUp && start > 0) ||
257 (isArrowDown && end < this.input.value.length)
265 * Receives keyboard events from the input and handles those that should
266 * navigate within the view or pick the currently selected item.
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.
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.
284 (event.key == "n" || event.key == "p")
287 this.view.selectBy(1, { reverse: event.key == "p" });
289 event.preventDefault();
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.
299 (event.keyCode === KeyEvent.DOM_VK_UP ||
300 event.keyCode === KeyEvent.DOM_VK_DOWN)
305 let { queryContext } = this._lastQueryContextWrapper;
306 let handled = this.view.oneOffSearchButtons.handleKeyDown(
308 this.view.visibleRowCount,
309 this.view.allowEmptySelection,
310 queryContext.searchString
317 switch (event.keyCode) {
318 case KeyEvent.DOM_VK_ESCAPE:
320 if (this.view.isOpen) {
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
331 this.browserWindow.gBrowser.selectedBrowser.focus();
333 this.input.handleRevert();
336 event.preventDefault();
338 case KeyEvent.DOM_VK_SPACE:
339 if (!this.view.shouldSpaceActivateSelectedElement()) {
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"}`);
346 this.input.handleCommand(event);
348 event.preventDefault();
350 case KeyEvent.DOM_VK_TAB: {
351 if (!this.view.visibleRowCount) {
352 // Leave it to the default behaviour if there are not results.
356 // Change the tab behavior when urlbar view is open.
358 lazy.UrlbarPrefs.get("scotchBonnet.enableOverride") &&
365 this.view.selectedElement ==
366 this.view.getFirstSelectableElement()) ||
368 this.view.selectedElement == this.view.getLastSelectableElement())
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();
378 !this.view.selectedElement &&
379 this.input.focusedViaMousedown
381 if (event.shiftKey) {
382 this.#focusOnUnifiedSearchButton();
384 this.view.selectBy(1, {
385 userPressedTab: true,
388 event.preventDefault();
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
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 ||
406 this.input.getAttribute("pageproxystate") != "valid");
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) &&
413 allowTabbingThroughResults
416 this.userSelectionBehavior = "tab";
417 this.view.selectBy(1, {
418 reverse: event.shiftKey,
419 userPressedTab: true,
422 event.preventDefault();
426 case KeyEvent.DOM_VK_PAGE_DOWN:
427 case KeyEvent.DOM_VK_PAGE_UP:
431 // eslint-disable-next-lined no-fallthrough
432 case KeyEvent.DOM_VK_DOWN:
433 case KeyEvent.DOM_VK_UP:
437 if (this.view.isOpen) {
439 this.userSelectionBehavior = "arrow";
441 event.keyCode == KeyEvent.DOM_VK_PAGE_DOWN ||
442 event.keyCode == KeyEvent.DOM_VK_PAGE_UP
443 ? lazy.UrlbarUtils.PAGE_UP_DOWN_DELTA
447 event.keyCode == KeyEvent.DOM_VK_UP ||
448 event.keyCode == KeyEvent.DOM_VK_PAGE_UP,
453 if (this.keyEventMovesCaret(event)) {
457 this.userSelectionBehavior = "arrow";
458 this.input.startQuery({
459 searchString: this.input.value,
464 event.preventDefault();
466 case KeyEvent.DOM_VK_RIGHT:
467 case KeyEvent.DOM_VK_END:
468 this.input.maybeConfirmSearchModeFromResult({
473 case KeyEvent.DOM_VK_LEFT:
474 case KeyEvent.DOM_VK_HOME:
475 this.view.removeAccessibleFocus();
477 case KeyEvent.DOM_VK_BACK_SPACE:
479 this.input.searchMode &&
480 this.input.selectionStart == 0 &&
481 this.input.selectionEnd == 0 &&
484 this.input.searchMode = null;
485 this.input.view.oneOffSearchButtons.selectedButton = null;
486 this.input.startQuery({
487 allowAutofill: false,
492 case KeyEvent.DOM_VK_DELETE:
493 if (!this.view.isOpen) {
496 if (event.shiftKey) {
497 if (!executeAction || this.#dismissSelectedResult(event)) {
498 event.preventDefault();
500 } else if (executeAction) {
501 this.userSelectionBehavior = "none";
508 * Tries to initialize a speculative connection on a result.
509 * Speculative connections are only supported for a subset of all the results.
511 * Speculative connect to:
512 * - Search engine heuristic results
514 * - http/https results
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.
520 speculativeConnect(result, context, reason) {
521 // Never speculative connect in private contexts.
522 if (!this.input || context.isPrivate || !context.results.length) {
527 case "resultsadded": {
528 // We should connect to an heuristic result, if it exists.
530 (result == context.results[0] && result.heuristic) ||
533 if (result.type == lazy.UrlbarUtils.RESULT_TYPE.SEARCH) {
534 // Speculative connect only if search suggestions are enabled.
536 lazy.UrlbarPrefs.get("suggest.searches") &&
537 lazy.UrlbarPrefs.get("browser.search.suggest.enabled")
539 let engine = Services.search.getEngineByName(
540 result.payload.engine
542 lazy.UrlbarUtils.setupSpeculativeConnection(
547 } else if (result.autofill) {
548 const { url } = lazy.UrlbarUtils.getUrlFromResult(result);
553 lazy.UrlbarUtils.setupSpeculativeConnection(
562 const { url } = lazy.UrlbarUtils.getUrlFromResult(result);
567 // On mousedown, connect only to http/https urls.
568 if (url.startsWith("http")) {
569 lazy.UrlbarUtils.setupSpeculativeConnection(url, this.browserWindow);
574 throw new Error("Invalid speculative connection reason");
580 * Stores the selection behavior that the user has used to select a result.
582 * @param {"arrow"|"tab"|"none"} behavior
583 * The behavior the user used.
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") {
591 this._userSelectionBehavior = behavior;
595 * Records details of the selected result in telemetry. We only record the
596 * selection behavior, type and index.
598 * @param {Event} event
599 * The event which triggered the result to be selected.
600 * @param {UrlbarResult} result
601 * The selected result.
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;
612 lazy.BrowserSearchTelemetry.recordSearchSuggestionSelectionMethod(
616 this._userSelectionBehavior
623 // Do not modify existing telemetry types. To add a new type:
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.
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.
638 result.providerName == "UrlbarProviderTopSites"
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) =>
646 Glean.urlbarPickedSearchmode[name]?.[resultIndex].add(1);
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()`.
656 * @param {Event} event
657 * The event that triggered dismissal.
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.
663 #dismissSelectedResult(event) {
664 if (!this._lastQueryContextWrapper) {
665 console.error("Cannot dismiss selected result, last query not present");
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.
677 let result = this.input.view.selectedResult;
678 if (!result || result.heuristic) {
682 this.engagementEvent.record(event, {
685 searchString: queryContext.searchString,
692 * Removes a result from the current query context and notifies listeners.
693 * Heuristic results cannot be removed.
695 * @param {UrlbarResult} result
696 * The result to remove.
698 removeResult(result) {
699 if (!result || result.heuristic) {
703 if (!this._lastQueryContextWrapper) {
704 console.error("Cannot remove result, last query not present");
707 let { queryContext } = this._lastQueryContextWrapper;
709 let index = queryContext.results.indexOf(result);
711 console.error("Failed to find the selected result in the results");
715 queryContext.results.splice(index, 1);
716 this.notify(NOTIFICATIONS.QUERY_RESULT_REMOVED, index);
720 * Set the query context cache.
722 * @param {UrlbarQueryContext} queryContext the object to cache.
724 setLastQueryContextCache(queryContext) {
725 this._lastQueryContextWrapper = { queryContext };
729 * Clear the previous query context cache.
731 clearLastQueryContextCache() {
732 this._lastQueryContextWrapper = null;
736 * Notifies listeners of results.
738 * @param {string} name Name of the notification.
739 * @param {object} params Parameters to pass with the notification.
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") {
746 listener[name](...params);
754 #focusOnUnifiedSearchButton() {
755 this.input.toggleAttribute("unifiedsearchbutton-available", true);
757 const switcher = this.input.document.getElementById(
758 "urlbar-searchmode-switcher"
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);
767 this.input.addEventListener("blur", this.input);
768 switcher.addEventListener(
771 switcher.removeAttribute("tabindex");
773 this.input.hasAttribute("focused") &&
774 !e.relatedTarget?.closest("#urlbar")
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,
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.
801 class TelemetryEvent {
802 constructor(controller, category) {
803 this._controller = controller;
804 this._category = category;
805 this.#beginObservingPingPrefs();
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.
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}
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(
832 this._startEventInfo.searchString = searchString;
834 this._startEventInfo.interactionType == "returned" &&
836 this._startEventInfo.searchString[0] != searchString[0])
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";
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.
853 if (!this._category) {
857 console.error("Must always provide an event");
860 const validEvents = [
870 if (!validEvents.includes(event.type)) {
871 console.error("Can't start recording from event type: ", event.type);
875 this._startEventInfo = {
876 timeStamp: event.timeStamp || Cu.now(),
877 interactionType: this._getStartInteractionType(event, searchString),
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.
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.
893 * @param {event} [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.
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) {
922 // This should never throw, or it may break the urlbar.
924 this.#handlingRecord = true;
925 this.#internalRecord(event, details);
927 console.error("Could not record event: ", ex);
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;
943 #internalRecord(event, details) {
944 const startEventInfo = this._startEventInfo;
946 if (!this._category || !startEventInfo) {
951 startEventInfo.interactionType != "pasted" &&
952 startEventInfo.interactionType != "dropped"
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");
958 throw new Error("Invalid event details: " + details);
962 let skipLegacyTelemetry = false;
965 startEventInfo.interactionType == "dropped" ? "drop_go" : "paste_go";
966 } else if (event.type == "blur") {
968 } else if (event.type == "tabswitch") {
969 action = "tab_switch";
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"
977 action = details.element.dataset.command;
978 skipLegacyTelemetry = true;
979 } else if (details.selType == "dismiss") {
981 skipLegacyTelemetry = true;
982 } else if (MouseEvent.isInstance(event)) {
983 action = event.target.classList.contains("urlbar-go-button")
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(
1001 ) || details.result?.payload.providesSearchMode
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
1012 details.provider = details.result?.providerName;
1013 details.selIndex = details.result?.rowIndex ?? -1;
1015 let { queryContext } = this._controller._lastQueryContextWrapper || {};
1017 this._recordSearchEngagementTelemetry(
1026 provider: details.provider,
1027 searchSource: details.searchSource,
1028 searchMode: details.searchMode,
1029 selectedElement: details.element,
1030 selIndex: details.selIndex,
1031 selType: details.selType,
1035 if (!details.isSessionOngoing) {
1036 this.#recordExposures(queryContext);
1039 if (!skipLegacyTelemetry) {
1040 if (method == "engagement") {
1041 Glean.urlbar.engagementCount.add(1);
1043 Glean.urlbar.abandonmentCount.add(1);
1046 let firstVisibleResult = this._controller.view?.visibleResults?.[0];
1048 method === "engagement" &&
1049 firstVisibleResult?.autofill &&
1050 firstVisibleResult?.type == lazy.UrlbarUtils.RESULT_TYPE.URL
1052 // Record autofill impressions upon engagement.
1053 const type = lazy.UrlbarUtils.telemetryTypeFromResult(
1057 Glean.urlbarImpression[type]?.add(1);
1062 this._controller.manager.notifyEngagementChange(
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);
1075 _recordSearchEngagementTelemetry(
1092 const browserWindow = this._controller.browserWindow;
1094 if (searchSource === "urlbar-handoff") {
1097 browserWindow.isBlankPageURL(browserWindow.gBrowser.currentURI.spec)
1099 sap = "urlbar_newtab";
1100 } else if (browserWindow.gBrowser.currentURI.schemeIs("moz-extension")) {
1101 sap = "urlbar_addonpage";
1104 searchMode = searchMode ?? this._controller.input.searchMode;
1106 // Distinguish user typed search strings from persisted search terms.
1107 const interaction = this.#getInteractionType(
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))
1120 let results = currentResults
1121 .map(r => lazy.UrlbarUtils.searchEngagementTelemetryType(r))
1123 let actions = currentResults
1124 .map((r, i) => lazy.UrlbarUtils.searchEngagementTelemetryAction(r, i))
1127 const search_engine_default_id = Services.search.defaultEngine.telemetryId;
1130 if (method === "engagement") {
1131 let selected_result = lazy.UrlbarUtils.searchEngagementTelemetryType(
1132 currentResults[selIndex],
1136 if (selType == "action") {
1137 let actionKey = lazy.UrlbarUtils.searchEngagementTelemetryAction(
1138 currentResults[selIndex],
1141 selected_result = `action_${actionKey}`;
1144 if (selected_result === "input_field" && !this._controller.view?.isOpen) {
1156 n_results: numResults,
1157 selected_position: selIndex + 1,
1161 selType === "help" || selType === "dismiss" ? selType : action,
1162 search_engine_default_id,
1167 } else if (method === "abandonment") {
1169 abandonment_type: action,
1175 n_results: numResults,
1176 search_engine_default_id,
1181 } else if (method === "impression") {
1189 n_results: numResults,
1190 search_engine_default_id,
1196 console.error(`Unknown telemetry event method: ${method}`);
1200 this._controller.logger.info(`${method} event:`, eventInfo);
1202 Glean.urlbar[method].record(eventInfo);
1205 #recordExposures(queryContext) {
1206 let exposures = this.#exposures;
1207 this.#exposures = [];
1208 this.#tentativeExposures = [];
1209 if (!exposures.length) {
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();
1219 this.#exposureResults.delete(result);
1221 let endResults = result.isHiddenExposure
1222 ? queryContext.results
1223 : this._controller.view?.visibleResults;
1224 terminal = endResults?.includes(result);
1227 terminalByType.set(resultType, terminal);
1229 // Record the `keyword_exposure` event if there's a 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;
1238 // Record the `exposure` event.
1239 let tuples = [...terminalByType].sort((a, b) => a[0].localeCompare(b[0]));
1241 results: tuples.map(t => t[0]).join(","),
1242 terminal: tuples.map(t => t[1]).join(","),
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();
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.
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
1267 addExposure(result, queryContext) {
1268 if (result.exposureTelemetry) {
1269 this.#addExposureInternal(result, queryContext);
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.
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
1283 addTentativeExposure(result, queryContext) {
1284 if (result.exposureTelemetry) {
1285 this.#tentativeExposures.push({
1286 weakResult: Cu.getWeakReference(result),
1287 weakQueryContext: Cu.getWeakReference(queryContext),
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.
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);
1306 this.#tentativeExposures = [];
1311 * Discards all tentative exposures that were added and not yet accepted
1312 * during the current urlbar session.
1314 discardTentativeExposures() {
1315 if (this.#tentativeExposures.length) {
1316 this.#tentativeExposures = [];
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({
1329 weakResult: Cu.getWeakReference(result),
1331 !queryContext.isPrivate &&
1332 lazy.UrlbarPrefs.get("keywordExposureResults").has(resultType)
1333 ? queryContext.trimmedLowerCaseSearchString
1339 #getInteractionType(
1346 if (searchMode?.entry === "topsites_newtab") {
1347 return "topsite_search";
1350 let interaction = startEventInfo.interactionType;
1352 (interaction === "returned" || interaction === "restarted") &&
1353 this._isRefined(new Set(searchWords), this.#previousSearchWordsSet)
1355 interaction = "refined";
1358 if (searchSource === "urlbar-persisted") {
1359 switch (interaction) {
1361 interaction = "persisted_search_terms";
1366 interaction = `persisted_search_terms_${interaction}`;
1373 (method === "engagement" &&
1374 lazy.UrlbarPrefs.isPersistedSearchTermsEnabled()) ||
1375 method === "abandonment"
1377 this.#previousSearchWordsSet = new Set(searchWords);
1378 } else if (method === "engagement") {
1379 this.#previousSearchWordsSet = null;
1385 #getSearchMode(searchMode) {
1390 if (searchMode.engineName) {
1391 return "search_engine";
1394 const source = lazy.UrlbarUtils.LOCAL_SEARCH_MODES.find(
1395 m => m.source == searchMode.source
1397 return source ?? "unknown";
1400 _parseSearchString(searchString) {
1401 let numChars = searchString.length.toString();
1402 let searchWords = searchString
1403 .substring(0, lazy.UrlbarUtils.MAX_TEXT_LENGTH)
1405 .split(lazy.UrlbarTokenizer.REGEXP_SPACES)
1407 let numWords = searchWords.length.toString();
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
1422 * @param {Set} currentSet The current keywords.
1423 * @param {Set} [previousSet] The previous keywords.
1424 * @returns {boolean} true if current searching are refined.
1426 _isRefined(currentSet, previousSet = null) {
1431 const intersect = (setA, setB) => {
1433 for (const word of setA.values()) {
1434 if (setB.has(word)) {
1438 return count > 0 && count != setA.size;
1442 intersect(currentSet, previousSet) || intersect(previousSet, currentSet)
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") {
1453 } else if (searchString) {
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
1465 if (this._startEventInfo) {
1466 this._startEventInfo = null;
1467 this._discarded = true;
1472 * Extracts a telemetry type from a result and the element being interacted
1473 * with for event telemetry.
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.
1479 typeFromElement(result, element) {
1483 if (element.dataset.command == "help") {
1484 return result?.type == lazy.UrlbarUtils.RESULT_TYPE.TIP
1488 if (element.dataset.command == "dismiss") {
1491 // Now handle the result.
1492 return lazy.UrlbarUtils.telemetryTypeFromResult(result);
1496 * Reset the internal state. This function is used for only when testing.
1499 this.#previousSearchWordsSet = null;
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,
1511 #beginObservingPingPrefs() {
1512 for (const p of Object.keys(this.#PING_PREFS)) {
1513 this.onPrefChanged(p);
1515 lazy.UrlbarPrefs.addObserver(this);
1518 onPrefChanged(pref) {
1519 const metric = this.#PING_PREFS[pref];
1521 metric.set(lazy.UrlbarPrefs.get(pref));
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:
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.
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()`.
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
1565 #tentativeExposures = [];
1566 #exposureResults = new WeakSet();