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