UrlbarProviderSearchSuggestions.sys.mjs (21739B)
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 /** 6 * This module exports a provider that offers search engine suggestions. 7 */ 8 9 import { 10 SkippableTimer, 11 UrlbarProvider, 12 UrlbarUtils, 13 } from "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs"; 14 15 const lazy = {}; 16 17 ChromeUtils.defineESModuleGetters(lazy, { 18 DEFAULT_FORM_HISTORY_PARAM: 19 "moz-src:///toolkit/components/search/SearchSuggestionController.sys.mjs", 20 FormHistory: "resource://gre/modules/FormHistory.sys.mjs", 21 SearchSuggestionController: 22 "moz-src:///toolkit/components/search/SearchSuggestionController.sys.mjs", 23 UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs", 24 UrlbarProviderTopSites: 25 "moz-src:///browser/components/urlbar/UrlbarProviderTopSites.sys.mjs", 26 UrlbarResult: "moz-src:///browser/components/urlbar/UrlbarResult.sys.mjs", 27 UrlbarSearchUtils: 28 "moz-src:///browser/components/urlbar/UrlbarSearchUtils.sys.mjs", 29 UrlbarTokenizer: 30 "moz-src:///browser/components/urlbar/UrlbarTokenizer.sys.mjs", 31 UrlUtils: "resource://gre/modules/UrlUtils.sys.mjs", 32 }); 33 34 /** 35 * @import {SearchSuggestionController} from "moz-src:///toolkit/components/search/SearchSuggestionController.sys.mjs" 36 */ 37 38 const RESULT_MENU_COMMANDS = { 39 TRENDING_BLOCK: "trendingblock", 40 TRENDING_HELP: "help", 41 }; 42 43 const TRENDING_HELP_URL = 44 Services.urlFormatter.formatURLPref("app.support.baseURL") + 45 "google-trending-searches-on-awesomebar"; 46 47 /** 48 * Returns whether the passed in string looks like a url. 49 * 50 * @param {string} str 51 * The string to check. 52 * @param {boolean} [ignoreAlphanumericHosts] 53 * If true, don't consider a string with an alphanumeric host to be a URL. 54 * @returns {boolean} 55 * True if the query looks like a URL. 56 */ 57 function looksLikeUrl(str, ignoreAlphanumericHosts = false) { 58 // Single word including special chars. 59 return ( 60 !lazy.UrlUtils.REGEXP_SPACES.test(str) && 61 (["/", "@", ":", "["].some(c => str.includes(c)) || 62 (ignoreAlphanumericHosts 63 ? /^([\[\]A-Z0-9-]+\.){3,}[^.]+$/i.test(str) 64 : str.includes("."))) 65 ); 66 } 67 68 /** 69 * Class used to create the provider. 70 */ 71 export class UrlbarProviderSearchSuggestions extends UrlbarProvider { 72 constructor() { 73 super(); 74 } 75 76 /** 77 * @returns {Values<typeof UrlbarUtils.PROVIDER_TYPE>} 78 */ 79 get type() { 80 return UrlbarUtils.PROVIDER_TYPE.NETWORK; 81 } 82 83 /** 84 * Whether this provider should be invoked for the given context. 85 * If this method returns false, the providers manager won't start a query 86 * with this provider, to save on resources. 87 * 88 * @param {UrlbarQueryContext} queryContext The query context object. 89 */ 90 async isActive(queryContext) { 91 // If the sources don't include search or the user used a restriction 92 // character other than search, don't allow any suggestions. 93 if ( 94 !queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.SEARCH) || 95 (queryContext.restrictSource && 96 queryContext.restrictSource != UrlbarUtils.RESULT_SOURCE.SEARCH) 97 ) { 98 return false; 99 } 100 101 // No suggestions for empty search strings, unless we are restricting to 102 // search or showing trending suggestions. 103 if ( 104 !queryContext.trimmedSearchString && 105 !this._isTokenOrRestrictionPresent(queryContext) && 106 !this.#shouldFetchTrending(queryContext) 107 ) { 108 return false; 109 } 110 111 if (!this._allowSuggestions(queryContext)) { 112 return false; 113 } 114 115 let wantsLocalSuggestions = 116 lazy.UrlbarPrefs.get("maxHistoricalSearchSuggestions") && 117 queryContext.trimmedSearchString; 118 119 return ( 120 !!wantsLocalSuggestions || this._allowRemoteSuggestions(queryContext) 121 ); 122 } 123 124 /** 125 * Returns whether the user typed a token alias or restriction token, or is in 126 * search mode. We use this value to override the pref to disable search 127 * suggestions in the Urlbar. 128 * 129 * @param {UrlbarQueryContext} queryContext The query context object. 130 * @returns {boolean} True if the user typed a token alias or search 131 * restriction token. 132 */ 133 _isTokenOrRestrictionPresent(queryContext) { 134 return ( 135 queryContext.searchString.startsWith("@") || 136 (queryContext.restrictSource && 137 queryContext.restrictSource == UrlbarUtils.RESULT_SOURCE.SEARCH) || 138 queryContext.tokens.some( 139 t => t.type == lazy.UrlbarTokenizer.TYPE.RESTRICT_SEARCH 140 ) || 141 (queryContext.searchMode && 142 queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.SEARCH)) 143 ); 144 } 145 146 /** 147 * Returns whether suggestions in general are allowed for a given query 148 * context. If this returns false, then we shouldn't fetch either form 149 * history or remote suggestions. 150 * 151 * @param {UrlbarQueryContext} queryContext The query context object. 152 * @returns {boolean} True if suggestions in general are allowed and false if 153 * not. 154 */ 155 _allowSuggestions(queryContext) { 156 if ( 157 // If the user typed a restriction token or token alias, we ignore the 158 // pref to disable suggestions in the Urlbar. 159 (queryContext.sapName == "urlbar" && 160 !lazy.UrlbarPrefs.get("suggest.searches") && 161 !this._isTokenOrRestrictionPresent(queryContext)) || 162 !lazy.UrlbarPrefs.get("browser.search.suggest.enabled") || 163 (queryContext.isPrivate && 164 !lazy.UrlbarPrefs.get("browser.search.suggest.enabled.private")) 165 ) { 166 return false; 167 } 168 return true; 169 } 170 171 /** 172 * Returns whether remote suggestions are allowed for a given query context. 173 * 174 * @param {object} queryContext The query context object 175 * @param {string} [searchString] The effective search string without 176 * restriction tokens or aliases. Defaults to the context searchString. 177 * @returns {boolean} True if remote suggestions are allowed and false if not. 178 */ 179 _allowRemoteSuggestions( 180 queryContext, 181 searchString = queryContext.searchString 182 ) { 183 // This is checked by `queryContext.allowRemoteResults` below, but we can 184 // short-circuit that call with the `_isTokenOrRestrictionPresent` block 185 // before that. Make sure we don't allow remote suggestions if this is set. 186 if (queryContext.prohibitRemoteResults) { 187 return false; 188 } 189 190 // Allow remote suggestions if trending suggestions are enabled. 191 if (this.#shouldFetchTrending(queryContext)) { 192 return true; 193 } 194 195 if (!searchString.trim()) { 196 return false; 197 } 198 199 // Skip all remaining checks and allow remote suggestions at this point if 200 // the user used a token alias or restriction token. We want "@engine query" 201 // to return suggestions from the engine. We'll return early from startQuery 202 // if the query doesn't match an alias. 203 if (this._isTokenOrRestrictionPresent(queryContext)) { 204 return true; 205 } 206 207 // If the user is just adding on to a query that previously didn't return 208 // many remote suggestions, we are unlikely to get any more results. 209 if ( 210 !!this._lastLowResultsSearchSuggestion && 211 searchString.length > this._lastLowResultsSearchSuggestion.length && 212 searchString.startsWith(this._lastLowResultsSearchSuggestion) 213 ) { 214 return false; 215 } 216 217 return queryContext.allowRemoteResults( 218 searchString, 219 lazy.UrlbarPrefs.get("trending.featureGate") 220 ); 221 } 222 223 /** 224 * Starts querying. 225 * 226 * @param {UrlbarQueryContext} queryContext 227 * @param {(provider: UrlbarProvider, result: UrlbarResult) => void} addCallback 228 * Callback invoked by the provider to add a new result. 229 */ 230 async startQuery(queryContext, addCallback) { 231 let instance = this.queryInstance; 232 233 let aliasEngine = await this._maybeGetAlias(queryContext); 234 if (!aliasEngine) { 235 // Autofill matches queries starting with "@" to token alias engines. 236 // If the string starts with "@", but an alias engine is not yet 237 // matched, then autofill might still be filtering token alias 238 // engine results. We don't want to mix search suggestions with those 239 // engine results, so we return early. See bug 1551049 comment 1 for 240 // discussion on how to improve this behavior. 241 if (queryContext.searchString.startsWith("@")) { 242 return; 243 } 244 } 245 246 let query = aliasEngine 247 ? aliasEngine.query 248 : UrlbarUtils.substringAt( 249 queryContext.searchString, 250 queryContext.tokens[0]?.value || "" 251 ).trim(); 252 253 let leadingRestrictionToken = null; 254 if ( 255 lazy.UrlbarTokenizer.isRestrictionToken(queryContext.tokens[0]) && 256 (queryContext.tokens.length > 1 || 257 queryContext.tokens[0].type == 258 lazy.UrlbarTokenizer.TYPE.RESTRICT_SEARCH) 259 ) { 260 leadingRestrictionToken = queryContext.tokens[0].value; 261 } 262 263 // Strip a leading search restriction char, because we prepend it to text 264 // when the search shortcut is used and it's not user typed. Don't strip 265 // other restriction chars, so that it's possible to search for things 266 // including one of those (e.g. "c#"). 267 if (leadingRestrictionToken === lazy.UrlbarTokenizer.RESTRICT.SEARCH) { 268 query = UrlbarUtils.substringAfter(query, leadingRestrictionToken).trim(); 269 } 270 271 // Find our search engine. It may have already been set with an alias. 272 let engine; 273 if (aliasEngine) { 274 engine = aliasEngine.engine; 275 } else if (queryContext.searchMode?.engineName) { 276 engine = lazy.UrlbarSearchUtils.getEngineByName( 277 queryContext.searchMode.engineName 278 ); 279 } else { 280 engine = lazy.UrlbarSearchUtils.getDefaultEngine(queryContext.isPrivate); 281 } 282 283 if (!engine) { 284 return; 285 } 286 287 let alias = (aliasEngine && aliasEngine.alias) || ""; 288 let results = await this.#fetchSearchSuggestions( 289 queryContext, 290 engine, 291 query, 292 alias 293 ); 294 295 if (!results || instance != this.queryInstance) { 296 return; 297 } 298 299 for (let result of results) { 300 addCallback(this, result); 301 } 302 } 303 304 /** 305 * Gets the provider's priority. 306 * 307 * @param {UrlbarQueryContext} queryContext The query context object 308 * @returns {number} The provider's priority for the given query. 309 */ 310 getPriority(queryContext) { 311 if (this.#shouldFetchTrending(queryContext)) { 312 return lazy.UrlbarProviderTopSites.PRIORITY; 313 } 314 return 0; 315 } 316 317 /** 318 * Cancels a running query. 319 */ 320 cancelQuery() { 321 if (this.#suggestionsController) { 322 this.#suggestionsController.stop(); 323 } 324 } 325 326 /** 327 * Returns the menu commands to be shown for trending results. 328 * 329 * @param {UrlbarResult} result 330 * The result to get menu comands for. 331 */ 332 getResultCommands(result) { 333 if (result.payload.trending) { 334 return /** @type {UrlbarResultCommand[]} */ ([ 335 { 336 name: RESULT_MENU_COMMANDS.TRENDING_BLOCK, 337 l10n: { id: "urlbar-result-menu-trending-dont-show" }, 338 }, 339 { 340 name: "separator", 341 }, 342 { 343 name: RESULT_MENU_COMMANDS.TRENDING_HELP, 344 l10n: { id: "urlbar-result-menu-trending-why" }, 345 }, 346 ]); 347 } 348 return undefined; 349 } 350 351 onEngagement(queryContext, controller, details) { 352 let { result } = details; 353 354 if (details.selType == "dismiss") { 355 lazy.FormHistory.update({ 356 op: "remove", 357 fieldname: lazy.DEFAULT_FORM_HISTORY_PARAM, 358 value: result.payload.suggestion, 359 }).catch(error => 360 console.error(`Removing form history failed: ${error}`) 361 ); 362 controller.removeResult(result); 363 return; 364 } 365 366 switch (details.selType) { 367 case RESULT_MENU_COMMANDS.TRENDING_HELP: 368 // Handled by UrlbarInput 369 break; 370 case RESULT_MENU_COMMANDS.TRENDING_BLOCK: 371 lazy.UrlbarPrefs.set("suggest.trending", false); 372 this.#recordTrendingBlockedTelemetry(); 373 this.#replaceTrendingResultWithAcknowledgement(controller); 374 break; 375 } 376 } 377 378 /** 379 * @type {?SearchSuggestionController} 380 */ 381 #suggestionsController; 382 383 async #fetchSearchSuggestions(queryContext, engine, searchString, alias) { 384 if (!engine) { 385 return null; 386 } 387 388 if (!this.#suggestionsController) { 389 this.#suggestionsController = new lazy.SearchSuggestionController(); 390 } 391 392 // If there's a form history entry that equals the search string, the search 393 // suggestions controller will include it, and we'll make a result for it. 394 // If the heuristic result ends up being a search result, the muxer will 395 // discard the form history result since it dupes the heuristic, and the 396 // final list of results would be left with `count` - 1 form history results 397 // instead of `count`. Therefore we request `count` + 1 entries. The muxer 398 // will dedupe and limit the final form history count as appropriate. 399 let maxLocalResults = queryContext.maxResults + 1; 400 401 // Request maxResults + 1 remote suggestions for the same reason we request 402 // maxResults + 1 form history entries. 403 let allowRemote = this._allowRemoteSuggestions(queryContext, searchString); 404 let maxRemoteResults = allowRemote ? queryContext.maxResults + 1 : 0; 405 406 if (allowRemote && this.#shouldFetchTrending(queryContext)) { 407 if ( 408 queryContext.searchMode && 409 lazy.UrlbarPrefs.get("trending.maxResultsSearchMode") != -1 410 ) { 411 maxRemoteResults = lazy.UrlbarPrefs.get( 412 "trending.maxResultsSearchMode" 413 ); 414 } else if ( 415 !queryContext.searchMode && 416 lazy.UrlbarPrefs.get("trending.maxResultsNoSearchMode") != -1 417 ) { 418 maxRemoteResults = lazy.UrlbarPrefs.get( 419 "trending.maxResultsNoSearchMode" 420 ); 421 } 422 } 423 424 // See `SearchSuggestionsController.fetch` documentation for a description 425 // of `fetchData`. 426 let fetchData = await this.#suggestionsController.fetch({ 427 searchString, 428 inPrivateBrowsing: queryContext.isPrivate, 429 engine, 430 userContextId: queryContext.userContextId, 431 restrictToEngine: this._isTokenOrRestrictionPresent(queryContext), 432 dedupeRemoteAndLocal: false, 433 fetchTrending: this.#shouldFetchTrending(queryContext), 434 maxLocalResults, 435 maxRemoteResults, 436 }); 437 438 // The fetch was canceled. 439 if (!fetchData) { 440 return null; 441 } 442 443 let results = []; 444 445 // maxHistoricalSearchSuggestions used to determine the initial number of 446 // form history results, with the special case where zero means to never 447 // show form history at all. With the introduction of flexed result 448 // groups, we now use it only as a boolean: Zero means don't show form 449 // history at all (as before), non-zero means show it. 450 if (lazy.UrlbarPrefs.get("maxHistoricalSearchSuggestions")) { 451 for (let entry of fetchData.local) { 452 results.push(makeFormHistoryResult(queryContext, engine, entry)); 453 } 454 } 455 456 // If we don't return many results, then keep track of the query. If the 457 // user just adds on to the query, we won't fetch more suggestions if the 458 // query is very long since we are unlikely to get any. 459 if ( 460 allowRemote && 461 !fetchData.remote.length && 462 searchString.length > lazy.UrlbarPrefs.get("maxCharsForSearchSuggestions") 463 ) { 464 this._lastLowResultsSearchSuggestion = searchString; 465 } 466 467 // If we have only tail suggestions, we only show them if we have no other 468 // results. We need to wait for other results to arrive to avoid flickering. 469 // We will wait for this timer unless we have suggestions that don't have a 470 // tail. 471 let tailTimer = new SkippableTimer({ 472 name: "ProviderSearchSuggestions", 473 time: 100, 474 logger: this.logger, 475 }); 476 477 for (let entry of fetchData.remote) { 478 if (looksLikeUrl(entry.value)) { 479 continue; 480 } 481 482 let tail = entry.tail; 483 let tailPrefix = entry.matchPrefix; 484 485 // Skip tail suggestions if the pref is disabled. 486 if (tail && !lazy.UrlbarPrefs.get("richSuggestions.tail")) { 487 continue; 488 } 489 490 if (!tail) { 491 await tailTimer.fire().catch(ex => this.logger.error(ex)); 492 } 493 494 try { 495 let query = searchString.trim(); 496 let suggestion = entry.value; 497 let title; 498 if (tail && entry.tailOffsetIndex >= 0) { 499 title = tail; 500 } else if (suggestion) { 501 title = suggestion; 502 } else { 503 title = query; 504 } 505 506 results.push( 507 new lazy.UrlbarResult({ 508 type: UrlbarUtils.RESULT_TYPE.SEARCH, 509 source: UrlbarUtils.RESULT_SOURCE.SEARCH, 510 isRichSuggestion: !!entry.icon, 511 payload: { 512 title, 513 engine: engine.name, 514 suggestion, 515 lowerCaseSuggestion: entry.value.toLocaleLowerCase(), 516 tailPrefix, 517 tail, 518 tailOffsetIndex: tail ? entry.tailOffsetIndex : undefined, 519 keyword: alias || undefined, 520 trending: entry.trending, 521 description: entry.description || undefined, 522 query, 523 icon: !entry.value ? await engine.getIconURL() : entry.icon, 524 helpUrl: entry.trending ? TRENDING_HELP_URL : undefined, 525 }, 526 highlights: { 527 engine: UrlbarUtils.HIGHLIGHT.TYPED, 528 suggestion: UrlbarUtils.HIGHLIGHT.SUGGESTED, 529 tail: UrlbarUtils.HIGHLIGHT.SUGGESTED, 530 keyword: UrlbarUtils.HIGHLIGHT.TYPED, 531 }, 532 }) 533 ); 534 } catch (err) { 535 this.logger.error(err); 536 continue; 537 } 538 } 539 540 await tailTimer.promise; 541 return results; 542 } 543 544 /** 545 * @typedef {object} EngineAlias 546 * 547 * @property {nsISearchEngine} engine 548 * The search engine 549 * @property {string} alias 550 * The search engine's alias 551 * @property {string} query 552 * The remainder of the search engine string after the alias 553 */ 554 555 /** 556 * Searches for an engine alias given the queryContext. 557 * 558 * @param {UrlbarQueryContext} queryContext 559 * The query context object. 560 * @returns {Promise<EngineAlias?>} aliasEngine 561 * A representation of the aliased engine. Null if there's no match. 562 */ 563 async _maybeGetAlias(queryContext) { 564 if (queryContext.searchMode) { 565 // If we're in search mode, don't try to parse an alias at all. 566 return null; 567 } 568 569 let possibleAlias = queryContext.tokens[0]?.value; 570 // "@" on its own is handled by UrlbarProviderTokenAliasEngines and returns 571 // a list of every available token alias. 572 if (!possibleAlias || possibleAlias == "@") { 573 return null; 574 } 575 576 let query = UrlbarUtils.substringAfter( 577 queryContext.searchString, 578 possibleAlias 579 ); 580 581 // Match an alias only when it has a space after it. If there's no trailing 582 // space, then continue to treat it as part of the search string. 583 if (!lazy.UrlUtils.REGEXP_SPACES_START.test(query)) { 584 return null; 585 } 586 587 // Check if the user entered an engine alias directly. 588 let engineMatch = 589 await lazy.UrlbarSearchUtils.engineForAlias(possibleAlias); 590 if (engineMatch) { 591 return { 592 engine: engineMatch, 593 alias: possibleAlias, 594 query: query.trim(), 595 }; 596 } 597 598 return null; 599 } 600 601 /** 602 * Whether we should show trending suggestions. These are shown when the 603 * user enters a specific engines searchMode when enabled, the 604 * seperate `requireSearchMode` pref controls whether they are visible 605 * when the urlbar is first opened without any search mode. 606 * 607 * @param {UrlbarQueryContext} queryContext 608 * The query context object. 609 * @returns {boolean} 610 * Whether we should fetch trending results. 611 */ 612 #shouldFetchTrending(queryContext) { 613 return !!( 614 queryContext.searchString == "" && 615 queryContext.sapName != "searchbar" && 616 lazy.UrlbarPrefs.get("trending.featureGate") && 617 lazy.UrlbarPrefs.get("suggest.trending") && 618 (queryContext.searchMode || 619 !lazy.UrlbarPrefs.get("trending.requireSearchMode")) 620 ); 621 } 622 623 /* 624 * Send telemetry to indicating trending results have been hidden. 625 */ 626 #recordTrendingBlockedTelemetry() { 627 Glean.urlbarTrending.block.add(1); 628 } 629 630 /* 631 * Remove all the trending results and show an acknowledgement that the 632 * trending suggestions have been turned off. 633 */ 634 #replaceTrendingResultWithAcknowledgement(controller) { 635 let resultsToRemove = controller.view.visibleResults.filter( 636 result => result.payload.trending 637 ); 638 if (resultsToRemove.length) { 639 // Show an acknowledgement tip for the first result. 640 resultsToRemove[0].acknowledgeDismissalL10n = { 641 id: "urlbar-trending-dismissal-acknowledgment", 642 }; 643 } 644 // Remove results in reverse order so the acknowledgment tip isn't removed. 645 resultsToRemove.reverse(); 646 resultsToRemove.forEach(result => controller.removeResult(result)); 647 } 648 } 649 650 function makeFormHistoryResult(queryContext, engine, entry) { 651 return new lazy.UrlbarResult({ 652 type: UrlbarUtils.RESULT_TYPE.SEARCH, 653 source: UrlbarUtils.RESULT_SOURCE.HISTORY, 654 payload: { 655 engine: engine.name, 656 suggestion: entry.value, 657 title: entry.value, 658 lowerCaseSuggestion: entry.value.toLocaleLowerCase(), 659 isBlockable: true, 660 blockL10n: { id: "urlbar-result-menu-remove-from-history" }, 661 helpUrl: 662 Services.urlFormatter.formatURLPref("app.support.baseURL") + 663 "awesome-bar-result-menu", 664 }, 665 highlights: { 666 suggestion: UrlbarUtils.HIGHLIGHT.SUGGESTED, 667 }, 668 }); 669 }