tor-browser

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

UrlbarProviderQuickSuggest.sys.mjs (20679B)


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