mirror of
				https://github.com/mozilla/gecko-dev.git
				synced 2025-11-04 02:09:05 +02:00 
			
		
		
		
	
		
			
				
	
	
		
			944 lines
		
	
	
	
		
			31 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			944 lines
		
	
	
	
		
			31 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
/* This Source Code Form is subject to the terms of the Mozilla Public
 | 
						|
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 | 
						|
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 | 
						|
 | 
						|
import {
 | 
						|
  UrlbarProvider,
 | 
						|
  UrlbarUtils,
 | 
						|
} from "resource:///modules/UrlbarUtils.sys.mjs";
 | 
						|
 | 
						|
const lazy = {};
 | 
						|
 | 
						|
ChromeUtils.defineESModuleGetters(lazy, {
 | 
						|
  CONTEXTUAL_SERVICES_PING_TYPES:
 | 
						|
    "resource:///modules/PartnerLinkAttribution.sys.mjs",
 | 
						|
  MerinoClient: "resource:///modules/MerinoClient.sys.mjs",
 | 
						|
  QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs",
 | 
						|
  UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
 | 
						|
  UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs",
 | 
						|
});
 | 
						|
 | 
						|
// `contextId` is a unique identifier used by Contextual Services
 | 
						|
const CONTEXT_ID_PREF = "browser.contextual-services.contextId";
 | 
						|
ChromeUtils.defineLazyGetter(lazy, "contextId", () => {
 | 
						|
  let _contextId = Services.prefs.getStringPref(CONTEXT_ID_PREF, null);
 | 
						|
  if (!_contextId) {
 | 
						|
    _contextId = String(Services.uuid.generateUUID());
 | 
						|
    Services.prefs.setStringPref(CONTEXT_ID_PREF, _contextId);
 | 
						|
  }
 | 
						|
  return _contextId;
 | 
						|
});
 | 
						|
 | 
						|
// Used for suggestions that don't otherwise have a score.
 | 
						|
const DEFAULT_SUGGESTION_SCORE = 0.2;
 | 
						|
 | 
						|
const TELEMETRY_PREFIX = "contextual.services.quicksuggest";
 | 
						|
 | 
						|
const TELEMETRY_SCALARS = {
 | 
						|
  BLOCK_DYNAMIC_WIKIPEDIA: `${TELEMETRY_PREFIX}.block_dynamic_wikipedia`,
 | 
						|
  BLOCK_NONSPONSORED: `${TELEMETRY_PREFIX}.block_nonsponsored`,
 | 
						|
  BLOCK_SPONSORED: `${TELEMETRY_PREFIX}.block_sponsored`,
 | 
						|
  CLICK_DYNAMIC_WIKIPEDIA: `${TELEMETRY_PREFIX}.click_dynamic_wikipedia`,
 | 
						|
  CLICK_NAV_NOTMATCHED: `${TELEMETRY_PREFIX}.click_nav_notmatched`,
 | 
						|
  CLICK_NAV_SHOWN_HEURISTIC: `${TELEMETRY_PREFIX}.click_nav_shown_heuristic`,
 | 
						|
  CLICK_NAV_SHOWN_NAV: `${TELEMETRY_PREFIX}.click_nav_shown_nav`,
 | 
						|
  CLICK_NAV_SUPERCEDED: `${TELEMETRY_PREFIX}.click_nav_superceded`,
 | 
						|
  CLICK_NONSPONSORED: `${TELEMETRY_PREFIX}.click_nonsponsored`,
 | 
						|
  CLICK_SPONSORED: `${TELEMETRY_PREFIX}.click_sponsored`,
 | 
						|
  HELP_NONSPONSORED: `${TELEMETRY_PREFIX}.help_nonsponsored`,
 | 
						|
  HELP_SPONSORED: `${TELEMETRY_PREFIX}.help_sponsored`,
 | 
						|
  IMPRESSION_DYNAMIC_WIKIPEDIA: `${TELEMETRY_PREFIX}.impression_dynamic_wikipedia`,
 | 
						|
  IMPRESSION_NAV_NOTMATCHED: `${TELEMETRY_PREFIX}.impression_nav_notmatched`,
 | 
						|
  IMPRESSION_NAV_SHOWN: `${TELEMETRY_PREFIX}.impression_nav_shown`,
 | 
						|
  IMPRESSION_NAV_SUPERCEDED: `${TELEMETRY_PREFIX}.impression_nav_superceded`,
 | 
						|
  IMPRESSION_NONSPONSORED: `${TELEMETRY_PREFIX}.impression_nonsponsored`,
 | 
						|
  IMPRESSION_SPONSORED: `${TELEMETRY_PREFIX}.impression_sponsored`,
 | 
						|
};
 | 
						|
 | 
						|
/**
 | 
						|
 * A provider that returns a suggested url to the user based on what
 | 
						|
 * they have currently typed so they can navigate directly.
 | 
						|
 */
 | 
						|
