QuickSuggest.sys.mjs (41209B)
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 const lazy = {}; 6 7 ChromeUtils.defineESModuleGetters(lazy, { 8 NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", 9 Preferences: "resource://gre/modules/Preferences.sys.mjs", 10 Region: "resource://gre/modules/Region.sys.mjs", 11 TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", 12 UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs", 13 UrlbarUtils: "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs", 14 }); 15 16 // See the `QuickSuggest.SETTINGS_UI` jsdoc below. 17 const SETTINGS_UI = Object.freeze({ 18 FULL: 0, 19 NONE: 1, 20 // Only settings relevant to offline will be shown. Settings that pertain to 21 // online will be hidden. 22 OFFLINE_ONLY: 2, 23 }); 24 25 const EN_LOCALES = ["en-CA", "en-GB", "en-US", "en-ZA"]; 26 27 /** 28 * @typedef {[string[], boolean|number]} RegionLocaleDefault 29 * The first element is an array of locales (e.g. "en-US"), the second is the 30 * value of the preference. 31 */ 32 33 /** 34 * @typedef {object} SuggestPrefsRecord 35 * @property {Record<string, RegionLocaleDefault>} [defaultValues] 36 * This controls the home regions and locales where Suggest and each of its 37 * subfeatures will be enabled. If the pref should be initialized on the 38 * default branch depending on the user's home region and locale, then this 39 * should be set to an object where each entry maps a region name to a tuple 40 * `[locales, prefValue]`. `locales` is an array of strings and `prefValue` is 41 * the value that should be set when the region and locale match the user's 42 * region and locale. If the user's region and locale do not match any of the 43 * entries in `defaultValues`, then the pref will retain its default value as 44 * defined in `firefox.js`. 45 * @property {string} [nimbusVariableIfExposedInUi] 46 * If the pref is exposed in the settings UI and it's a fallback for a Nimbus 47 * variable, then this should be set to the variable's name. See point 3 in 48 * the comment in `#initPrefs()` for more. 49 */ 50 51 /** 52 * This defines the home regions and locales where Suggest will be enabled. 53 * Suggest will remain disabled for regions and locales not defined here. More 54 * generally it defines important Suggest prefs that require special handling. 55 * Each entry in this object defines a pref name and information about that 56 * pref. Pref names are relative to `browser.urlbar.` The value in each entry is 57 * an object with the following properties: 58 * 59 * @type {{[key: string]: SuggestPrefsRecord}} 60 * {object} defaultValues 61 */ 62 const SUGGEST_PREFS = Object.freeze({ 63 // Prefs related to Suggest overall 64 // 65 // Please update `test_quicksuggest_defaultPrefs.js` when you change these. 66 "quicksuggest.enabled": { 67 defaultValues: { 68 DE: [["de", ...EN_LOCALES], true], 69 FR: [["fr", ...EN_LOCALES], true], 70 GB: [EN_LOCALES, true], 71 IT: [["it", ...EN_LOCALES], true], 72 US: [EN_LOCALES, true], 73 }, 74 }, 75 "quicksuggest.settingsUi": { 76 defaultValues: { 77 DE: [["de"], SETTINGS_UI.OFFLINE_ONLY], 78 FR: [["fr"], SETTINGS_UI.OFFLINE_ONLY], 79 GB: [EN_LOCALES, SETTINGS_UI.OFFLINE_ONLY], 80 IT: [["it"], SETTINGS_UI.OFFLINE_ONLY], 81 US: [EN_LOCALES, SETTINGS_UI.OFFLINE_ONLY], 82 }, 83 }, 84 "suggest.quicksuggest.all": { 85 defaultValues: { 86 DE: [["de"], true], 87 FR: [["fr"], true], 88 GB: [EN_LOCALES, true], 89 IT: [["it"], true], 90 US: [EN_LOCALES, true], 91 }, 92 }, 93 "suggest.quicksuggest.sponsored": { 94 nimbusVariableIfExposedInUi: "quickSuggestSponsoredEnabled", 95 defaultValues: { 96 DE: [["de"], true], 97 FR: [["fr"], true], 98 GB: [EN_LOCALES, true], 99 IT: [["it"], true], 100 US: [EN_LOCALES, true], 101 }, 102 }, 103 104 // Prefs related to individual features 105 // 106 // Please update `test_quicksuggest_defaultPrefs.js` when you change these. 107 "addons.featureGate": { 108 defaultValues: { 109 US: [EN_LOCALES, true], 110 }, 111 }, 112 "amp.featureGate": { 113 defaultValues: { 114 GB: [EN_LOCALES, true], 115 US: [EN_LOCALES, true], 116 }, 117 }, 118 "importantDates.featureGate": { 119 defaultValues: { 120 DE: [["de", ...EN_LOCALES], true], 121 FR: [["fr", ...EN_LOCALES], true], 122 GB: [EN_LOCALES, true], 123 IT: [["it", ...EN_LOCALES], true], 124 US: [EN_LOCALES, true], 125 }, 126 }, 127 "mdn.featureGate": { 128 defaultValues: { 129 US: [EN_LOCALES, true], 130 }, 131 }, 132 "weather.featureGate": { 133 defaultValues: { 134 DE: [["de"], true], 135 FR: [["fr"], true], 136 GB: [EN_LOCALES, true], 137 IT: [["it"], true], 138 US: [EN_LOCALES, true], 139 }, 140 }, 141 "wikipedia.featureGate": { 142 defaultValues: { 143 GB: [EN_LOCALES, true], 144 US: [EN_LOCALES, true], 145 }, 146 }, 147 "yelp.featureGate": { 148 defaultValues: { 149 US: [EN_LOCALES, true], 150 }, 151 }, 152 }); 153 154 // Suggest features classes. On init, `QuickSuggest` creates an instance of each 155 // class and keeps it in the `#featuresByName` map. See `SuggestFeature`. 156 const FEATURES = { 157 AddonSuggestions: 158 "moz-src:///browser/components/urlbar/private/AddonSuggestions.sys.mjs", 159 AmpSuggestions: 160 "moz-src:///browser/components/urlbar/private/AmpSuggestions.sys.mjs", 161 DynamicSuggestions: 162 "moz-src:///browser/components/urlbar/private/DynamicSuggestions.sys.mjs", 163 FlightStatusSuggestions: 164 "moz-src:///browser/components/urlbar/private/FlightStatusSuggestions.sys.mjs", 165 ImportantDatesSuggestions: 166 "moz-src:///browser/components/urlbar/private/ImportantDatesSuggestions.sys.mjs", 167 ImpressionCaps: 168 "moz-src:///browser/components/urlbar/private/ImpressionCaps.sys.mjs", 169 MarketSuggestions: 170 "moz-src:///browser/components/urlbar/private/MarketSuggestions.sys.mjs", 171 MDNSuggestions: 172 "moz-src:///browser/components/urlbar/private/MDNSuggestions.sys.mjs", 173 SportsSuggestions: 174 "moz-src:///browser/components/urlbar/private/SportsSuggestions.sys.mjs", 175 SuggestBackendMerino: 176 "moz-src:///browser/components/urlbar/private/SuggestBackendMerino.sys.mjs", 177 // SuggestBackendMl.sys.mjs is missing. tor-browser#44045. 178 SuggestBackendRust: 179 "moz-src:///browser/components/urlbar/private/SuggestBackendRust.sys.mjs", 180 WeatherSuggestions: 181 "moz-src:///browser/components/urlbar/private/WeatherSuggestions.sys.mjs", 182 WikipediaSuggestions: 183 "moz-src:///browser/components/urlbar/private/WikipediaSuggestions.sys.mjs", 184 YelpRealtimeSuggestions: 185 "moz-src:///browser/components/urlbar/private/YelpRealtimeSuggestions.sys.mjs", 186 YelpSuggestions: 187 "moz-src:///browser/components/urlbar/private/YelpSuggestions.sys.mjs", 188 }; 189 190 /** 191 * @import {SuggestBackendRust} from "moz-src:///browser/components/urlbar/private/SuggestBackendRust.sys.mjs" 192 * @import {SuggestFeature} from "moz-src:///browser/components/urlbar/private/SuggestFeature.sys.mjs" 193 * @import {SuggestProvider} from "moz-src:///browser/components/urlbar/private/SuggestFeature.sys.mjs" 194 * @import {ImpressionCaps} from "moz-src:///browser/components/urlbar/private/ImpressionCaps.sys.mjs" 195 */ 196 197 /** 198 * This class manages Firefox Suggest and has related helpers. 199 */ 200 class _QuickSuggest { 201 /** 202 * Test-only variable to skip telemetry environment initialisation. 203 */ 204 _testSkipTelemetryEnvironmentInit = false; 205 206 /** 207 * @returns {string} 208 * The help URL for Suggest. 209 */ 210 get HELP_URL() { 211 return ( 212 Services.urlFormatter.formatURLPref("app.support.baseURL") + 213 this.HELP_TOPIC 214 ); 215 } 216 217 /** 218 * @returns {string} 219 * The help URL topic for Suggest. 220 */ 221 get HELP_TOPIC() { 222 return "firefox-suggest"; 223 } 224 225 /** 226 * @returns {object} 227 * Possible values of the `quickSuggestSettingsUi` Nimbus variable and its 228 * fallback pref `browser.urlbar.quicksuggest.settingsUi`. When Suggest is 229 * enabled, these values determine the Suggest settings that will be visible 230 * in `about:preferences`. When Suggest is disabled, the variable/pref are 231 * ignored and Suggest settings are hidden. 232 */ 233 get SETTINGS_UI() { 234 return SETTINGS_UI; 235 } 236 237 /** 238 * @returns {Promise} 239 * Resolved when Suggest initialization finishes. 240 */ 241 get initPromise() { 242 return this.#initResolvers.promise; 243 } 244 245 /** 246 * @returns {Array} 247 * Enabled Suggest backends. 248 */ 249 get enabledBackends() { 250 // This getter may be accessed before `init()` is called, so the backends 251 // may not be registered yet. Don't assume they're non-null. 252 return [ 253 this.rustBackend, 254 this.#featuresByName.get("SuggestBackendMerino"), 255 // SuggestBackendMl.sys.mjs is missing. tor-browser#44045. 256 ].filter(b => b?.isEnabled); 257 } 258 259 /** 260 * @returns {SuggestBackendRust} 261 * The Rust backend, which manages the Rust component. 262 */ 263 get rustBackend() { 264 return this.#featuresByName.get("SuggestBackendRust"); 265 } 266 267 /** 268 * @returns {object} 269 * Global Suggest configuration stored in remote settings and ingested by 270 * the Rust component. See remote settings or the Rust component for the 271 * latest schema. 272 */ 273 get config() { 274 return this.rustBackend?.config || {}; 275 } 276 277 /** 278 * @returns {ImpressionCaps} 279 * The impression caps feature. 280 */ 281 get impressionCaps() { 282 return this.#featuresByName.get("ImpressionCaps"); 283 } 284 285 /** 286 * @returns {Set} 287 * The set of features that manage Rust suggestion types, as determined by 288 * each feature's `rustSuggestionType`. 289 */ 290 get rustFeatures() { 291 return new Set([ 292 ...this.#featuresByRustSuggestionType.values(), 293 ...this.#featuresByDynamicRustSuggestionType.values(), 294 ]); 295 } 296 297 /** 298 * @returns {Set} 299 * The set of features that manage ML suggestion types, as determined by 300 * each feature's `mlIntent`. 301 */ 302 get mlFeatures() { 303 return new Set(this.#featuresByMlIntent.values()); 304 } 305 306 get logger() { 307 if (!this._logger) { 308 this._logger = lazy.UrlbarUtils.getLogger({ prefix: "QuickSuggest" }); 309 } 310 return this._logger; 311 } 312 313 /** 314 * Initializes Suggest. It's safe to call more than once. 315 * 316 * @param {object} testOverrides 317 * This is intended for tests only. See `#initPrefs()`. 318 */ 319 async init(testOverrides = null) { 320 if (this.#initStarted) { 321 await this.initPromise; 322 return; 323 } 324 this.#initStarted = true; 325 326 // Wait for dependencies to finish before initializing prefs. 327 // 328 // (1) Whether Suggest should be enabled depends on the user's region. 329 await lazy.Region.init(); 330 331 // (2) The default-branch values of Suggest prefs that are both exposed in 332 // the UI and configurable by Nimbus depend on Nimbus. 333 await lazy.NimbusFeatures.urlbar.ready(); 334 335 // (3) `TelemetryEnvironment` records the values of some Suggest prefs. 336 if (!this._testSkipTelemetryEnvironmentInit) { 337 await lazy.TelemetryEnvironment.onInitialized(); 338 } 339 340 this.#initPrefs(testOverrides); 341 342 // Create an instance of each feature and keep it in `#featuresByName`. 343 for (let [name, uri] of Object.entries(FEATURES)) { 344 let { [name]: ctor } = ChromeUtils.importESModule(uri); 345 let feature = new ctor(); 346 this.#featuresByName.set(name, feature); 347 if (feature.merinoProvider) { 348 this.#featuresByMerinoProvider.set(feature.merinoProvider, feature); 349 } 350 if (feature.rustSuggestionType) { 351 if (feature.dynamicRustSuggestionTypes?.length) { 352 for (let t of feature.dynamicRustSuggestionTypes) { 353 this.#featuresByDynamicRustSuggestionType.set(t, feature); 354 } 355 } else { 356 this.#featuresByRustSuggestionType.set( 357 feature.rustSuggestionType, 358 feature 359 ); 360 } 361 } 362 if (feature.mlIntent) { 363 this.#featuresByMlIntent.set(feature.mlIntent, feature); 364 } 365 366 // Update the map from enabling preferences to features. 367 let prefs = feature.enablingPreferences; 368 if (prefs) { 369 for (let p of prefs) { 370 let features = this.#featuresByEnablingPrefs.get(p); 371 if (!features) { 372 features = new Set(); 373 this.#featuresByEnablingPrefs.set(p, features); 374 } 375 features.add(feature); 376 } 377 } 378 } 379 380 this.#updateAll(); 381 lazy.UrlbarPrefs.addObserver(this); 382 383 this.#initResolvers.resolve(); 384 } 385 386 /** 387 * Returns a Suggest feature by name. 388 * 389 * @param {string} name 390 * The name of the feature's JS class. 391 * @returns {SuggestFeature} 392 * The feature object, an instance of a subclass of `SuggestFeature`. 393 */ 394 getFeature(name) { 395 return this.#featuresByName.get(name); 396 } 397 398 /** 399 * Returns a Suggest feature by the ML intent name (as defined by 400 * `feature.mlIntent` and `MLSuggest`). Not all features support ML. 401 * 402 * @param {string} intent 403 * The name of an ML intent. 404 * @returns {SuggestProvider} 405 * The feature object, an instance of a subclass of `SuggestProvider`, or 406 * null if no feature corresponds to the intent. 407 */ 408 getFeatureByMlIntent(intent) { 409 return this.#featuresByMlIntent.get(intent); 410 } 411 412 /** 413 * Gets the Suggest feature that manages suggestions for urlbar result. 414 * 415 * @param {UrlbarResult} result 416 * The urlbar result. 417 * @returns {SuggestProvider} 418 * The feature instance or null if none was found. 419 */ 420 getFeatureByResult(result) { 421 return this.getFeatureBySource(result.payload); 422 } 423 424 /** 425 * Gets the Suggest feature that manages suggestions for a source and provider 426 * name. The source and provider name can be supplied from either a suggestion 427 * object or the payload of a `UrlbarResult` object. 428 * 429 * @param {object} options 430 * Options object. 431 * @param {string} options.source 432 * The suggestion source, one of: "merino", "ml", "rust" 433 * @param {string} options.provider 434 * This value depends on `source`. The possible values per source are: 435 * 436 * merino: 437 * The name of the Merino provider that serves the suggestion type 438 * ml: 439 * The name of the intent as determined by `MLSuggest` 440 * rust: 441 * The name of the suggestion type as defined in Rust 442 * 443 * @param {string} options.suggestionType 444 * This value is only relevant to dynamic Rust suggestions. It is 445 * `suggestion.suggestionType` value, the dynamic Rust suggestion type. 446 * @returns {SuggestProvider} 447 * The feature instance or null if none was found. 448 */ 449 getFeatureBySource({ source, provider, suggestionType }) { 450 switch (source) { 451 case "merino": 452 return this.#featuresByMerinoProvider.get(provider); 453 case "rust": 454 if (provider == "Dynamic" && suggestionType) { 455 let dynamicFeature = 456 this.#featuresByDynamicRustSuggestionType.get(suggestionType); 457 if (dynamicFeature) { 458 return dynamicFeature; 459 } 460 } 461 return this.#featuresByRustSuggestionType.get(provider); 462 case "ml": 463 return this.getFeatureByMlIntent(provider); 464 } 465 return null; 466 } 467 468 /** 469 * Registers a dismissal with the Rust backend. A 470 * `quicksuggest-dismissals-changed` notification topic is sent when done. 471 * 472 * @param {UrlbarResult} result 473 * The result to dismiss. 474 */ 475 async dismissResult(result) { 476 if (result.payload.source == "rust") { 477 await this.rustBackend?.dismissRustSuggestion( 478 result.payload.suggestionObject 479 ); 480 } else { 481 let key = getDismissalKey(result); 482 if (key) { 483 await this.rustBackend?.dismissByKey(key); 484 } 485 } 486 487 Services.obs.notifyObservers(null, "quicksuggest-dismissals-changed"); 488 } 489 490 /** 491 * Returns whether a dismissal is recorded for a result. 492 * 493 * @param {UrlbarResult} result 494 * The result to check. 495 * @returns {Promise<boolean>} 496 * Whether the result has been dismissed. 497 */ 498 async isResultDismissed(result) { 499 let promises = [ 500 // Check whether the result was dismissed using the old API, where 501 // dismissals were recorded as URL digests. 502 getDigest(result.payload.originalUrl || result.payload.url).then(digest => 503 this.rustBackend?.isDismissedByKey(digest) 504 ), 505 ]; 506 507 if (result.payload.source == "rust") { 508 promises.push( 509 this.rustBackend?.isRustSuggestionDismissed( 510 result.payload.suggestionObject 511 ) 512 ); 513 } else { 514 let key = getDismissalKey(result); 515 if (key) { 516 promises.push(this.rustBackend?.isDismissedByKey(key)); 517 } 518 } 519 520 let values = await Promise.all(promises); 521 return values.some(v => !!v); 522 } 523 524 /** 525 * Clears all dismissed suggestions, including individually dismissed 526 * suggestions and dismissed suggestion types. The following notification 527 * topics are sent when done, in this order: 528 * 529 * ``` 530 * quicksuggest-dismissals-changed 531 * quicksuggest-dismissals-cleared 532 * ``` 533 */ 534 async clearDismissedSuggestions() { 535 // Clear the user value of each feature's primary user-controlled pref if 536 // its value is `false`. 537 for (let [name, feature] of this.#featuresByName) { 538 for (let pref of feature.primaryUserControlledPreferences) { 539 // This should never throw, but try-catch to avoid breaking the entire 540 // loop if `UrlbarPrefs` doesn't recognize a pref in one iteration. 541 try { 542 if (pref && !lazy.UrlbarPrefs.get(pref)) { 543 lazy.UrlbarPrefs.clear(pref); 544 } 545 } catch (error) { 546 this.logger.error("Error clearing primaryEnablingPreference", { 547 "feature.name": name, 548 pref, 549 error, 550 }); 551 } 552 } 553 } 554 555 // Clear individually dismissed suggestions, which are stored in the Rust 556 // component regardless of their source. 557 await this.rustBackend?.clearDismissedSuggestions(); 558 559 Services.obs.notifyObservers(null, "quicksuggest-dismissals-changed"); 560 Services.obs.notifyObservers(null, "quicksuggest-dismissals-cleared"); 561 } 562 563 /** 564 * Whether there are any dismissed suggestions that can be cleared, including 565 * individually dismissed suggestions and dismissed suggestion types. 566 * 567 * @returns {Promise<boolean>} 568 * Whether dismissals can be cleared. 569 */ 570 async canClearDismissedSuggestions() { 571 // Return true if any feature's primary user-controlled pref is `false` on 572 // the user branch. 573 for (let [name, feature] of this.#featuresByName) { 574 for (let pref of feature.primaryUserControlledPreferences) { 575 // This should never throw, but try-catch to avoid breaking the entire 576 // loop if `UrlbarPrefs` doesn't recognize a pref in one iteration. 577 try { 578 if ( 579 pref && 580 !lazy.UrlbarPrefs.get(pref) && 581 lazy.UrlbarPrefs.hasUserValue(pref) 582 ) { 583 return true; 584 } 585 } catch (error) { 586 this.logger.error( 587 "Error accessing primaryUserControlledPreferences", 588 { 589 "feature.name": name, 590 pref, 591 error, 592 } 593 ); 594 } 595 } 596 } 597 598 // Return true if there are any individually dismissed suggestions. 599 if (await this.rustBackend?.anyDismissedSuggestions()) { 600 return true; 601 } 602 603 return false; 604 } 605 606 /** 607 * Gets the intended default Suggest prefs for a home region and locale. 608 * 609 * @param {string} region 610 * A home region, typically from `Region.home`. 611 * @param {string} locale 612 * A locale. 613 * @returns {object} 614 * An object that maps pref names to their intended default values. Pref 615 * names are relative to `browser.urlbar.`. 616 */ 617 intendedDefaultPrefs(region, locale) { 618 let regionLocalePrefs = Object.fromEntries( 619 Object.entries(SUGGEST_PREFS) 620 .map(([prefName, { defaultValues }]) => { 621 if (defaultValues?.hasOwnProperty(region)) { 622 let [enablingLocales, prefValue] = defaultValues[region]; 623 if (enablingLocales.includes(locale)) { 624 return [prefName, prefValue]; 625 } 626 } 627 return null; 628 }) 629 .filter(entry => !!entry) 630 ); 631 return { 632 ...this.#unmodifiedDefaultPrefs, 633 ...regionLocalePrefs, 634 }; 635 } 636 637 /** 638 * Called when a urlbar pref changes. 639 * 640 * @param {string} pref 641 * The name of the pref relative to `browser.urlbar`. 642 */ 643 onPrefChanged(pref) { 644 // If any feature's enabling preferences changed, update it now. 645 let features = this.#featuresByEnablingPrefs.get(pref); 646 if (!features) { 647 return; 648 } 649 650 let isPrimaryUserControlledPref = false; 651 652 for (let f of features) { 653 f.update(); 654 if (f.primaryUserControlledPreferences.includes(pref)) { 655 isPrimaryUserControlledPref = true; 656 } 657 } 658 659 if (isPrimaryUserControlledPref) { 660 Services.obs.notifyObservers(null, "quicksuggest-dismissals-changed"); 661 } 662 } 663 664 /** 665 * Called when a urlbar Nimbus variable changes. 666 * 667 * @param {string} variable 668 * The name of the variable. 669 */ 670 onNimbusChanged(variable) { 671 // If a change occurred to a variable that corresponds to a pref exposed in 672 // the UI, sync the variable to the pref on the default branch. 673 this.#syncNimbusVariablesToUiPrefs(variable); 674 675 // Update features. 676 this.#updateAll(); 677 } 678 679 /** 680 * Returns whether a given URL and result URL map back to the same original 681 * suggestion URL. 682 * 683 * Some features may create result URLs that are potentially unique per query. 684 * Typically this is done by modifying an original suggestion URL at query 685 * time, for example by adding timestamps or query-specific search params. In 686 * that case, a single original suggestion URL will map to many result URLs. 687 * This function returns whether the given URL and result URL are equal 688 * excluding any such modifications. 689 * 690 * @param {string} url 691 * The URL to check, typically from the user's history. 692 * @param {UrlbarResult} result 693 * The Suggest result. 694 * @returns {boolean} 695 * Whether `url` is equivalent to the result's URL. 696 */ 697 isUrlEquivalentToResultUrl(url, result) { 698 let feature = this.getFeatureByResult(result); 699 return feature 700 ? feature.isUrlEquivalentToResultUrl(url, result) 701 : url == result.payload.url; 702 } 703 704 /** 705 * Returns the title and highlights for suggestions that should display their 706 * full keywords. 707 * 708 * When `fullKeyword` is defined, highlighting will be applied only to it, not 709 * to the title as a whole; otherwise highlighting will not be applied at all. 710 * It's unclear if that's the intended UI spec, but historically it's how 711 * highlighting has been implemented for suggestions that should display their 712 * full keywords. 713 * 714 * @param {object} options 715 * @param {Array} options.tokens 716 * It is compatible to UrlbarQueryContext.tokens. 717 * @param {Values<typeof lazy.UrlbarUtils.HIGHLIGHT>} [options.highlightType] 718 * @param {string} [options.fullKeyword] 719 * Full keyword if there is. 720 * @param {string} options.title 721 * Suggestion title. 722 * @returns {object} { value, highlights } 723 * The value will be used for title. 724 * The highlights will be created by UrlbarUtils.getTokenMatches(). 725 */ 726 getFullKeywordTitleAndHighlights({ 727 tokens, 728 highlightType, 729 fullKeyword, 730 title, 731 }) { 732 return { 733 value: fullKeyword ? `${fullKeyword} — ${title}` : title, 734 highlights: fullKeyword 735 ? lazy.UrlbarUtils.getTokenMatches(tokens, fullKeyword, highlightType) 736 : [], 737 }; 738 } 739 740 /** 741 * @returns {object} 742 * An object that maps from Nimbus variable names to their corresponding 743 * prefs, for prefs in `SUGGEST_PREFS` with `nimbusVariableIfExposedInUi` 744 * set. 745 */ 746 get #uiPrefsByNimbusVariable() { 747 return Object.fromEntries( 748 Object.entries(SUGGEST_PREFS) 749 .map(([prefName, { nimbusVariableIfExposedInUi }]) => 750 nimbusVariableIfExposedInUi 751 ? [nimbusVariableIfExposedInUi, prefName] 752 : null 753 ) 754 .filter(entry => !!entry) 755 ); 756 } 757 758 /** 759 * Sets appropriate default-branch values of Suggest prefs depending on 760 * whether Suggest should be enabled by default. 761 * 762 * @param {object} testOverrides 763 * This is intended for tests only. Pass to force the following: 764 * `{ region, locale, migrationVersion, defaultPrefs }` 765 */ 766 #initPrefs(testOverrides = null) { 767 // Updating prefs is tricky and it's important to preserve the user's 768 // choices, so we describe the process in detail below. tl;dr: 769 // 770 // * Prefs exposed in the settings UI should be sticky. 771 // * Prefs that are both exposed in the settings UI and configurable via 772 // Nimbus should be added to `SUGGEST_PREFS` with 773 // `nimbusVariableIfExposedInUi` set appropriately. 774 // * Prefs with `nimbusVariableIfExposedInUi` set should not be specified as 775 // `fallbackPref` for their Nimbus variables. Access these prefs directly 776 // instead of through their variables. 777 // 778 // The pref-update process is described next. 779 // 780 // 1. Determine the appropriate values for Suggest prefs according to the 781 // user's home region and locale. 782 // 783 // 2. Set the prefs on the default branch. We use the default branch and not 784 // the user branch because we want to distinguish default prefs from the 785 // user's choices. 786 // 787 // In particular it's important to consider prefs that are exposed in the 788 // UI, like whether sponsored suggestions are enabled. Once the user 789 // makes a choice to change a default, we want to preserve that choice 790 // indefinitely regardless of whether Suggest is currently enabled or 791 // will be enabled in the future. User choices are of course recorded on 792 // the user branch, so if we set defaults on the user branch too, we 793 // wouldn't be able to distinguish user choices from default values. This 794 // is also why prefs that are exposed in the UI should be sticky. Unlike 795 // non-sticky prefs, sticky prefs retain their user-branch values even 796 // when those values are the same as the ones on the default branch. 797 // 798 // It's important to note that the defaults we set here do not persist 799 // across app restarts. (This is a feature of the pref service; prefs set 800 // programmatically on the default branch are not stored anywhere 801 // permanent like firefox.js or user.js.) That's why BrowserGlue calls 802 // `init()` on every startup. 803 // 804 // 3. Some prefs are both exposed in the UI and configurable via Nimbus, 805 // like whether data collection is enabled. We absolutely want to 806 // preserve the user's past choices for these prefs. But if the user 807 // hasn't yet made a choice for a particular pref, then it should be 808 // configurable. 809 // 810 // For any such prefs that have values defined in Nimbus, we set their 811 // default-branch values to their Nimbus values. (These defaults 812 // therefore override any set in the previous step.) If a pref has a user 813 // value, accessing the pref will return the user value; if it does not 814 // have a user value, accessing it will return the value that was 815 // specified in Nimbus. 816 // 817 // This isn't strictly necessary. Since prefs exposed in the UI are 818 // sticky, they will always preserve their user-branch values regardless 819 // of their default-branch values, and as long as a pref is listed as a 820 // `fallbackPref` for its corresponding Nimbus variable, Nimbus will use 821 // the user-branch value. So we could instead specify fallback prefs in 822 // Nimbus and always access values through Nimbus instead of through 823 // prefs. But that would make preferences UI code a little harder to 824 // write since the checked state of a checkbox would depend on something 825 // other than its pref. Since we're already setting default-branch values 826 // here as part of the previous step, it's not much more work to set 827 // defaults for these prefs too, and it makes the UI code a little nicer. 828 // 829 // 4. Migrate prefs as necessary. This refers to any pref changes that are 830 // neccesary across app versions: introducing and initializing new prefs, 831 // removing prefs, or changing the meaning of existing prefs. 832 833 // We use `Preferences` because it lets us access prefs without worrying 834 // about their types and can do so on the default branch. Most of our prefs 835 // are bools but not all. 836 let defaults = new lazy.Preferences({ 837 branch: "browser.urlbar.", 838 defaultBranch: true, 839 }); 840 841 // Before setting defaults, save their original unmodifed values as defined 842 // in `firefox.js` so we can restore them if Suggest becomes disabled. 843 if (!this.#unmodifiedDefaultPrefs) { 844 this.#unmodifiedDefaultPrefs = Object.fromEntries( 845 Object.keys(SUGGEST_PREFS).map(name => [name, defaults.get(name)]) 846 ); 847 } 848 849 // 1. Determine the appropriate values for Suggest prefs according to the 850 // user's home region and locale. 851 if (testOverrides?.defaultPrefs) { 852 this.#intendedDefaultPrefs = testOverrides.defaultPrefs; 853 } else { 854 let region = testOverrides?.region ?? lazy.Region.home; 855 let locale = testOverrides?.locale ?? Services.locale.appLocaleAsBCP47; 856 this.#intendedDefaultPrefs = this.intendedDefaultPrefs(region, locale); 857 } 858 859 // 2. Set the prefs on the default branch. 860 for (let [name, value] of Object.entries(this.#intendedDefaultPrefs)) { 861 defaults.set(name, value); 862 } 863 864 // 3. Set default-branch values for prefs that are both exposed in the 865 // settings UI and configurable via Nimbus. 866 this.#syncNimbusVariablesToUiPrefs(); 867 868 // 4. Migrate user-branch prefs across app versions. 869 let shouldEnableSuggest = 870 !!this.#intendedDefaultPrefs["quicksuggest.enabled"]; 871 this.#ensureUserPrefsMigrated(shouldEnableSuggest, testOverrides); 872 } 873 874 /** 875 * Sets default-branch values for prefs in `#uiPrefsByNimbusVariable`, i.e., 876 * prefs that are both exposed in the settings UI and configurable via Nimbus. 877 * 878 * @param {string} variable 879 * If defined, only the pref corresponding to this variable will be set. If 880 * there is no UI pref for this variable, this function is a no-op. 881 */ 882 #syncNimbusVariablesToUiPrefs(variable = null) { 883 let prefsByVariable = this.#uiPrefsByNimbusVariable; 884 885 if (variable) { 886 if (!prefsByVariable.hasOwnProperty(variable)) { 887 // `variable` does not correspond to a pref exposed in the UI. 888 return; 889 } 890 // Restrict `prefsByVariable` only to `variable`. 891 prefsByVariable = { [variable]: prefsByVariable[variable] }; 892 } 893 894 let defaults = new lazy.Preferences({ 895 branch: "browser.urlbar.", 896 defaultBranch: true, 897 }); 898 899 for (let [v, pref] of Object.entries(prefsByVariable)) { 900 let value = lazy.NimbusFeatures.urlbar.getVariable(v); 901 if (value === undefined) { 902 value = this.#intendedDefaultPrefs[pref]; 903 } 904 defaults.set(pref, value); 905 } 906 } 907 908 /** 909 * Updates all features. 910 */ 911 #updateAll() { 912 // IMPORTANT: This method is a `NimbusFeatures.urlbar.onUpdate()` callback, 913 // which means it's called on every change to any pref that is a fallback 914 // for a urlbar Nimbus variable. 915 916 // Update features. 917 for (let feature of this.#featuresByName.values()) { 918 feature.update(); 919 } 920 } 921 922 /** 923 * The current version of the Firefox Suggest prefs. 924 * 925 * @returns {number} 926 */ 927 get MIGRATION_VERSION() { 928 return 6; 929 } 930 931 /** 932 * Migrates user-branch Suggest prefs to the current version if they haven't 933 * been migrated already. 934 * 935 * @param {boolean} shouldEnableSuggest 936 * Whether Suggest should be enabled right now. 937 * @param {object} testOverrides 938 * This is intended for tests only. Pass to force a migration version: 939 * `{ migrationVersion }` 940 */ 941 #ensureUserPrefsMigrated(shouldEnableSuggest, testOverrides) { 942 let currentVersion = 943 testOverrides?.migrationVersion !== undefined 944 ? testOverrides.migrationVersion 945 : this.MIGRATION_VERSION; 946 let lastSeenVersion = Math.max( 947 0, 948 lazy.UrlbarPrefs.get("quicksuggest.migrationVersion") 949 ); 950 if (currentVersion <= lastSeenVersion) { 951 // Migration up to date. 952 return; 953 } 954 955 // Migrate from the last-seen version up to the current version. 956 let userBranch = Services.prefs.getBranch("browser.urlbar."); 957 let version = lastSeenVersion; 958 for (; version < currentVersion; version++) { 959 let nextVersion = version + 1; 960 let methodName = "_migrateUserPrefsTo_" + nextVersion; 961 try { 962 this[methodName](userBranch, shouldEnableSuggest); 963 } catch (error) { 964 console.error( 965 `Error migrating Firefox Suggest prefs to version ${nextVersion}:`, 966 error 967 ); 968 break; 969 } 970 } 971 972 // Record the new last-seen migration version. 973 lazy.UrlbarPrefs.set("quicksuggest.migrationVersion", version); 974 } 975 976 _migrateUserPrefsTo_1(userBranch, shouldEnableSuggest) { 977 // Previously prefs were unversioned and worked like this: When 978 // `suggest.quicksuggest` is false, all quick suggest results are disabled 979 // and `suggest.quicksuggest.sponsored` is ignored. To show sponsored 980 // suggestions, both prefs must be true. 981 // 982 // Version 1 makes the following changes: 983 // 984 // `suggest.quicksuggest` is removed, `suggest.quicksuggest.nonsponsored` is 985 // introduced. `suggest.quicksuggest.nonsponsored` and 986 // `suggest.quicksuggest.sponsored` are independent: 987 // `suggest.quicksuggest.nonsponsored` controls non-sponsored results and 988 // `suggest.quicksuggest.sponsored` controls sponsored results. 989 // `quicksuggest.dataCollection.enabled` is introduced. 990 991 // Copy `suggest.quicksuggest` to `suggest.quicksuggest.nonsponsored` and 992 // clear the first. 993 if (userBranch.prefHasUserValue("suggest.quicksuggest")) { 994 userBranch.setBoolPref( 995 "suggest.quicksuggest.nonsponsored", 996 userBranch.getBoolPref("suggest.quicksuggest") 997 ); 998 userBranch.clearUserPref("suggest.quicksuggest"); 999 } 1000 1001 // In the unversioned prefs, sponsored suggestions were shown only if the 1002 // main suggestions pref `suggest.quicksuggest` was true, but now there are 1003 // two independent prefs, so disable sponsored if the main pref was false. 1004 if ( 1005 shouldEnableSuggest && 1006 userBranch.prefHasUserValue("suggest.quicksuggest.nonsponsored") && 1007 !userBranch.getBoolPref("suggest.quicksuggest.nonsponsored") 1008 ) { 1009 // Set the pref on the user branch. Suggestions are enabled by default 1010 // for offline; we want to preserve the user's choice of opting out, 1011 // and we want to preserve the default-branch true value. 1012 userBranch.setBoolPref("suggest.quicksuggest.sponsored", false); 1013 } 1014 } 1015 1016 _migrateUserPrefsTo_2(userBranch) { 1017 // For online, the defaults for `suggest.quicksuggest.nonsponsored` and 1018 // `suggest.quicksuggest.sponsored` are now true. Previously they were 1019 // false. 1020 1021 // In previous versions of the prefs for online, suggestions were disabled 1022 // by default; in version 2, they're enabled by default. For users who were 1023 // already in online and did not enable suggestions (because they did not 1024 // opt in, they did opt in but later disabled suggestions, or they were not 1025 // shown the modal) we don't want to suddenly enable them, so if the prefs 1026 // do not have user-branch values, set them to false. 1027 let scenario = userBranch.getCharPref("quicksuggest.scenario", ""); 1028 if (scenario == "online") { 1029 if (!userBranch.prefHasUserValue("suggest.quicksuggest.nonsponsored")) { 1030 userBranch.setBoolPref("suggest.quicksuggest.nonsponsored", false); 1031 } 1032 if (!userBranch.prefHasUserValue("suggest.quicksuggest.sponsored")) { 1033 userBranch.setBoolPref("suggest.quicksuggest.sponsored", false); 1034 } 1035 } 1036 } 1037 1038 _migrateUserPrefsTo_3() { 1039 // This used to check the `quicksuggest.dataCollection.enabled` preference 1040 // and set `quicksuggest.settingsUi` to `SETTINGS_UI.FULL` if data collection 1041 // was enabled. However, this is now cleared for everyone in the v4 migration, 1042 // hence there is nothing to do here. 1043 } 1044 1045 _migrateUserPrefsTo_4(userBranch) { 1046 // This will reset the pref to the default value, i.e. SETTINGS_UI.OFFLINE_ONLY 1047 // for users where suggest is enabled, or SETTINGS_UI.NONE where it is not 1048 // enabled. 1049 userBranch.clearUserPref("quicksuggest.settingsUi"); 1050 } 1051 1052 _migrateUserPrefsTo_5(userBranch) { 1053 // This migration clears the sponsored pref for region-locales where, at the 1054 // time of this migration, the Suggest technical platform is enabled 1055 // (`quicksuggest.enabled` is true) but features that are part of the 1056 // Suggest brand are not. It was incorrectly set to false on the user branch 1057 // due to the combination of two things: 1058 // 1059 // 1. In 146, bug 1992811 enabled the Suggest platform for `en` locales in 1060 // DE, FR, and IT in order to ship important-dates suggestions, which 1061 // aren't considered part of the Suggest brand. For these region-locales, 1062 // `quicksuggest.enabled` was defaulted to true and the sponsored and 1063 // nonsponsored prefs retained their false values from `firefox.js`. 1064 // 2. A previous implementation of the version 1 migration incorrectly set 1065 // the sponsored pref to false on the user branch if 1066 // `quicksuggest.enabled` is true and the nonsponsored pref is false on 1067 // either the user or default branch. The migration should have only 1068 // checked the user branch and has since been fixed. 1069 if ( 1070 ["DE", "FR", "IT"].includes(lazy.Region.home) && 1071 EN_LOCALES.includes(Services.locale.appLocaleAsBCP47) 1072 ) { 1073 userBranch.clearUserPref("suggest.quicksuggest.sponsored"); 1074 } 1075 } 1076 1077 _migrateUserPrefsTo_6(userBranch) { 1078 // Firefox 146 no longer uses `suggest.quicksuggest.nonsponsored` and stops 1079 // setting it on the default branch. It introduces 1080 // `suggest.quicksuggest.all`, which now controls all suggestions that are 1081 // part of the Suggest brand, both sponsored and nonsponsored. To show 1082 // nonsponsored suggestions, `all` must be true. To show sponsored 1083 // suggestions, both `all` and `suggest.quicksuggest.sponsored` must be 1084 // true. 1085 // 1086 // This migration copies the user-branch value of `nonsponsored` to the new 1087 // `all` pref. We keep the user-branch value in case we need it later. 1088 if (userBranch.prefHasUserValue("suggest.quicksuggest.nonsponsored")) { 1089 userBranch.setBoolPref( 1090 "suggest.quicksuggest.all", 1091 userBranch.getBoolPref("suggest.quicksuggest.nonsponsored") 1092 ); 1093 } 1094 } 1095 1096 async _test_reset(testOverrides = null) { 1097 if (this.#initStarted) { 1098 await this.initPromise; 1099 } 1100 1101 if (this.rustBackend) { 1102 await this.rustBackend.ingestPromise; 1103 } 1104 1105 this.#initPrefs(testOverrides); 1106 this.#updateAll(); 1107 if (this.rustBackend) { 1108 // `#updateAll()` triggers ingest, so wait for it to finish. 1109 await this.rustBackend.ingestPromise; 1110 } 1111 } 1112 1113 #initStarted = false; 1114 #initResolvers = Promise.withResolvers(); 1115 1116 // Maps from Suggest feature class names to feature instances. 1117 #featuresByName = new Map(); 1118 1119 // Maps from Merino provider names to Suggest feature instances. 1120 #featuresByMerinoProvider = new Map(); 1121 1122 // Maps from Rust suggestion types to Suggest feature instances. 1123 #featuresByRustSuggestionType = new Map(); 1124 1125 // Maps from dynamic Rust suggestion types to Suggest feature instances. 1126 // Features that manage a dynamic Rust suggestion type will be in this map 1127 // instead of `#featuresByRustSuggestionType`. 1128 #featuresByDynamicRustSuggestionType = new Map(); 1129 1130 // Maps from ML intent strings to Suggest feature instances. 1131 #featuresByMlIntent = new Map(); 1132 1133 // Maps from preference names to the `Set` of feature instances they enable. 1134 #featuresByEnablingPrefs = new Map(); 1135 1136 // A plain JS object that maps pref names relative to `browser.urlbar.` to 1137 // their intended defaults depending on whether Suggest should be enabled. 1138 #intendedDefaultPrefs; 1139 1140 // A plain JS object that maps pref names relative to `browser.urlbar.` to 1141 // their original unmodified values as defined in `firefox.js`. 1142 #unmodifiedDefaultPrefs; 1143 } 1144 1145 function getDismissalKey(result) { 1146 return ( 1147 result.payload.dismissalKey || 1148 result.payload.originalUrl || 1149 result.payload.url 1150 ); 1151 } 1152 1153 async function getDigest(string) { 1154 let stringArray = new TextEncoder().encode(string); 1155 let hashBuffer = await crypto.subtle.digest("SHA-1", stringArray); 1156 let hashArray = new Uint8Array(hashBuffer); 1157 return Array.from(hashArray, b => b.toString(16).padStart(2, "0")).join(""); 1158 } 1159 1160 export const QuickSuggest = new _QuickSuggest();