UrlbarProviderQuickSuggest.sys.mjs (20679B)
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 { 6 UrlbarProvider, 7 UrlbarUtils, 8 } from "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs"; 9 10 const lazy = {}; 11 12 ChromeUtils.defineESModuleGetters(lazy, { 13 ContentRelevancyManager: 14 "resource://gre/modules/ContentRelevancyManager.sys.mjs", 15 QuickSuggest: "moz-src:///browser/components/urlbar/QuickSuggest.sys.mjs", 16 SearchUtils: "moz-src:///toolkit/components/search/SearchUtils.sys.mjs", 17 UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs", 18 UrlbarResult: "moz-src:///browser/components/urlbar/UrlbarResult.sys.mjs", 19 UrlbarSearchUtils: 20 "moz-src:///browser/components/urlbar/UrlbarSearchUtils.sys.mjs", 21 }); 22 23 // Used for suggestions that don't otherwise have a score. 24 const DEFAULT_SUGGESTION_SCORE = 0.2; 25 26 /** 27 * A provider that returns a suggested url to the user based on what 28 * they have currently typed so they can navigate directly. 29 */ 30 export class UrlbarProviderQuickSuggest extends UrlbarProvider { 31 /** 32 * @returns {Values<typeof UrlbarUtils.PROVIDER_TYPE>} 33 */ 34 get type() { 35 return UrlbarUtils.PROVIDER_TYPE.NETWORK; 36 } 37 38 /** 39 * @returns {number} 40 * The default score for suggestions that don't otherwise have one. All 41 * suggestions require scores so they can be ranked. Scores are numeric 42 * values in the range [0, 1]. 43 */ 44 static get DEFAULT_SUGGESTION_SCORE() { 45 return DEFAULT_SUGGESTION_SCORE; 46 } 47 48 /** 49 * Whether this provider should be invoked for the given context. 50 * If this method returns false, the providers manager won't start a query 51 * with this provider, to save on resources. 52 * 53 * @param {UrlbarQueryContext} queryContext The query context object 54 */ 55 async isActive(queryContext) { 56 // If the sources don't include search or the user used a restriction 57 // character other than search, don't allow any suggestions. 58 if ( 59 !queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.SEARCH) || 60 (queryContext.restrictSource && 61 queryContext.restrictSource != UrlbarUtils.RESULT_SOURCE.SEARCH) 62 ) { 63 return false; 64 } 65 66 if ( 67 !lazy.UrlbarPrefs.get("quickSuggestEnabled") || 68 queryContext.isPrivate || 69 queryContext.searchMode 70 ) { 71 return false; 72 } 73 74 // Trim only the start of the search string because a trailing space can 75 // affect the suggestions. 76 let trimmedSearchString = queryContext.searchString.trimStart(); 77 78 // Per product requirements, at least two characters must be typed to 79 // trigger a Suggest suggestion. Suggestion keywords should always be at 80 // least two characters long, but we check here anyway to be safe. Note we 81 // called `trimStart()` above, so we only call `trimEnd()` here. 82 if (trimmedSearchString.trimEnd().length < 2) { 83 return false; 84 } 85 this._trimmedSearchString = trimmedSearchString; 86 return true; 87 } 88 89 /** 90 * Starts querying. 91 * 92 * @param {UrlbarQueryContext} queryContext 93 * @param {(provider: UrlbarProvider, result: UrlbarResult) => void} addCallback 94 * Callback invoked by the provider to add a new result. 95 */ 96 async startQuery(queryContext, addCallback) { 97 let instance = this.queryInstance; 98 let searchString = this._trimmedSearchString; 99 100 // Fetch suggestions from all enabled backends. 101 let values = await Promise.all( 102 lazy.QuickSuggest.enabledBackends.map(backend => 103 backend.query(searchString, { queryContext }) 104 ) 105 ); 106 if (instance != this.queryInstance) { 107 return; 108 } 109 110 let suggestions = await this.#filterAndSortSuggestions(values.flat()); 111 if (instance != this.queryInstance) { 112 return; 113 } 114 115 // Convert each suggestion into a result and add it. Don't add more than 116 // `maxResults` visible results so we don't spam the muxer. 117 let remainingCount = queryContext.maxResults ?? 10; 118 for (let suggestion of suggestions) { 119 if (!remainingCount) { 120 break; 121 } 122 123 let result = await this.#makeResult(queryContext, suggestion); 124 if (instance != this.queryInstance) { 125 return; 126 } 127 if (result) { 128 let canAdd = await this.#canAddResult(result); 129 if (instance != this.queryInstance) { 130 return; 131 } 132 if (canAdd) { 133 addCallback(this, result); 134 if (!result.isHiddenExposure) { 135 remainingCount--; 136 } 137 } 138 } 139 } 140 } 141 142 async #filterAndSortSuggestions(suggestions) { 143 let requiredKeys = ["source", "provider"]; 144 let scoreMap = lazy.UrlbarPrefs.get("quickSuggestScoreMap"); 145 let suggestionsByFeature = new Map(); 146 let indexesBySuggestion = new Map(); 147 148 for (let i = 0; i < suggestions.length; i++) { 149 let suggestion = suggestions[i]; 150 151 // Discard the suggestion if it doesn't have the properties required to 152 // get the feature that manages it. Each backend should set these, so this 153 // should never happen. 154 if (!requiredKeys.every(key => suggestion[key])) { 155 this.logger.error("Suggestion is missing one or more required keys", { 156 requiredKeys, 157 suggestion, 158 }); 159 continue; 160 } 161 162 // Ensure the suggestion has a score. 163 // 164 // Step 1: Set a default score if the suggestion doesn't have one. 165 if (typeof suggestion.score != "number" || isNaN(suggestion.score)) { 166 suggestion.score = DEFAULT_SUGGESTION_SCORE; 167 } 168 169 // Step 2: Apply relevancy ranking. 170 await this.#applyRanking(suggestion); 171 172 // Step 3: Apply score overrides defined in `quickSuggestScoreMap`. It 173 // maps telemetry types to scores. 174 if (scoreMap) { 175 let telemetryType = this.#getSuggestionTelemetryType(suggestion); 176 if (scoreMap.hasOwnProperty(telemetryType)) { 177 let score = parseFloat(scoreMap[telemetryType]); 178 if (!isNaN(score)) { 179 suggestion.score = score; 180 } 181 } 182 } 183 184 // Save some state used below to build the final list of suggestions. 185 // `feature` will be null if the suggestion isn't managed by one. 186 let feature = lazy.QuickSuggest.getFeatureBySource(suggestion); 187 let featureSuggestions = suggestionsByFeature.get(feature); 188 if (!featureSuggestions) { 189 featureSuggestions = []; 190 suggestionsByFeature.set(feature, featureSuggestions); 191 } 192 featureSuggestions.push(suggestion); 193 indexesBySuggestion.set(suggestion, i); 194 } 195 196 // Let each feature filter its suggestions. 197 let filteredSuggestions = ( 198 await Promise.all( 199 [...suggestionsByFeature].map(([feature, featureSuggestions]) => 200 feature 201 ? feature.filterSuggestions(featureSuggestions) 202 : Promise.resolve(featureSuggestions) 203 ) 204 ) 205 ).flat(); 206 207 // Sort the suggestions. When scores are equal, sort by original index to 208 // ensure a stable sort. 209 filteredSuggestions.sort((a, b) => { 210 return ( 211 b.score - a.score || 212 indexesBySuggestion.get(a) - indexesBySuggestion.get(b) 213 ); 214 }); 215 216 return filteredSuggestions; 217 } 218 219 onImpression(state, queryContext, controller, resultsAndIndexes, details) { 220 // Build a map from each feature to its results in `resultsAndIndexes`. 221 let resultsByFeature = resultsAndIndexes.reduce((memo, { result }) => { 222 let feature = lazy.QuickSuggest.getFeatureByResult(result); 223 if (feature) { 224 let featureResults = memo.get(feature); 225 if (!featureResults) { 226 featureResults = []; 227 memo.set(feature, featureResults); 228 } 229 featureResults.push(result); 230 } 231 return memo; 232 }, new Map()); 233 234 // Notify each feature with its results. 235 for (let [feature, featureResults] of resultsByFeature) { 236 feature.onImpression( 237 state, 238 queryContext, 239 controller, 240 featureResults, 241 details 242 ); 243 } 244 } 245 246 onEngagement(queryContext, controller, details) { 247 let { result } = details; 248 249 // Delegate to the result's feature if there is one. 250 let feature = lazy.QuickSuggest.getFeatureByResult(result); 251 if (feature) { 252 feature.onEngagement( 253 queryContext, 254 controller, 255 details, 256 this._trimmedSearchString 257 ); 258 return; 259 } 260 261 // Otherwise, handle commands. The dismiss, manage, and help commands are 262 // supported for results without features. Dismissal is the only one we need 263 // to handle here since urlbar handles the others. 264 if (details.selType == "dismiss" && result.payload.isBlockable) { 265 // `dismissResult()` is async but there's no need to await it here. 266 lazy.QuickSuggest.dismissResult(result); 267 controller.removeResult(result); 268 } 269 } 270 271 onSearchSessionEnd(queryContext, controller, details) { 272 for (let backend of lazy.QuickSuggest.enabledBackends) { 273 backend.onSearchSessionEnd(queryContext, controller, details); 274 } 275 } 276 277 /** 278 * This is called only for dynamic result types. 279 * 280 * @param {UrlbarResult} result The result whose view will be updated. 281 * @returns {object} An object of view template. 282 */ 283 getViewTemplate(result) { 284 return lazy.QuickSuggest.getFeatureByResult(result)?.getViewTemplate?.( 285 result 286 ); 287 } 288 289 /** 290 * This is called only for dynamic result types, when the urlbar view updates 291 * the view of one of the results of the provider. It should return an object 292 * describing the view update. 293 * 294 * @param {UrlbarResult} result The result whose view will be updated. 295 * @returns {object} An object describing the view update. 296 */ 297 getViewUpdate(result) { 298 return lazy.QuickSuggest.getFeatureByResult(result)?.getViewUpdate?.( 299 result 300 ); 301 } 302 303 /** 304 * Gets the list of commands that should be shown in the result menu for a 305 * given result from the provider. All commands returned by this method should 306 * be handled by implementing `onEngagement()` with the possible exception of 307 * commands automatically handled by the urlbar, like "help". 308 * 309 * @param {UrlbarResult} result 310 * The menu will be shown for this result. 311 */ 312 getResultCommands(result) { 313 return lazy.QuickSuggest.getFeatureByResult(result)?.getResultCommands?.( 314 result 315 ); 316 } 317 318 /** 319 * Returns the telemetry type for a suggestion. A telemetry type uniquely 320 * identifies a type of suggestion as well as the kind of `UrlbarResult` 321 * instances created from it. 322 * 323 * @param {object} suggestion 324 * A suggestion from a Suggest backend. 325 * @returns {string} 326 * The telemetry type. If the suggestion type is managed by a feature, the 327 * telemetry type is retrieved from it. Otherwise the suggestion type is 328 * assumed to come from Merino, and `suggestion.provider` (the Merino 329 * provider name) is returned. 330 */ 331 #getSuggestionTelemetryType(suggestion) { 332 let feature = lazy.QuickSuggest.getFeatureBySource(suggestion); 333 if (feature) { 334 return feature.getSuggestionTelemetryType(suggestion); 335 } 336 return suggestion.provider; 337 } 338 339 async #makeResult(queryContext, suggestion) { 340 let result = null; 341 let feature = lazy.QuickSuggest.getFeatureBySource(suggestion); 342 if (!feature) { 343 result = this.#makeUnmanagedResult(queryContext, suggestion); 344 } else if (feature.isEnabled) { 345 result = await feature.makeResult( 346 queryContext, 347 suggestion, 348 this._trimmedSearchString 349 ); 350 } 351 352 if (!result) { 353 return null; 354 } 355 356 // Set important properties that every Suggest result should have. 357 358 // `source` indicates the Suggest backend the suggestion came from. 359 result.payload.source = suggestion.source; 360 361 // `provider` depends on `source` and generally indicates the type of 362 // Suggest suggestion. See `QuickSuggest.getFeatureBySource()`. 363 result.payload.provider = suggestion.provider; 364 365 // Set `isSponsored` unless the feature already did. 366 if (!result.payload.hasOwnProperty("isSponsored")) { 367 result.payload.isSponsored = !!feature?.isSuggestionSponsored(suggestion); 368 } 369 370 // For most Suggest results, the result type recorded in urlbar telemetry is 371 // `${source}_${telemetryType}` (the payload values). 372 result.payload.telemetryType = this.#getSuggestionTelemetryType(suggestion); 373 374 // Handle icons here unless the feature already did. 375 result.payload.icon ||= suggestion.icon; 376 result.payload.iconBlob ||= suggestion.icon_blob; 377 378 switch (suggestion.source) { 379 case "merino": 380 // Dismissals of Merino suggestions are recorded in the Rust component's 381 // database. Each dismissal is recorded as a string value called a key. 382 // If Merino includes `dismissal_key` in the suggestion, use that as the 383 // key. Otherwise we'll use its URL. See `QuickSuggest.dismissResult()`. 384 if ( 385 suggestion.dismissal_key && 386 !result.payload.hasOwnProperty("dismissalKey") 387 ) { 388 result.payload.dismissalKey = suggestion.dismissal_key; 389 } 390 break; 391 case "rust": 392 // `suggestionObject` is passed back to the Rust component on dismissal. 393 // See `QuickSuggest.dismissResult()`. 394 result.payload.suggestionObject = suggestion; 395 // `suggestionType` is defined only for dynamic Rust suggestions and is 396 // the dynamic type. Don't add an undefined property to other payloads. 397 if (suggestion.suggestionType) { 398 result.payload.suggestionType = suggestion.suggestionType; 399 } 400 break; 401 } 402 403 // Set the appropriate suggested index and related properties unless the 404 // feature did it already. 405 if (!result.hasSuggestedIndex) { 406 if (result.isBestMatch) { 407 result.isRichSuggestion = true; 408 result.richSuggestionIconSize ||= 52; 409 result.suggestedIndex = 1; 410 } else { 411 result.isSuggestedIndexRelativeToGroup = true; 412 if (!result.payload.isSponsored) { 413 result.suggestedIndex = lazy.UrlbarPrefs.get( 414 "quickSuggestNonSponsoredIndex" 415 ); 416 } else if ( 417 lazy.UrlbarPrefs.get("showSearchSuggestionsFirst") && 418 (await this.queryInstance 419 .getProvider("UrlbarProviderSearchSuggestions") 420 ?.isActive(queryContext, this.queryInstance.controller)) && 421 lazy.UrlbarSearchUtils.getDefaultEngine( 422 queryContext.isPrivate 423 ).supportsResponseType(lazy.SearchUtils.URL_TYPE.SUGGEST_JSON) 424 ) { 425 // Allow sponsored suggestions to be shown somewhere other than the 426 // bottom of the Suggest section (-1, the `else` branch below) only if 427 // search suggestions are shown first, the search suggestions provider 428 // is active for the current context (it will not be active if search 429 // suggestions are disabled, among other reasons), and the default 430 // engine supports suggestions. 431 result.suggestedIndex = lazy.UrlbarPrefs.get( 432 "quickSuggestSponsoredIndex" 433 ); 434 } else { 435 result.suggestedIndex = -1; 436 } 437 } 438 } 439 440 return result; 441 } 442 443 /** 444 * Returns a new result for an unmanaged suggestion. An "unmanaged" suggestion 445 * is a suggestion without a feature. 446 * 447 * Merino is the only backend allowed to serve unmanaged suggestions, and its 448 * "top_picks" provider is the only Merino provider recognized by this method. 449 * For everything else, the method returns null. 450 * 451 * @param {UrlbarQueryContext} queryContext 452 * The query context. 453 * @param {object} suggestion 454 * The suggestion. 455 * @returns {UrlbarResult|null} 456 * A new result for the suggestion or null if the suggestion is not from 457 * the Merino "top_picks" provider. 458 */ 459 #makeUnmanagedResult(queryContext, suggestion) { 460 if (suggestion.source != "merino" || suggestion.provider != "top_picks") { 461 return null; 462 } 463 464 // Note that Merino uses snake_case keys. 465 let payload = { 466 url: suggestion.url, 467 originalUrl: suggestion.original_url, 468 isSponsored: !!suggestion.is_sponsored, 469 isBlockable: true, 470 isManageable: true, 471 }; 472 473 let titleHighlights; 474 if (suggestion.full_keyword) { 475 let { value, highlights } = 476 lazy.QuickSuggest.getFullKeywordTitleAndHighlights({ 477 tokens: queryContext.tokens, 478 highlightType: UrlbarUtils.HIGHLIGHT.SUGGESTED, 479 fullKeyword: suggestion.full_keyword, 480 title: suggestion.title, 481 }); 482 payload.title = value; 483 titleHighlights = highlights; 484 } else { 485 payload.title = suggestion.title; 486 titleHighlights = UrlbarUtils.HIGHLIGHT.TYPED; 487 payload.shouldShowUrl = true; 488 } 489 490 return new lazy.UrlbarResult({ 491 type: UrlbarUtils.RESULT_TYPE.URL, 492 source: UrlbarUtils.RESULT_SOURCE.SEARCH, 493 isBestMatch: !!suggestion.is_top_pick, 494 payload, 495 highlights: { 496 title: titleHighlights, 497 }, 498 }); 499 } 500 501 /** 502 * Cancels the current query. 503 */ 504 cancelQuery() { 505 for (let backend of lazy.QuickSuggest.enabledBackends) { 506 backend.cancelQuery(); 507 } 508 } 509 510 /** 511 * Applies relevancy ranking to a suggestion by updating its score. 512 * 513 * @param {object} suggestion 514 * The suggestion to be ranked. 515 */ 516 async #applyRanking(suggestion) { 517 let oldScore = suggestion.score; 518 519 let mode = lazy.UrlbarPrefs.get("quickSuggestRankingMode"); 520 switch (mode) { 521 case "random": 522 suggestion.score = Math.random(); 523 break; 524 case "interest": 525 await this.#updateScoreByRelevance(suggestion); 526 break; 527 case "default": 528 default: 529 // Do nothing. 530 return; 531 } 532 533 this.logger.debug("Applied ranking to suggestion score", { 534 mode, 535 oldScore, 536 newScore: suggestion.score.toFixed(3), 537 }); 538 } 539 540 /** 541 * Update score by interest-based relevance scoring. The final score is a mean 542 * between the interest-based score and the default static score, which means 543 * if the former is 0 or less than the latter, the combined score will be less 544 * than the static score. 545 * 546 * @param {object} suggestion 547 * The suggestion to be ranked. 548 */ 549 async #updateScoreByRelevance(suggestion) { 550 if (!suggestion.categories?.length) { 551 return; 552 } 553 554 let score; 555 try { 556 score = await lazy.ContentRelevancyManager.score(suggestion.categories); 557 } catch (error) { 558 Glean.suggestRelevance.status.failure.add(1); 559 this.logger.error("Error updating suggestion score", error); 560 return; 561 } 562 563 Glean.suggestRelevance.status.success.add(1); 564 let oldScore = suggestion.score; 565 suggestion.score = (oldScore + score) / 2; 566 Glean.suggestRelevance.outcome[ 567 suggestion.score >= oldScore ? "boosted" : "decreased" 568 ].add(1); 569 } 570 571 /** 572 * Returns whether a given result can be added for a query, assuming the 573 * provider itself should be active. 574 * 575 * @param {UrlbarResult} result 576 * The result to check. 577 * @returns {Promise<boolean>} 578 * Whether the result can be added. 579 */ 580 async #canAddResult(result) { 581 // Discard the result if it's not managed by a feature and its sponsored 582 // state isn't allowed. 583 // 584 // This isn't necessary when the result is managed because in that case: If 585 // its feature is disabled, we didn't create a result in the first place; if 586 // its feature is enabled, we delegate responsibility to it for either 587 // creating or not creating its results. 588 // 589 // Also note that it's possible for suggestion types to be considered 590 // neither sponsored nor nonsponsored. In other words, the decision to add 591 // them or not does not depend on the prefs in this conditional. Such types 592 // should always be managed. Exposure suggestions are an example. 593 let feature = lazy.QuickSuggest.getFeatureByResult(result); 594 if ( 595 !feature && 596 (!lazy.UrlbarPrefs.get("suggest.quicksuggest.all") || 597 (result.payload.isSponsored && 598 !lazy.UrlbarPrefs.get("suggest.quicksuggest.sponsored"))) 599 ) { 600 return false; 601 } 602 603 // Discard the result if it was dismissed. 604 if (await lazy.QuickSuggest.isResultDismissed(result)) { 605 this.logger.debug("Suggestion dismissed, not adding it"); 606 return false; 607 } 608 609 return true; 610 } 611 612 async _test_applyRanking(suggestion) { 613 await this.#applyRanking(suggestion); 614 } 615 }