class ProviderQuickSuggest extends UrlbarProvider {
 | 
						|
  /**
 | 
						|
   * Returns the name of this provider.
 | 
						|
   *
 | 
						|
   * @returns {string} the name of this provider.
 | 
						|
   */
 | 
						|
  get name() {
 | 
						|
    return "UrlbarProviderQuickSuggest";
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * The type of the provider.
 | 
						|
   *
 | 
						|
   * @returns {UrlbarUtils.PROVIDER_TYPE}
 | 
						|
   */
 | 
						|
  get type() {
 | 
						|
    return UrlbarUtils.PROVIDER_TYPE.NETWORK;
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * @returns {number}
 | 
						|
   *   The default score for suggestions that don't otherwise have one. All
 | 
						|
   *   suggestions require scores so they can be ranked. Scores are numeric
 | 
						|
   *   values in the range [0, 1].
 | 
						|
   */
 | 
						|
  get DEFAULT_SUGGESTION_SCORE() {
 | 
						|
    return DEFAULT_SUGGESTION_SCORE;
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * @returns {object} An object mapping from mnemonics to scalar names.
 | 
						|
   */
 | 
						|
  get TELEMETRY_SCALARS() {
 | 
						|
    return { ...TELEMETRY_SCALARS };
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Whether this provider should be invoked for the given context.
 | 
						|
   * If this method returns false, the providers manager won't start a query
 | 
						|
   * with this provider, to save on resources.
 | 
						|
   *
 | 
						|
   * @param {UrlbarQueryContext} queryContext The query context object
 | 
						|
   * @returns {boolean} Whether this provider should be invoked for the search.
 | 
						|
   */
 | 
						|
  isActive(queryContext) {
 | 
						|
    this.#resultFromLastQuery = null;
 | 
						|
 | 
						|
    // If the sources don't include search or the user used a restriction
 | 
						|
    // character other than search, don't allow any suggestions.
 | 
						|
    if (
 | 
						|
      !queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.SEARCH) ||
 | 
						|
      (queryContext.restrictSource &&
 | 
						|
        queryContext.restrictSource != UrlbarUtils.RESULT_SOURCE.SEARCH)
 | 
						|
    ) {
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
 | 
						|
    if (
 | 
						|
      !lazy.UrlbarPrefs.get("quickSuggestEnabled") ||
 | 
						|
      queryContext.isPrivate ||
 | 
						|
      queryContext.searchMode
 | 
						|
    ) {
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
 | 
						|
    // Trim only the start of the search string because a trailing space can
 | 
						|
    // affect the suggestions.
 | 
						|
    let trimmedSearchString = queryContext.searchString.trimStart();
 | 
						|
 | 
						|
    // Per product requirements, at least two characters must be typed to
 | 
						|
    // trigger a Suggest suggestion. Suggestion keywords should always be at
 | 
						|
    // least two characters long, but we check here anyway to be safe. Note we
 | 
						|
    // called `trimStart()` above, so we only call `trimEnd()` here.
 | 
						|
    if (trimmedSearchString.trimEnd().length < 2) {
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
    this._trimmedSearchString = trimmedSearchString;
 | 
						|
    return true;
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Starts querying. Extended classes should return a Promise resolved when the
 | 
						|
   * provider is done searching AND returning results.
 | 
						|
   *
 | 
						|
   * @param {UrlbarQueryContext} queryContext The query context object
 | 
						|
   * @param {Function} addCallback Callback invoked by the provider to add a new
 | 
						|
   *        result. A UrlbarResult should be passed to it.
 | 
						|
   * @returns {Promise}
 | 
						|
   */
 | 
						|
  async startQuery(queryContext, addCallback) {
 | 
						|
    let instance = this.queryInstance;
 | 
						|
    let searchString = this._trimmedSearchString;
 | 
						|
 | 
						|
    // There are two sources for quick suggest: the current remote settings
 | 
						|
    // backend (either JS or Rust) and Merino.
 | 
						|
    let promises = [];
 | 
						|
    let { backend } = lazy.QuickSuggest;
 | 
						|
    if (backend?.isEnabled) {
 | 
						|
      promises.push(backend.query(searchString));
 | 
						|
    }
 | 
						|
    if (
 | 
						|
      lazy.UrlbarPrefs.get("quicksuggest.dataCollection.enabled") &&
 | 
						|
      queryContext.allowRemoteResults()
 | 
						|
    ) {
 | 
						|
      promises.push(this._fetchMerinoSuggestions(queryContext, searchString));
 | 
						|
    }
 | 
						|
 | 
						|
    // Wait for both sources to finish before adding a suggestion.
 | 
						|
    let values = await Promise.all(promises);
 | 
						|
    if (instance != this.queryInstance) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    let suggestions = values.flat();
 | 
						|
 | 
						|
    // Ensure all suggestions have a `score` by falling back to the default
 | 
						|
    // score as necessary. If `quickSuggestScoreMap` is defined, override scores
 | 
						|
    // with the values it defines. It maps telemetry types to scores.
 | 
						|
    let scoreMap = lazy.UrlbarPrefs.get("quickSuggestScoreMap");
 | 
						|
    for (let suggestion of suggestions) {
 | 
						|
      if (isNaN(suggestion.score)) {
 | 
						|
        suggestion.score = DEFAULT_SUGGESTION_SCORE;
 | 
						|
      }
 | 
						|
      if (scoreMap) {
 | 
						|
        let telemetryType = this.#getSuggestionTelemetryType(suggestion);
 | 
						|
        if (scoreMap.hasOwnProperty(telemetryType)) {
 | 
						|
          let score = parseFloat(scoreMap[telemetryType]);
 | 
						|
          if (!isNaN(score)) {
 | 
						|
            suggestion.score = score;
 | 
						|
          }
 | 
						|
        }
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    suggestions.sort((a, b) => b.score - a.score);
 | 
						|
 | 
						|
    // All suggestions should have the following keys at this point. They are
 | 
						|
    // required for looking up the features that manage them.
 | 
						|
    let requiredKeys = ["source", "provider"];
 | 
						|
 | 
						|
    // Add a result for the first suggestion that can be shown.
 | 
						|
    for (let suggestion of suggestions) {
 | 
						|
      for (let key of requiredKeys) {
 | 
						|
        if (!suggestion[key]) {
 | 
						|
          this.logger.error(
 | 
						|
            `Suggestion is missing required key '${key}': ` +
 | 
						|
              JSON.stringify(suggestion)
 | 
						|
          );
 | 
						|
          continue;
 | 
						|
        }
 | 
						|
      }
 | 
						|
 | 
						|
      let canAdd = await this._canAddSuggestion(suggestion);
 | 
						|
      if (instance != this.queryInstance) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      let result;
 | 
						|
      if (
 | 
						|
        canAdd &&
 | 
						|
        (result = await this.#makeResult(queryContext, suggestion))
 | 
						|
      ) {
 | 
						|
        this.#resultFromLastQuery = result;
 | 
						|
        addCallback(this, result);
 | 
						|
        return;
 | 
						|
      }
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  onLegacyEngagement(state, queryContext, details, controller) {
 | 
						|
    // Ignore engagements on other results that didn't end the session.
 | 
						|
    if (details.result?.providerName != this.name && details.isSessionOngoing) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    // Reset the Merino session ID when a session ends. By design for the user's
 | 
						|
    // privacy, we don't keep it around between engagements.
 | 
						|
    if (!details.isSessionOngoing) {
 | 
						|
      this.#merino?.resetSession();
 | 
						|
    }
 | 
						|
 | 
						|
    // Impression and clicked telemetry are both recorded on engagement. We
 | 
						|
    // define "impression" to mean a quick suggest result was present in the
 | 
						|
    // view when any result was picked.
 | 
						|
    if (state == "engagement" && queryContext) {
 | 
						|
      // Get the result that's visible in the view. `details.result` is the
 | 
						|
      // engaged result, if any; if it's from this provider, then that's the
 | 
						|
      // visible result. Otherwise fall back to #getVisibleResultFromLastQuery.
 | 
						|
      let { result } = details;
 | 
						|
      if (result?.providerName != this.name) {
 | 
						|
        result = this.#getVisibleResultFromLastQuery(controller.view);
 | 
						|
      }
 | 
						|
 | 
						|
      this.#recordEngagement(queryContext, result, details);
 | 
						|
    }
 | 
						|
 | 
						|
    if (details.result?.providerName == this.name) {
 | 
						|
      let feature = this.#getFeatureByResult(details.result);
 | 
						|
      if (feature?.handleCommand) {
 | 
						|
        feature.handleCommand(
 | 
						|
          controller.view,
 | 
						|
          details.result,
 | 
						|
          details.selType,
 | 
						|
          this._trimmedSearchString
 | 
						|
        );
 | 
						|
      } else if (details.selType == "dismiss") {
 | 
						|
        // Handle dismissals.
 | 
						|
        this.#dismissResult(controller, details.result);
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    this.#resultFromLastQuery = null;
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * This is called only for dynamic result types, when the urlbar view updates
 | 
						|
   * the view of one of the results of the provider.  It should return an object
 | 
						|
   * describing the view update.
 | 
						|
   *
 | 
						|
   * @param {UrlbarResult} result The result whose view will be updated.
 | 
						|
   * @returns {object} An object describing the view update.
 | 
						|
   */
 | 
						|
  getViewUpdate(result) {
 | 
						|
    return this.#getFeatureByResult(result)?.getViewUpdate?.(result);
 | 
						|
  }
 | 
						|
 | 
						|
  getResultCommands(result) {
 | 
						|
    return this.#getFeatureByResult(result)?.getResultCommands?.(result);
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Gets the `BaseFeature` instance that implements suggestions for a source
 | 
						|
   * and provider name. The source and provider name can be supplied from either
 | 
						|
   * a suggestion object or the payload of a `UrlbarResult` object.
 | 
						|
   *
 | 
						|
   * @param {object} options
 | 
						|
   *   Options object.
 | 
						|
   * @param {string} options.source
 | 
						|
   *   The suggestion source, one of: "remote-settings", "merino", "rust"
 | 
						|
   * @param {string} options.provider
 | 
						|
   *   This value depends on `source`. The possible values per source are:
 | 
						|
   *
 | 
						|
   *   remote-settings:
 | 
						|
   *     The name of the `BaseFeature` instance (`feature.name`) that manages
 | 
						|
   *     the suggestion type
 | 
						|
   *   merino:
 | 
						|
   *     The name of the Merino provider that serves the suggestion type
 | 
						|
   *   rust:
 | 
						|
   *     The name of the suggestion type as defined in `suggest.udl`
 | 
						|
   * @returns {BaseFeature}
 | 
						|
   *   The feature instance or null if no feature was found.
 | 
						|
   */
 | 
						|
  #getFeature({ source, provider }) {
 | 
						|
    switch (source) {
 | 
						|
      case "remote-settings":
 | 
						|
        return lazy.QuickSuggest.getFeature(provider);
 | 
						|
      case "merino":
 | 
						|
        return lazy.QuickSuggest.getFeatureByMerinoProvider(provider);
 | 
						|
      case "rust":
 | 
						|
        return lazy.QuickSuggest.getFeatureByRustSuggestionType(provider);
 | 
						|
    }
 | 
						|
    return null;
 | 
						|
  }
 | 
						|
 | 
						|
  #getFeatureByResult(result) {
 | 
						|
    return this.#getFeature(result.payload);
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Returns the telemetry type for a suggestion. A telemetry type uniquely
 | 
						|
   * identifies a type of suggestion as well as the kind of `UrlbarResult`
 | 
						|
   * instances created from it.
 | 
						|
   *
 | 
						|
   * @param {object} suggestion
 | 
						|
   *   A suggestion from remote settings or Merino.
 | 
						|
   * @returns {string}
 | 
						|
   *   The telemetry type. If the suggestion type is managed by a `BaseFeature`
 | 
						|
   *   instance, the telemetry type is retrieved from it. Otherwise the
 | 
						|
   *   suggestion type is assumed to come from Merino, and `suggestion.provider`
 | 
						|
   *   (the Merino provider name) is returned.
 | 
						|
   */
 | 
						|
  #getSuggestionTelemetryType(suggestion) {
 | 
						|
    let feature = this.#getFeature(suggestion);
 | 
						|
    if (feature) {
 | 
						|
      return feature.getSuggestionTelemetryType(suggestion);
 | 
						|
    }
 | 
						|
    return suggestion.provider;
 | 
						|
  }
 | 
						|
 | 
						|
  async #makeResult(queryContext, suggestion) {
 | 
						|
    let result;
 | 
						|
    let feature = this.#getFeature(suggestion);
 | 
						|
    if (!feature) {
 | 
						|
      result = this.#makeDefaultResult(queryContext, suggestion);
 | 
						|
    } else {
 | 
						|
      result = await feature.makeResult(
 | 
						|
        queryContext,
 | 
						|
        suggestion,
 | 
						|
        this._trimmedSearchString
 | 
						|
      );
 | 
						|
      if (!result) {
 | 
						|
        // Feature might return null, if the feature is disabled and so on.
 | 
						|
        return null;
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    // `source` will be one of: "remote-settings", "merino", "rust".
 | 
						|
    // `provider` depends on `source`. See `#getFeature()` for possible values.
 | 
						|
    result.payload.source = suggestion.source;
 | 
						|
    result.payload.provider = suggestion.provider;
 | 
						|
    result.payload.telemetryType = this.#getSuggestionTelemetryType(suggestion);
 | 
						|
 | 
						|
    // Handle icons here so each feature doesn't have to do it, but use `||=` to
 | 
						|
    // let them do it if they need to.
 | 
						|
    result.payload.icon ||= suggestion.icon;
 | 
						|
    result.payload.iconBlob ||= suggestion.icon_blob;
 | 
						|
 | 
						|
    // Set the appropriate suggested index and related properties unless the
 | 
						|
    // feature did it already.
 | 
						|
    if (!result.hasSuggestedIndex) {
 | 
						|
      if (suggestion.is_top_pick) {
 | 
						|
        result.isBestMatch = true;
 | 
						|
        result.isRichSuggestion = true;
 | 
						|
        result.richSuggestionIconSize ||= 52;
 | 
						|
        result.suggestedIndex = 1;
 | 
						|
      } else if (
 | 
						|
        suggestion.is_sponsored &&
 | 
						|
        lazy.UrlbarPrefs.get("quickSuggestSponsoredPriority")
 | 
						|
      ) {
 | 
						|
        result.isBestMatch = true;
 | 
						|
        result.suggestedIndex = 1;
 | 
						|
      } else if (
 | 
						|
        !isNaN(suggestion.position) &&
 | 
						|
        lazy.UrlbarPrefs.get("quickSuggestAllowPositionInSuggestions")
 | 
						|
      ) {
 | 
						|
        result.suggestedIndex = suggestion.position;
 | 
						|
      } else {
 | 
						|
        result.isSuggestedIndexRelativeToGroup = true;
 | 
						|
        result.suggestedIndex = lazy.UrlbarPrefs.get(
 | 
						|
          suggestion.is_sponsored
 | 
						|
            ? "quickSuggestSponsoredIndex"
 | 
						|
            : "quickSuggestNonSponsoredIndex"
 | 
						|
        );
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    return result;
 | 
						|
  }
 | 
						|
 | 
						|
  #makeDefaultResult(queryContext, suggestion) {
 | 
						|
    let payload = {
 | 
						|
      url: suggestion.url,
 | 
						|
      isSponsored: suggestion.is_sponsored,
 | 
						|
      isBlockable: true,
 | 
						|
      blockL10n: {
 | 
						|
        id: "urlbar-result-menu-dismiss-firefox-suggest",
 | 
						|
      },
 | 
						|
      isManageable: true,
 | 
						|
    };
 | 
						|
 | 
						|
    if (suggestion.full_keyword) {
 | 
						|
      payload.title = suggestion.title;
 | 
						|
      payload.qsSuggestion = [
 | 
						|
        suggestion.full_keyword,
 | 
						|
        UrlbarUtils.HIGHLIGHT.SUGGESTED,
 | 
						|
      ];
 | 
						|
    } else {
 | 
						|
      payload.title = [suggestion.title, UrlbarUtils.HIGHLIGHT.TYPED];
 | 
						|
      payload.shouldShowUrl = true;
 | 
						|
    }
 | 
						|
 | 
						|
    return new lazy.UrlbarResult(
 | 
						|
      UrlbarUtils.RESULT_TYPE.URL,
 | 
						|
      UrlbarUtils.RESULT_SOURCE.SEARCH,
 | 
						|
      ...lazy.UrlbarResult.payloadAndSimpleHighlights(
 | 
						|
        queryContext.tokens,
 | 
						|
        payload
 | 
						|
      )
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  #getVisibleResultFromLastQuery(view) {
 | 
						|
    let result = this.#resultFromLastQuery;
 | 
						|
 | 
						|
    if (
 | 
						|
      result?.rowIndex >= 0 &&
 | 
						|
      view?.visibleResults?.[result.rowIndex] == result
 | 
						|
    ) {
 | 
						|
      // The result was visible.
 | 
						|
      return result;
 | 
						|
    }
 | 
						|
 | 
						|
    // Find a visible result. Quick suggest results typically appear last in the
 | 
						|
    // view, so do a reverse search.
 | 
						|
    return view?.visibleResults?.findLast(r => r.providerName == this.name);
 | 
						|
  }
 | 
						|
 | 
						|
  #dismissResult(controller, result) {
 | 
						|
    if (!result.payload.isBlockable) {
 | 
						|
      this.logger.info("Dismissals disabled, ignoring dismissal");
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    this.logger.info("Dismissing result: " + JSON.stringify(result));
 | 
						|
    lazy.QuickSuggest.blockedSuggestions.add(
 | 
						|
      // adM results have `originalUrl`, which contains timestamp templates.
 | 
						|
      result.payload.originalUrl ?? result.payload.url
 | 
						|
    );
 | 
						|
    controller.removeResult(result);
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Records engagement telemetry. This should be called only at the end of an
 | 
						|
   * engagement when a quick suggest result is present or when a quick suggest
 | 
						|
   * result is dismissed.
 | 
						|
   *
 | 
						|
   * @param {UrlbarQueryContext} queryContext
 | 
						|
   *   The query context.
 | 
						|
   * @param {UrlbarResult} result
 | 
						|
   *   The quick suggest result that was present (and possibly picked) at the
 | 
						|
   *   end of the engagement or that was dismissed. Null if no quick suggest
 | 
						|
   *   result was present.
 | 
						|
   * @param {object} details
 | 
						|
   *   The `details` object that was passed to `onLegacyEngagement()`. It must
 | 
						|
   *   look like this: `{ selType, selIndex }`
 | 
						|
   */
 | 
						|
  #recordEngagement(queryContext, result, details) {
 | 
						|
    let resultSelType = "";
 | 
						|
    let resultClicked = false;
 | 
						|
    if (result && details.result == result) {
 | 
						|
      resultSelType = details.selType;
 | 
						|
      resultClicked =
 | 
						|
        details.element?.tagName != "menuitem" &&
 | 
						|
        !details.element?.classList.contains("urlbarView-button") &&
 | 
						|
        details.selType != "dismiss";
 | 
						|
    }
 | 
						|
 | 
						|
    if (result) {
 | 
						|
      // Update impression stats.
 | 
						|
      lazy.QuickSuggest.impressionCaps.updateStats(
 | 
						|
        result.payload.isSponsored ? "sponsored" : "nonsponsored"
 | 
						|
      );
 | 
						|
 | 
						|
      // Record engagement scalars, event, and pings.
 | 
						|
      this.#recordEngagementScalars({ result, resultSelType, resultClicked });
 | 
						|
      this.#recordEngagementEvent({ result, resultSelType, resultClicked });
 | 
						|
      if (!queryContext.isPrivate) {
 | 
						|
        this.#recordEngagementPings({ result, resultSelType, resultClicked });
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    // Navigational suggestions telemetry requires special handling and does not
 | 
						|
    // depend on a result being visible.
 | 
						|
    if (
 | 
						|
      lazy.UrlbarPrefs.get("recordNavigationalSuggestionTelemetry") &&
 | 
						|
      queryContext.heuristicResult
 | 
						|
    ) {
 | 
						|
      this.#recordNavSuggestionTelemetry({
 | 
						|
        queryContext,
 | 
						|
        result,
 | 
						|
        resultSelType,
 | 
						|
        resultClicked,
 | 
						|
        details,
 | 
						|
      });
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Helper for engagement telemetry that records engagement scalars.
 | 
						|
   *
 | 
						|
   * @param {object} options
 | 
						|
   *   Options object
 | 
						|
   * @param {UrlbarResult} options.result
 | 
						|
   *   The quick suggest result related to the engagement. Must not be null.
 | 
						|
   * @param {string} options.resultSelType
 | 
						|
   *   If an element in the result's row was clicked, this should be its
 | 
						|
   *   `selType`. Otherwise it should be an empty string.
 | 
						|
   * @param {boolean} options.resultClicked
 | 
						|
   *   True if the main part of the result's row was clicked; false if a button
 | 
						|
   *   like help or dismiss was clicked or if no part of the row was clicked.
 | 
						|
   */
 | 
						|
  #recordEngagementScalars({ result, resultSelType, resultClicked }) {
 | 
						|
    // Navigational suggestion scalars are handled separately.
 | 
						|
    if (result.payload.telemetryType == "top_picks") {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    // Indexes recorded in quick suggest telemetry are 1-based, so add 1 to the
 | 
						|
    // 0-based `result.rowIndex`.
 | 
						|
    let telemetryResultIndex = result.rowIndex + 1;
 | 
						|
 | 
						|
    let scalars = [];
 | 
						|
    switch (result.payload.telemetryType) {
 | 
						|
      case "adm_nonsponsored":
 | 
						|
        scalars.push(TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED);
 | 
						|
        if (resultClicked) {
 | 
						|
          scalars.push(TELEMETRY_SCALARS.CLICK_NONSPONSORED);
 | 
						|
        } else {
 | 
						|
          switch (resultSelType) {
 | 
						|
            case "help":
 | 
						|
              scalars.push(TELEMETRY_SCALARS.HELP_NONSPONSORED);
 | 
						|
              break;
 | 
						|
            case "dismiss":
 | 
						|
              scalars.push(TELEMETRY_SCALARS.BLOCK_NONSPONSORED);
 | 
						|
              break;
 | 
						|
          }
 | 
						|
        }
 | 
						|
        break;
 | 
						|
      case "adm_sponsored":
 | 
						|
        scalars.push(TELEMETRY_SCALARS.IMPRESSION_SPONSORED);
 | 
						|
        if (resultClicked) {
 | 
						|
          scalars.push(TELEMETRY_SCALARS.CLICK_SPONSORED);
 | 
						|
        } else {
 | 
						|
          switch (resultSelType) {
 | 
						|
            case "help":
 | 
						|
              scalars.push(TELEMETRY_SCALARS.HELP_SPONSORED);
 | 
						|
              break;
 | 
						|
            case "dismiss":
 | 
						|
              scalars.push(TELEMETRY_SCALARS.BLOCK_SPONSORED);
 | 
						|
              break;
 | 
						|
          }
 | 
						|
        }
 | 
						|
        break;
 | 
						|
      case "wikipedia":
 | 
						|
        scalars.push(TELEMETRY_SCALARS.IMPRESSION_DYNAMIC_WIKIPEDIA);
 | 
						|
        if (resultClicked) {
 | 
						|
          scalars.push(TELEMETRY_SCALARS.CLICK_DYNAMIC_WIKIPEDIA);
 | 
						|
        } else {
 | 
						|
          switch (resultSelType) {
 | 
						|
            case "dismiss":
 | 
						|
              scalars.push(TELEMETRY_SCALARS.BLOCK_DYNAMIC_WIKIPEDIA);
 | 
						|
              break;
 | 
						|
          }
 | 
						|
        }
 | 
						|
        break;
 | 
						|
    }
 | 
						|
 | 
						|
    for (let scalar of scalars) {
 | 
						|
      Services.telemetry.keyedScalarAdd(scalar, telemetryResultIndex, 1);
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Helper for engagement telemetry that records the legacy engagement event.
 | 
						|
   *
 | 
						|
   * @param {object} options
 | 
						|
   *   Options object
 | 
						|
   * @param {UrlbarResult} options.result
 | 
						|
   *   The quick suggest result related to the engagement. Must not be null.
 | 
						|
   * @param {string} options.resultSelType
 | 
						|
   *   If an element in the result's row was clicked, this should be its
 | 
						|
   *   `selType`. Otherwise it should be an empty string.
 | 
						|
   * @param {boolean} options.resultClicked
 | 
						|
   *   True if the main part of the result's row was clicked; false if a button
 | 
						|
   *   like help or dismiss was clicked or if no part of the row was clicked.
 | 
						|
   */
 | 
						|
  #recordEngagementEvent({ result, resultSelType, resultClicked }) {
 | 
						|
    let eventType;
 | 
						|
    if (resultClicked) {
 | 
						|
      eventType = "click";
 | 
						|
    } else if (!resultSelType) {
 | 
						|
      eventType = "impression_only";
 | 
						|
    } else {
 | 
						|
      switch (resultSelType) {
 | 
						|
        case "dismiss":
 | 
						|
          eventType = "block";
 | 
						|
          break;
 | 
						|
        case "help":
 | 
						|
          eventType = "help";
 | 
						|
          break;
 | 
						|
        default:
 | 
						|
          eventType = "other";
 | 
						|
          break;
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    let suggestion_type;
 | 
						|
    switch (result.payload.telemetryType) {
 | 
						|
      case "adm_nonsponsored":
 | 
						|
        suggestion_type = "nonsponsored";
 | 
						|
        break;
 | 
						|
      case "adm_sponsored":
 | 
						|
        suggestion_type = "sponsored";
 | 
						|
        break;
 | 
						|
      case "top_picks":
 | 
						|
        suggestion_type = "navigational";
 | 
						|
        break;
 | 
						|
      case "wikipedia":
 | 
						|
        suggestion_type = "dynamic-wikipedia";
 | 
						|
        break;
 | 
						|
      default:
 | 
						|
        suggestion_type = result.payload.telemetryType;
 | 
						|
        break;
 | 
						|
    }
 | 
						|
 | 
						|
    Services.telemetry.recordEvent(
 | 
						|
      lazy.QuickSuggest.TELEMETRY_EVENT_CATEGORY,
 | 
						|
      "engagement",
 | 
						|
      eventType,
 | 
						|
      "",
 | 
						|
      {
 | 
						|
        suggestion_type,
 | 
						|
        match_type: result.isBestMatch ? "best-match" : "firefox-suggest",
 | 
						|
        // Quick suggest telemetry indexes are 1-based but `rowIndex` is 0-based
 | 
						|
        position: String(result.rowIndex + 1),
 | 
						|
        source: result.payload.source,
 | 
						|
      }
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Helper for engagement telemetry that records custom contextual services
 | 
						|
   * pings.
 | 
						|
   *
 | 
						|
   * @param {object} options
 | 
						|
   *   Options object
 | 
						|
   * @param {UrlbarResult} options.result
 | 
						|
   *   The quick suggest result related to the engagement. Must not be null.
 | 
						|
   * @param {string} options.resultSelType
 | 
						|
   *   If an element in the result's row was clicked, this should be its
 | 
						|
   *   `selType`. Otherwise it should be an empty string.
 | 
						|
   * @param {boolean} options.resultClicked
 | 
						|
   *   True if the main part of the result's row was clicked; false if a button
 | 
						|
   *   like help or dismiss was clicked or if no part of the row was clicked.
 | 
						|
   */
 | 
						|
  #recordEngagementPings({ result, resultSelType, resultClicked }) {
 | 
						|
    if (
 | 
						|
      result.payload.telemetryType != "adm_sponsored" &&
 | 
						|
      result.payload.telemetryType != "adm_nonsponsored"
 | 
						|
    ) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    // Contextual services ping paylod
 | 
						|
    let payload = {
 | 
						|
      match_type: result.isBestMatch ? "best-match" : "firefox-suggest",
 | 
						|
      // Always use lowercase to make the reporting consistent
 | 
						|
      advertiser: result.payload.sponsoredAdvertiser.toLocaleLowerCase(),
 | 
						|
      block_id: result.payload.sponsoredBlockId,
 | 
						|
      improve_suggest_experience_checked: lazy.UrlbarPrefs.get(
 | 
						|
        "quicksuggest.dataCollection.enabled"
 | 
						|
      ),
 | 
						|
      // Quick suggest telemetry indexes are 1-based but `rowIndex` is 0-based
 | 
						|
      position: result.rowIndex + 1,
 | 
						|
      suggested_index: result.suggestedIndex,
 | 
						|
      suggested_index_relative_to_group:
 | 
						|
        !!result.isSuggestedIndexRelativeToGroup,
 | 
						|
      request_id: result.payload.requestId,
 | 
						|
      source: result.payload.source,
 | 
						|
    };
 | 
						|
 | 
						|
    // Glean ping key -> value
 | 
						|
    let defaultValuesByGleanKey = {
 | 
						|
      matchType: payload.match_type,
 | 
						|
      advertiser: payload.advertiser,
 | 
						|
      blockId: payload.block_id,
 | 
						|
      improveSuggestExperience: payload.improve_suggest_experience_checked,
 | 
						|
      position: payload.position,
 | 
						|
      suggestedIndex: payload.suggested_index.toString(),
 | 
						|
      suggestedIndexRelativeToGroup: payload.suggested_index_relative_to_group,
 | 
						|
      requestId: payload.request_id,
 | 
						|
      source: payload.source,
 | 
						|
      contextId: lazy.contextId,
 | 
						|
    };
 | 
						|
 | 
						|
    let sendGleanPing = valuesByGleanKey => {
 | 
						|
      valuesByGleanKey = { ...defaultValuesByGleanKey, ...valuesByGleanKey };
 | 
						|
      for (let [gleanKey, value] of Object.entries(valuesByGleanKey)) {
 | 
						|
        let glean = Glean.quickSuggest[gleanKey];
 | 
						|
        if (value !== undefined && value !== "") {
 | 
						|
          glean.set(value);
 | 
						|
        }
 | 
						|
      }
 | 
						|
      GleanPings.quickSuggest.submit();
 | 
						|
    };
 | 
						|
 | 
						|
    // impression
 | 
						|
    sendGleanPing({
 | 
						|
      pingType: lazy.CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION,
 | 
						|
      isClicked: resultClicked,
 | 
						|
      reportingUrl: result.payload.sponsoredImpressionUrl,
 | 
						|
    });
 | 
						|
 | 
						|
    // click
 | 
						|
    if (resultClicked) {
 | 
						|
      sendGleanPing({
 | 
						|
        pingType: lazy.CONTEXTUAL_SERVICES_PING_TYPES.QS_SELECTION,
 | 
						|
        reportingUrl: result.payload.sponsoredClickUrl,
 | 
						|
      });
 | 
						|
    }
 | 
						|
 | 
						|
    // dismiss
 | 
						|
    if (resultSelType == "dismiss") {
 | 
						|
      sendGleanPing({
 | 
						|
        pingType: lazy.CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK,
 | 
						|
        iabCategory: result.payload.sponsoredIabCategory,
 | 
						|
      });
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Helper for engagement telemetry that records telemetry specific to
 | 
						|
   * navigational suggestions.
 | 
						|
   *
 | 
						|
   * @param {object} options
 | 
						|
   *   Options object
 | 
						|
   * @param {UrlbarQueryContext} options.queryContext
 | 
						|
   *   The query context.
 | 
						|
   * @param {UrlbarResult} options.result
 | 
						|
   *   The quick suggest result related to the engagement, or null if no result
 | 
						|
   *   was present.
 | 
						|
   * @param {boolean} options.resultClicked
 | 
						|
   *   True if the main part of the result's row was clicked; false if a button
 | 
						|
   *   like help or dismiss was clicked or if no part of the row was clicked.
 | 
						|
   * @param {object} options.details
 | 
						|
   *   The `details` object that was passed to `onLegacyEngagement()`. It must
 | 
						|
   *   look like this: `{ selType, selIndex }`
 | 
						|
   */
 | 
						|
  #recordNavSuggestionTelemetry({
 | 
						|
    queryContext,
 | 
						|
    result,
 | 
						|
    resultClicked,
 | 
						|
    details,
 | 
						|
  }) {
 | 
						|
    let scalars = [];
 | 
						|
    let heuristicClicked =
 | 
						|
      details.selIndex == 0 && queryContext.heuristicResult;
 | 
						|
 | 
						|
    if (result?.payload.telemetryType == "top_picks") {
 | 
						|
      // nav suggestion shown
 | 
						|
      scalars.push(TELEMETRY_SCALARS.IMPRESSION_NAV_SHOWN);
 | 
						|
      if (resultClicked) {
 | 
						|
        scalars.push(TELEMETRY_SCALARS.CLICK_NAV_SHOWN_NAV);
 | 
						|
      } else if (heuristicClicked) {
 | 
						|
        scalars.push(TELEMETRY_SCALARS.CLICK_NAV_SHOWN_HEURISTIC);
 | 
						|
      }
 | 
						|
    } else if (
 | 
						|
      this.#resultFromLastQuery?.payload.telemetryType == "top_picks" &&
 | 
						|
      this.#resultFromLastQuery?.payload.dupedHeuristic
 | 
						|
    ) {
 | 
						|
      // nav suggestion duped heuristic
 | 
						|
      scalars.push(TELEMETRY_SCALARS.IMPRESSION_NAV_SUPERCEDED);
 | 
						|
      if (heuristicClicked) {
 | 
						|
        scalars.push(TELEMETRY_SCALARS.CLICK_NAV_SUPERCEDED);
 | 
						|
      }
 | 
						|
    } else {
 | 
						|
      // nav suggestion not matched or otherwise not shown
 | 
						|
      scalars.push(TELEMETRY_SCALARS.IMPRESSION_NAV_NOTMATCHED);
 | 
						|
      if (heuristicClicked) {
 | 
						|
        scalars.push(TELEMETRY_SCALARS.CLICK_NAV_NOTMATCHED);
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    let heuristicType = UrlbarUtils.searchEngagementTelemetryType(
 | 
						|
      queryContext.heuristicResult
 | 
						|
    );
 | 
						|
    for (let scalar of scalars) {
 | 
						|
      Services.telemetry.keyedScalarAdd(scalar, heuristicType, 1);
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Cancels the current query.
 | 
						|
   */
 | 
						|
  cancelQuery() {
 | 
						|
    // Cancel the Rust query.
 | 
						|
    let backend = lazy.QuickSuggest.getFeature("SuggestBackendRust");
 | 
						|
    if (backend?.isEnabled) {
 | 
						|
      backend.cancelQuery();
 | 
						|
    }
 | 
						|
 | 
						|
    // Cancel the Merino timeout timer so it doesn't fire and record a timeout.
 | 
						|
    // If it's already canceled or has fired, this is a no-op.
 | 
						|
    this.#merino?.cancelTimeoutTimer();
 | 
						|
 | 
						|
    // Don't abort the Merino fetch if one is ongoing. By design we allow
 | 
						|
    // fetches to finish so we can record their latency.
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Fetches Merino suggestions.
 | 
						|
   *
 | 
						|
   * @param {UrlbarQueryContext} queryContext
 | 
						|
   *   The query context.
 | 
						|
   * @param {string} searchString
 | 
						|
   *   The search string.
 | 
						|
   * @returns {Array}
 | 
						|
   *   The Merino suggestions or null if there's an error or unexpected
 | 
						|
   *   response.
 | 
						|
   */
 | 
						|
  async _fetchMerinoSuggestions(queryContext, searchString) {
 | 
						|
    if (!this.#merino) {
 | 
						|
      this.#merino = new lazy.MerinoClient(this.name);
 | 
						|
    }
 | 
						|
 | 
						|
    let providers;
 | 
						|
    if (
 | 
						|
      !lazy.UrlbarPrefs.get("suggest.quicksuggest.nonsponsored") &&
 | 
						|
      !lazy.UrlbarPrefs.get("suggest.quicksuggest.sponsored") &&
 | 
						|
      !lazy.UrlbarPrefs.get("merinoProviders")
 | 
						|
    ) {
 | 
						|
      // Data collection is enabled but suggestions are not. Use an empty list
 | 
						|
      // of providers to tell Merino not to fetch any suggestions.
 | 
						|
      providers = [];
 | 
						|
    }
 | 
						|
 | 
						|
    let suggestions = await this.#merino.fetch({
 | 
						|
      providers,
 | 
						|
      query: searchString,
 | 
						|
    });
 | 
						|
 | 
						|
    return suggestions;
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Returns whether a given suggestion can be added for a query, assuming the
 | 
						|
   * provider itself should be active.
 | 
						|
   *
 | 
						|
   * @param {object} suggestion
 | 
						|
   *   The suggestion to check.
 | 
						|
   * @returns {boolean}
 | 
						|
   *   Whether the suggestion can be added.
 | 
						|
   */
 | 
						|
  async _canAddSuggestion(suggestion) {
 | 
						|
    this.logger.info("Checking if suggestion can be added");
 | 
						|
    this.logger.debug(JSON.stringify({ suggestion }));
 | 
						|
 | 
						|
    // Return false if suggestions are disabled.
 | 
						|
    if (
 | 
						|
      (suggestion.is_sponsored &&
 | 
						|
        !lazy.UrlbarPrefs.get("suggest.quicksuggest.sponsored")) ||
 | 
						|
      (!suggestion.is_sponsored &&
 | 
						|
        !lazy.UrlbarPrefs.get("suggest.quicksuggest.nonsponsored"))
 | 
						|
    ) {
 | 
						|
      this.logger.info("Suggestions disabled, not adding suggestion");
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
 | 
						|
    // Return false if an impression cap has been hit.
 | 
						|
    if (
 | 
						|
      (suggestion.is_sponsored &&
 | 
						|
        lazy.UrlbarPrefs.get("quickSuggestImpressionCapsSponsoredEnabled")) ||
 | 
						|
      (!suggestion.is_sponsored &&
 | 
						|
        lazy.UrlbarPrefs.get("quickSuggestImpressionCapsNonSponsoredEnabled"))
 | 
						|
    ) {
 | 
						|
      let type = suggestion.is_sponsored ? "sponsored" : "nonsponsored";
 | 
						|
      let hitStats = lazy.QuickSuggest.impressionCaps.getHitStats(type);
 | 
						|
      if (hitStats) {
 | 
						|
        this.logger.info("Impression cap(s) hit, not adding suggestion");
 | 
						|
        this.logger.debug(JSON.stringify({ type, hitStats }));
 | 
						|
        return false;
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    // Return false if the suggestion is blocked based on its URL. Suggestions
 | 
						|
    // from the JS backend define a single `url` property. Suggestions from the
 | 
						|
    // Rust backend are more complicated: Sponsored suggestions define `rawUrl`,
 | 
						|
    // which may contain timestamp templates, while non-sponsored suggestions
 | 
						|
    // define only `url`. Blocking should always be based on URLs with timestamp
 | 
						|
    // templates, where applicable, so check `rawUrl` and then `url`, in that
 | 
						|
    // order.
 | 
						|
    let { blockedSuggestions } = lazy.QuickSuggest;
 | 
						|
    if (await blockedSuggestions.has(suggestion.rawUrl ?? suggestion.url)) {
 | 
						|
      this.logger.info("Suggestion blocked, not adding suggestion");
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
 | 
						|
    this.logger.info("Suggestion can be added");
 | 
						|
    return true;
 | 
						|
  }
 | 
						|
 | 
						|
  get _test_merino() {
 | 
						|
    return this.#merino;
 | 
						|
  }
 | 
						|
 | 
						|
  // The result we added during the most recent query.
 | 
						|
  #resultFromLastQuery = null;
 | 
						|
 | 
						|
  // The Merino client.
 | 
						|
  #merino = null;
 | 
						|
}
 | 
						|
 | 
						|
export var UrlbarProviderQuickSuggest = new ProviderQuickSuggest();
 |