tor-browser

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

YelpSuggestions.sys.mjs (20160B)


      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 { SuggestProvider } from "moz-src:///browser/components/urlbar/private/SuggestFeature.sys.mjs";
      6 
      7 const lazy = {};
      8 
      9 ChromeUtils.defineESModuleGetters(lazy, {
     10  GeolocationUtils:
     11    "moz-src:///browser/components/urlbar/private/GeolocationUtils.sys.mjs",
     12  GeonameMatchType:
     13    "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs",
     14  QuickSuggest: "moz-src:///browser/components/urlbar/QuickSuggest.sys.mjs",
     15  UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs",
     16  UrlbarResult: "moz-src:///browser/components/urlbar/UrlbarResult.sys.mjs",
     17  UrlbarUtils: "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs",
     18  YelpSubjectType:
     19    "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs",
     20 });
     21 
     22 /**
     23 * @import {GeonameMatch} from "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs"
     24 */
     25 
     26 const RESULT_MENU_COMMAND = {
     27  INACCURATE_LOCATION: "inaccurate_location",
     28  MANAGE: "manage",
     29  NOT_INTERESTED: "not_interested",
     30  NOT_RELEVANT: "not_relevant",
     31  SHOW_LESS_FREQUENTLY: "show_less_frequently",
     32 };
     33 
     34 /**
     35 * A feature for Yelp suggestions.
     36 */
     37 export class YelpSuggestions extends SuggestProvider {
     38  get enablingPreferences() {
     39    return [
     40      "yelpFeatureGate",
     41      "suggest.yelp",
     42      "suggest.quicksuggest.all",
     43      "suggest.quicksuggest.sponsored",
     44    ];
     45  }
     46 
     47  get primaryUserControlledPreferences() {
     48    return ["suggest.yelp"];
     49  }
     50 
     51  get rustSuggestionType() {
     52    return "Yelp";
     53  }
     54 
     55  get mlIntent() {
     56    return "yelp_intent";
     57  }
     58 
     59  get isMlIntentEnabled() {
     60    // Note that even when ML is enabled, we still leave Yelp Rust suggestions
     61    // enabled because we need to fetch the Yelp icon, URL, etc. from Rust, as
     62    // well as geonames, and Rust still needs to ingest all of that.
     63    return lazy.UrlbarPrefs.get("yelpMlEnabled");
     64  }
     65 
     66  get showLessFrequentlyCount() {
     67    const count = lazy.UrlbarPrefs.get("yelp.showLessFrequentlyCount") || 0;
     68    return Math.max(count, 0);
     69  }
     70 
     71  get canShowLessFrequently() {
     72    const cap =
     73      lazy.UrlbarPrefs.get("yelpShowLessFrequentlyCap") ||
     74      lazy.QuickSuggest.config.showLessFrequentlyCap ||
     75      0;
     76    return !cap || this.showLessFrequentlyCount < cap;
     77  }
     78 
     79  isSuggestionSponsored(_suggestion) {
     80    return true;
     81  }
     82 
     83  getSuggestionTelemetryType() {
     84    return "yelp";
     85  }
     86 
     87  enable(enabled) {
     88    if (!enabled) {
     89      this.#metadataCache = null;
     90    }
     91  }
     92 
     93  async filterSuggestions(suggestions) {
     94    // Important notes:
     95    //
     96    // Both Rust and ML return at most one Yelp suggestion each.
     97    //
     98    // We leave Rust Yelp suggestions enabled even when ML Yelp is enabled
     99    // because we need to fetch the Yelp icon, URL, etc. from Rust, as well as
    100    // geonames, and Rust still needs to ingest all of that. Since we don't have
    101    // a way to tell the Rust backend to leave a suggestion type enabled without
    102    // querying it, `suggestions` can contain both kinds of suggestions. If ML
    103    // is enabled, return the ML suggestion; if it's disabled, return Rust.
    104    //
    105    // After this method returns, the Suggest provider will sort suggestions by
    106    // score and check whether they've been previously dismissed based on their
    107    // URLs. So we need to make sure suggestions have scores and URLs now. For
    108    // both Rust and ML suggestions, we'll make sure URLs at this point do *not*
    109    // contain a location param because we'll likely end up setting a new param
    110    // in `makeResult()`. That means for the purpose of dismissal, Yelp URLs
    111    // will exclude location.
    112    //
    113    // Since we're doing all the above in this method anyway, we'll also
    114    // normalize the suggestion so that `makeResult()` can easily handle either
    115    // kind of suggestion.
    116 
    117    let suggestion;
    118    if (!lazy.UrlbarPrefs.get("yelpMlEnabled")) {
    119      suggestion = suggestions.find(s => s.source != "ml");
    120      if (suggestion) {
    121        suggestion = this.#normalizeRustSuggestion(suggestion);
    122      }
    123    } else {
    124      suggestion = suggestions.find(s => s.source == "ml");
    125      if (suggestion) {
    126        if (!this.#metadataCache) {
    127          this.#metadataCache = await this.#makeMetadataCache();
    128        }
    129        suggestion = this.#normalizeMlSuggestion(suggestion);
    130      }
    131    }
    132 
    133    return suggestion ? [suggestion] : [];
    134  }
    135 
    136  async makeResult(queryContext, suggestion, searchString) {
    137    // If the user clicked "Show less frequently" at least once or if the
    138    // subject wasn't typed in full, then apply the min length threshold and
    139    // return null if the entire search string is too short.
    140    if (
    141      (this.showLessFrequentlyCount || !suggestion.subjectExactMatch) &&
    142      searchString.length < this.#minKeywordLength
    143    ) {
    144      return null;
    145    }
    146 
    147    let { city, region } = suggestion;
    148    if (!city && !region) {
    149      // The user didn't specify any location at all, so use geolocation. If we
    150      // can't get the geolocation for some reason, that's fine, the suggestion
    151      // just won't have a location.
    152      let geo = await lazy.GeolocationUtils.geolocation();
    153      if (geo) {
    154        city = geo.city;
    155        region = geo.region_code;
    156      }
    157    } else {
    158      // The user specified a city and/or region -- at least we think they did.
    159      // If we can't find a matching location, assume they're typing something
    160      // unrelated to Yelp and discard the suggestion by returning null.
    161      let match = await this.#bestCityRegion(city, region);
    162      if (!match) {
    163        return null;
    164      }
    165      city = match.city;
    166      region = match.region;
    167    }
    168 
    169    let url = new URL(suggestion.url);
    170 
    171    let title = suggestion.title;
    172    let locationStr = [city, region].filter(s => !!s).join(", ");
    173    if (locationStr) {
    174      url.searchParams.set(suggestion.locationParam, locationStr);
    175      if (!suggestion.hasLocationSign) {
    176        title += " in";
    177      }
    178      title += " " + locationStr;
    179    }
    180 
    181    url.searchParams.set("utm_medium", "partner");
    182    url.searchParams.set("utm_source", "mozilla");
    183 
    184    let resultProperties = {
    185      isRichSuggestion: true,
    186      showFeedbackMenu: true,
    187      isBestMatch: lazy.UrlbarPrefs.get("yelpSuggestPriority"),
    188    };
    189    if (!resultProperties.isBestMatch) {
    190      let suggestedIndex = lazy.UrlbarPrefs.get("yelpSuggestNonPriorityIndex");
    191      if (suggestedIndex !== null) {
    192        resultProperties.isSuggestedIndexRelativeToGroup = true;
    193        resultProperties.suggestedIndex = suggestedIndex;
    194      }
    195    }
    196 
    197    let payload = {
    198      url: url.toString(),
    199      originalUrl: suggestion.url,
    200      bottomTextL10n: {
    201        id: "firefox-suggest-yelp-bottom-text",
    202      },
    203      iconBlob: suggestion.icon_blob,
    204    };
    205    let highlights;
    206 
    207    if (
    208      lazy.UrlbarPrefs.get("yelpServiceResultDistinction") &&
    209      suggestion.subjectType === lazy.YelpSubjectType.SERVICE
    210    ) {
    211      let titleHighlights = lazy.UrlbarUtils.getTokenMatches(
    212        queryContext.tokens,
    213        title,
    214        lazy.UrlbarUtils.HIGHLIGHT.TYPED
    215      );
    216      payload.titleL10n = {
    217        id: "firefox-suggest-yelp-service-title",
    218        args: {
    219          service: title,
    220        },
    221        argsHighlights: {
    222          service: titleHighlights,
    223        },
    224      };
    225      // Used for the tooltip.
    226      payload.title = title;
    227    } else {
    228      payload.title = title;
    229      highlights = {
    230        title: lazy.UrlbarUtils.HIGHLIGHT.TYPED,
    231      };
    232    }
    233 
    234    return new lazy.UrlbarResult({
    235      type: lazy.UrlbarUtils.RESULT_TYPE.URL,
    236      source: lazy.UrlbarUtils.RESULT_SOURCE.SEARCH,
    237      ...resultProperties,
    238      payload,
    239      highlights,
    240    });
    241  }
    242 
    243  /**
    244   * @typedef {object} L10nItem
    245   * @property {Values<RESULT_MENU_COMMAND>} [name]
    246   *   The name of the command.
    247   * @property {{id: string}} [l10n]
    248   *   The id of the l10n string to use for the translation.
    249   */
    250 
    251  /**
    252   * Gets the list of commands that should be shown in the result menu for a
    253   * given result from the provider. All commands returned by this method should
    254   * be handled by implementing `onEngagement()` with the possible exception of
    255   * commands automatically handled by the urlbar, like "help".
    256   */
    257  getResultCommands() {
    258    /** @type {UrlbarResultCommand[]} */
    259    let commands = [
    260      {
    261        name: RESULT_MENU_COMMAND.INACCURATE_LOCATION,
    262        l10n: {
    263          id: "urlbar-result-menu-report-inaccurate-location",
    264        },
    265      },
    266    ];
    267 
    268    if (this.canShowLessFrequently) {
    269      commands.push({
    270        name: RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY,
    271        l10n: {
    272          id: "urlbar-result-menu-show-less-frequently",
    273        },
    274      });
    275    }
    276 
    277    commands.push(
    278      {
    279        l10n: {
    280          id: "firefox-suggest-command-dont-show-this",
    281        },
    282        children: [
    283          {
    284            name: RESULT_MENU_COMMAND.NOT_RELEVANT,
    285            l10n: {
    286              id: "firefox-suggest-command-not-relevant",
    287            },
    288          },
    289          {
    290            name: RESULT_MENU_COMMAND.NOT_INTERESTED,
    291            l10n: {
    292              id: "firefox-suggest-command-not-interested",
    293            },
    294          },
    295        ],
    296      },
    297      { name: "separator" },
    298      {
    299        name: RESULT_MENU_COMMAND.MANAGE,
    300        l10n: {
    301          id: "urlbar-result-menu-manage-firefox-suggest",
    302        },
    303      }
    304    );
    305 
    306    return commands;
    307  }
    308 
    309  onEngagement(queryContext, controller, details, searchString) {
    310    let { result } = details;
    311    switch (details.selType) {
    312      case RESULT_MENU_COMMAND.MANAGE:
    313        // "manage" is handled by UrlbarInput, no need to do anything here.
    314        break;
    315      case RESULT_MENU_COMMAND.INACCURATE_LOCATION:
    316        // Currently the only way we record this feedback is in the Glean
    317        // engagement event. As with all commands, it will be recorded with an
    318        // `engagement_type` value that is the command's name, in this case
    319        // `inaccurate_location`.
    320        controller.view.acknowledgeFeedback(result);
    321        break;
    322      // selType == "dismiss" when the user presses the dismiss key shortcut.
    323      case "dismiss":
    324      case RESULT_MENU_COMMAND.NOT_RELEVANT:
    325        lazy.QuickSuggest.dismissResult(result);
    326        result.acknowledgeDismissalL10n = {
    327          id: "firefox-suggest-dismissal-acknowledgment-one-yelp",
    328        };
    329        controller.removeResult(result);
    330        break;
    331      case RESULT_MENU_COMMAND.NOT_INTERESTED:
    332        lazy.UrlbarPrefs.set("suggest.yelp", false);
    333        result.acknowledgeDismissalL10n = {
    334          id: "firefox-suggest-dismissal-acknowledgment-all-yelp",
    335        };
    336        controller.removeResult(result);
    337        break;
    338      case RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY:
    339        controller.view.acknowledgeFeedback(result);
    340        this.incrementShowLessFrequentlyCount();
    341        if (!this.canShowLessFrequently) {
    342          controller.view.invalidateResultMenuCommands();
    343        }
    344        lazy.UrlbarPrefs.set("yelp.minKeywordLength", searchString.length + 1);
    345        break;
    346    }
    347  }
    348 
    349  incrementShowLessFrequentlyCount() {
    350    if (this.canShowLessFrequently) {
    351      lazy.UrlbarPrefs.set(
    352        "yelp.showLessFrequentlyCount",
    353        this.showLessFrequentlyCount + 1
    354      );
    355    }
    356  }
    357 
    358  get #minKeywordLength() {
    359    // Use the pref value if it has a user value (which means the user clicked
    360    // "Show less frequently") or if there's no Nimbus value. Otherwise use the
    361    // Nimbus value. This lets us override the pref's default value using Nimbus
    362    // if necessary.
    363    let hasUserValue = Services.prefs.prefHasUserValue(
    364      "browser.urlbar.yelp.minKeywordLength"
    365    );
    366    let nimbusValue = lazy.UrlbarPrefs.get("yelpMinKeywordLength");
    367    let minLength =
    368      hasUserValue || nimbusValue === null
    369        ? lazy.UrlbarPrefs.get("yelp.minKeywordLength")
    370        : nimbusValue;
    371    return Math.max(minLength, 0);
    372  }
    373 
    374  #normalizeRustSuggestion(suggestion) {
    375    // TODO: The Rust component should be updated to return Yelp suggestions
    376    // that don't require us to make these modifications.
    377 
    378    // Rust Yelp suggestions don't currently specify the city and region
    379    // separately. Instead the location param in the URL contains whatever was
    380    // left over at the end of the search string. We'll assume it's a city. If
    381    // it's actually a region, then unfortunately we'll discard the suggestion
    382    // because it won't match any cities in our DB, but it's much more likely
    383    // for it to be a city.
    384    let url = new URL(suggestion.url);
    385    let loc = url.searchParams.get(suggestion.locationParam);
    386    if (loc) {
    387      // Normalized suggestion URLs should not include the location. See
    388      // `filterSuggestions()`.
    389      url.searchParams.delete(suggestion.locationParam);
    390      suggestion.url = url.toString();
    391      suggestion.city = loc;
    392 
    393      // Rust includes the location in the title, but we'll want to replace it
    394      // with the location we compute in `makeResult()`, so remove it.
    395      if (suggestion.title.endsWith(loc)) {
    396        suggestion.title = suggestion.title
    397          .substring(0, suggestion.title.length - loc.length)
    398          .trimEnd();
    399      }
    400    }
    401 
    402    return suggestion;
    403  }
    404 
    405  #normalizeMlSuggestion(ml) {
    406    // The ML model can return false positives, including Yelp-intent
    407    // suggestions with nothing but a city or region, no subject. Discard them.
    408    if (!ml.subject) {
    409      return null;
    410    }
    411 
    412    let url = new URL(this.#metadataCache.urlOrigin);
    413    url.pathname = this.#metadataCache.urlPathname;
    414    url.searchParams.set(this.#metadataCache.findDesc, ml.subject);
    415 
    416    return {
    417      ...ml,
    418      title: ml.subject,
    419      url: url.toString(),
    420      subjectExactMatch: false,
    421      hasLocationSign: false,
    422      locationParam: this.#metadataCache.findLoc,
    423      icon_blob: this.#metadataCache.iconBlob,
    424      score: this.#metadataCache.score,
    425      city: ml.location?.city,
    426      region: ml.location?.state,
    427    };
    428  }
    429 
    430  /**
    431   * TODO Bug 1926782: ML suggestions don't include an icon, score, or URL, so
    432   * for now we directly query the Rust backend with a known Yelp keyword and
    433   * location to get all of that information and then cache it in
    434   * `#metadataCache`. If the known Yelp suggestion is absent for some reason,
    435   * we fall back to hardcoded values. This is a tad hacky and we should come up
    436   * with something better.
    437   */
    438  async #makeMetadataCache() {
    439    let cache;
    440 
    441    this.logger.debug("Querying Rust backend to populate metadata cache");
    442    let rs = await lazy.QuickSuggest.rustBackend.query("coffee in atlanta", {
    443      types: ["Yelp"],
    444    });
    445    if (!rs.length) {
    446      this.logger.debug("Rust didn't return any Yelp suggestions!");
    447      cache = {};
    448    } else {
    449      let suggestion = rs[0];
    450      let url = new URL(suggestion.url);
    451      let findParamWithValue = value => {
    452        let tuple = [...url.searchParams.entries()].find(
    453          ([_, v]) => v == value
    454        );
    455        return tuple?.[0];
    456      };
    457      cache = {
    458        iconBlob: suggestion.icon_blob,
    459        score: suggestion.score,
    460        urlOrigin: url.origin,
    461        urlPathname: url.pathname,
    462        findDesc: findParamWithValue("coffee"),
    463        findLoc: findParamWithValue("atlanta"),
    464      };
    465    }
    466 
    467    let defaults = {
    468      urlOrigin: "https://www.yelp.com",
    469      urlPathname: "/search",
    470      findDesc: "find_desc",
    471      findLoc: "find_loc",
    472      score: 0.25,
    473    };
    474    for (let [key, value] of Object.entries(defaults)) {
    475      if (cache[key] === undefined) {
    476        cache[key] = value;
    477      }
    478    }
    479 
    480    return cache;
    481  }
    482 
    483  /**
    484   * Looks up a city-region in the Suggest database and returns the one that
    485   * best matches the client's geolocation.
    486   *
    487   * @param {string|null} city
    488   *   The candidate city name or null if you're only matching regions.
    489   * @param {string|null} region
    490   *   The candidate region name or abbreviation, or null if you're only
    491   *   matching cities.
    492   * @returns {Promise<{city: string|null, region: string|null}|null>}
    493   *   If a city was passed in and it didn't match a city in the DB, or if a
    494   *   region was passed in and it didn't match a region in the DB, null is
    495   *   returned. Null is also returned if both were passed but they aren't a
    496   *   valid city-region combination. Otherwise, an object `{ city, region }` is
    497   *   returned:
    498   *
    499   *   city
    500   *     The best matching city's name, or if the passed-in city was null and a
    501   *     region was matched, this will be null.
    502   *   region
    503   *     The best matching region. If a city was matched, it will be the ISO
    504   *     code of the city's region (e.g., the usual two-letter abbreviation for
    505   *     U.S. states). If a city wasn't passed in, this will be the best
    506   *     matching region's name.
    507   */
    508  async #bestCityRegion(city, region) {
    509    // Match the region first since we'll use region matches to filter city
    510    // matches. We'll do prefix matching on cities below, so to avoid even more
    511    // time and work that's probably unnecessary, don't do it for regions.
    512    let regionMatches;
    513    if (region) {
    514      regionMatches = await lazy.QuickSuggest.rustBackend.fetchGeonames(
    515        region,
    516        false, // prefix matching
    517        null // geonames filter array
    518      );
    519      if (!regionMatches.length) {
    520        // The user typed something we thought was a region but isn't, so assume
    521        // the query is not Yelp-related after all.
    522        return null;
    523      }
    524    }
    525 
    526    if (city) {
    527      let cityMatches = await lazy.QuickSuggest.rustBackend.fetchGeonames(
    528        city,
    529        true, // prefix matching
    530        regionMatches?.map(m => m.geoname)
    531      );
    532      // Discard prefix matches on any names that aren't full names, i.e., on
    533      // abbreviations and airport codes. Airport codes especially can sometimes
    534      // be surprising (e.g., "act" for Waco, TX), and we don't want to return
    535      // too many false positives.
    536      cityMatches = cityMatches.filter(
    537        match => match.matchType == lazy.GeonameMatchType.NAME || !match.prefix
    538      );
    539      if (!cityMatches.length) {
    540        // The user typed something we thought was a city but isn't, so assume
    541        // the query is not Yelp-related after all.
    542        return null;
    543      }
    544 
    545      // Return the best city for the user's geolocation.
    546      let best = await lazy.GeolocationUtils.best(
    547        cityMatches,
    548        locationFromGeonameMatch
    549      );
    550      return {
    551        city: best.geoname.name,
    552        region: best.geoname.adminDivisionCodes.get(1),
    553      };
    554    }
    555 
    556    // We didn't detect a city in the query but we detected a region, so try to
    557    // return at least that, but only if a full name was matched, not an
    558    // abbreviation. Abbreviations are too short and make it too easy to return
    559    // false positives. For example, after the user types "ramen in", we
    560    // probably shouldn't match "in" to Indiana.
    561    regionMatches = regionMatches?.filter(
    562      match => match.matchType == lazy.GeonameMatchType.NAME
    563    );
    564    if (regionMatches?.length) {
    565      let best = await lazy.GeolocationUtils.best(
    566        regionMatches,
    567        locationFromGeonameMatch
    568      );
    569      return { city: null, region: best.geoname.name };
    570    }
    571 
    572    return null;
    573  }
    574 
    575  _test_invalidateMetadataCache() {
    576    this.#metadataCache = null;
    577  }
    578 
    579  #metadataCache = null;
    580 }
    581 
    582 /**
    583 * A function that can be passed to `GeolocationUtils.best()` as
    584 * `locationFromItem`. It maps `GeonameMatch` objects to the location objects
    585 * required by that function.
    586 *
    587 * @param {GeonameMatch} match
    588 *   A match object.
    589 * @returns {object}
    590 *   A location object suitable for `GeolocationUtils`.
    591 */
    592 function locationFromGeonameMatch(match) {
    593  return {
    594    latitude: match.geoname.latitude,
    595    longitude: match.geoname.longitude,
    596    country: match.geoname.countryCode,
    597    region: match.geoname.adminDivisionCodes.get(1),
    598    population: match.geoname.population,
    599  };
    600 }