tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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();