tor-browser

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

WeatherSuggestions.sys.mjs (18595B)


      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  MerinoClient: "moz-src:///browser/components/urlbar/MerinoClient.sys.mjs",
     13  QuickSuggest: "moz-src:///browser/components/urlbar/QuickSuggest.sys.mjs",
     14  Region: "resource://gre/modules/Region.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  UrlbarView: "moz-src:///browser/components/urlbar/UrlbarView.sys.mjs",
     19 });
     20 
     21 const MERINO_PROVIDER = "accuweather";
     22 const MERINO_TIMEOUT_MS = 5000; // 5s
     23 
     24 // Cache period for Merino's weather response. This is intentionally a small
     25 // amount of time. See the `cachePeriodMs` discussion in `MerinoClient`. In
     26 // addition, caching also helps prevent the weather suggestion from flickering
     27 // out of and into the view as the user matches the same suggestion with each
     28 // keystroke, especially when Merino has high latency.
     29 const MERINO_WEATHER_CACHE_PERIOD_MS = 60000; // 1 minute
     30 
     31 const RESULT_MENU_COMMAND = {
     32  DISMISS: "dismiss",
     33  HELP: "help",
     34  INACCURATE_LOCATION: "inaccurate_location",
     35  MANAGE: "manage",
     36  SHOW_LESS_FREQUENTLY: "show_less_frequently",
     37 };
     38 
     39 const WEATHER_PROVIDER_DISPLAY_NAME = "AccuWeather®";
     40 
     41 const NORTH_AMERICA_COUNTRY_CODES = new Set(["CA", "US"]);
     42 
     43 const WEATHER_DYNAMIC_TYPE = "weather";
     44 const WEATHER_VIEW_TEMPLATE = {
     45  attributes: {
     46    selectable: true,
     47  },
     48  children: [
     49    {
     50      name: "currentConditions",
     51      tag: "span",
     52      children: [
     53        {
     54          name: "currently",
     55          tag: "div",
     56        },
     57        {
     58          name: "currentTemperature",
     59          tag: "div",
     60          children: [
     61            {
     62              name: "temperature",
     63              tag: "span",
     64            },
     65            {
     66              name: "weatherIcon",
     67              tag: "img",
     68            },
     69          ],
     70        },
     71      ],
     72    },
     73    {
     74      name: "summary",
     75      tag: "span",
     76      overflowable: true,
     77      children: [
     78        {
     79          name: "top",
     80          tag: "div",
     81          children: [
     82            {
     83              name: "topNoWrap",
     84              tag: "span",
     85              children: [
     86                { name: "title", tag: "span", classList: ["urlbarView-title"] },
     87                {
     88                  name: "titleSeparator",
     89                  tag: "span",
     90                  classList: ["urlbarView-title-separator"],
     91                },
     92              ],
     93            },
     94            {
     95              name: "url",
     96              tag: "span",
     97              classList: ["urlbarView-url"],
     98            },
     99          ],
    100        },
    101        {
    102          name: "middle",
    103          tag: "div",
    104          children: [
    105            {
    106              name: "middleNoWrap",
    107              tag: "span",
    108              overflowable: true,
    109              children: [
    110                {
    111                  name: "summaryText",
    112                  tag: "span",
    113                },
    114                {
    115                  name: "summaryTextSeparator",
    116                  tag: "span",
    117                },
    118                {
    119                  name: "highLow",
    120                  tag: "span",
    121                },
    122              ],
    123            },
    124            {
    125              name: "highLowWrap",
    126              tag: "span",
    127            },
    128          ],
    129        },
    130        {
    131          name: "bottom",
    132          tag: "div",
    133        },
    134      ],
    135    },
    136  ],
    137 };
    138 
    139 /**
    140 * A feature that periodically fetches weather suggestions from Merino.
    141 */
    142 export class WeatherSuggestions extends SuggestProvider {
    143  constructor() {
    144    super();
    145    lazy.UrlbarResult.addDynamicResultType(WEATHER_DYNAMIC_TYPE);
    146    lazy.UrlbarView.addDynamicViewTemplate(
    147      WEATHER_DYNAMIC_TYPE,
    148      WEATHER_VIEW_TEMPLATE
    149    );
    150  }
    151 
    152  get enablingPreferences() {
    153    return [
    154      "weatherFeatureGate",
    155      "suggest.weather",
    156      "suggest.quicksuggest.all",
    157      "suggest.quicksuggest.sponsored",
    158    ];
    159  }
    160 
    161  get primaryUserControlledPreferences() {
    162    return ["suggest.weather"];
    163  }
    164 
    165  get rustSuggestionType() {
    166    return "Weather";
    167  }
    168 
    169  get showLessFrequentlyCount() {
    170    const count = lazy.UrlbarPrefs.get("weather.showLessFrequentlyCount") || 0;
    171    return Math.max(count, 0);
    172  }
    173 
    174  get canShowLessFrequently() {
    175    const cap =
    176      lazy.UrlbarPrefs.get("weatherShowLessFrequentlyCap") ||
    177      lazy.QuickSuggest.config.showLessFrequentlyCap ||
    178      0;
    179    return !cap || this.showLessFrequentlyCount < cap;
    180  }
    181 
    182  isSuggestionSponsored(_suggestion) {
    183    return true;
    184  }
    185 
    186  getSuggestionTelemetryType() {
    187    return "weather";
    188  }
    189 
    190  enable(enabled) {
    191    if (!enabled) {
    192      this.#merino = null;
    193    }
    194  }
    195 
    196  async filterSuggestions(suggestions) {
    197    // If the query didn't include a city, Rust will return at most one
    198    // suggestion. If the query matched multiple cities, Rust will return one
    199    // suggestion per city. All suggestions will have the same score, and
    200    // they'll be ordered by population size from largest to smallest.
    201    if (suggestions.length <= 1) {
    202      return suggestions;
    203    }
    204 
    205    let suggestion = await lazy.GeolocationUtils.best(suggestions, s => ({
    206      latitude: s.city?.latitude,
    207      longitude: s.city?.longitude,
    208      country: s.city?.countryCode,
    209      region: s.city?.adminDivisionCodes.get(1),
    210      population: s.city?.population,
    211    }));
    212 
    213    return [suggestion];
    214  }
    215 
    216  async makeResult(queryContext, suggestion, searchString) {
    217    if (searchString.length < this.#minKeywordLength) {
    218      return null;
    219    }
    220 
    221    // `suggestion` is a Rust suggestion that tells us weather intent was
    222    // matched and possibly a city. Fetch the final suggestion from Merino.
    223    let merinoSuggestion = await this.#fetchMerinoSuggestion(suggestion.city);
    224    if (!merinoSuggestion) {
    225      return null;
    226    }
    227 
    228    let unit = Services.locale.regionalPrefsLocales[0] == "en-US" ? "f" : "c";
    229 
    230    let treatment = lazy.UrlbarPrefs.get("weatherUiTreatment");
    231    if (treatment == 1 || treatment == 2) {
    232      return this.#makeDynamicResult(merinoSuggestion, unit);
    233    }
    234 
    235    let titleL10n = await this.#getTitleL10n(suggestion.city, merinoSuggestion);
    236 
    237    return new lazy.UrlbarResult({
    238      type: lazy.UrlbarUtils.RESULT_TYPE.URL,
    239      source: lazy.UrlbarUtils.RESULT_SOURCE.SEARCH,
    240      suggestedIndex: 1,
    241      isRichSuggestion: true,
    242      richSuggestionIconVariation: String(
    243        merinoSuggestion.current_conditions.icon_id
    244      ),
    245      payload: {
    246        url: merinoSuggestion.url,
    247        titleL10n: {
    248          id: titleL10n.id,
    249          args: {
    250            temperature: merinoSuggestion.current_conditions.temperature[unit],
    251            unit: unit.toUpperCase(),
    252            ...titleL10n.args,
    253          },
    254          parseMarkup: true,
    255        },
    256        bottomTextL10n: {
    257          id: "urlbar-result-weather-provider-sponsored",
    258          args: { provider: WEATHER_PROVIDER_DISPLAY_NAME },
    259        },
    260        helpUrl: lazy.QuickSuggest.HELP_URL,
    261      },
    262    });
    263  }
    264 
    265  #makeDynamicResult(suggestion, unit) {
    266    return new lazy.UrlbarResult({
    267      type: lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC,
    268      source: lazy.UrlbarUtils.RESULT_SOURCE.SEARCH,
    269      showFeedbackMenu: true,
    270      suggestedIndex: 1,
    271      payload: {
    272        url: suggestion.url,
    273        input: suggestion.url,
    274        iconId: suggestion.current_conditions.icon_id,
    275        dynamicType: WEATHER_DYNAMIC_TYPE,
    276        city: suggestion.city_name,
    277        region: suggestion.region_code,
    278        temperatureUnit: unit,
    279        temperature: suggestion.current_conditions.temperature[unit],
    280        currentConditions: suggestion.current_conditions.summary,
    281        forecast: suggestion.forecast.summary,
    282        high: suggestion.forecast.high[unit],
    283        low: suggestion.forecast.low[unit],
    284        showRowLabel: true,
    285        helpUrl: lazy.QuickSuggest.HELP_URL,
    286      },
    287    });
    288  }
    289 
    290  getViewUpdate(result) {
    291    let useSimplerUi = lazy.UrlbarPrefs.get("weatherUiTreatment") == 1;
    292    let uppercaseUnit = result.payload.temperatureUnit.toUpperCase();
    293    return {
    294      currently: {
    295        l10n: {
    296          id: "firefox-suggest-weather-currently",
    297        },
    298      },
    299      temperature: {
    300        l10n: {
    301          id: "firefox-suggest-weather-temperature",
    302          args: {
    303            value: result.payload.temperature,
    304            unit: uppercaseUnit,
    305          },
    306        },
    307      },
    308      weatherIcon: {
    309        attributes: { "icon-variation": result.payload.iconId },
    310      },
    311      title: {
    312        l10n: {
    313          id: "firefox-suggest-weather-title",
    314          args: { city: result.payload.city, region: result.payload.region },
    315        },
    316      },
    317      url: {
    318        textContent: result.payload.url,
    319      },
    320      summaryText: useSimplerUi
    321        ? { textContent: result.payload.currentConditions }
    322        : {
    323            l10n: {
    324              id: "firefox-suggest-weather-summary-text",
    325              args: {
    326                currentConditions: result.payload.currentConditions,
    327                forecast: result.payload.forecast,
    328              },
    329            },
    330          },
    331      highLow: {
    332        l10n: {
    333          id: "firefox-suggest-weather-high-low",
    334          args: {
    335            high: result.payload.high,
    336            low: result.payload.low,
    337            unit: uppercaseUnit,
    338          },
    339        },
    340      },
    341      highLowWrap: {
    342        l10n: {
    343          id: "firefox-suggest-weather-high-low",
    344          args: {
    345            high: result.payload.high,
    346            low: result.payload.low,
    347            unit: uppercaseUnit,
    348          },
    349        },
    350      },
    351      bottom: {
    352        l10n: {
    353          id: "urlbar-result-weather-provider-sponsored",
    354          args: { provider: WEATHER_PROVIDER_DISPLAY_NAME },
    355        },
    356      },
    357    };
    358  }
    359 
    360  /**
    361   * Gets the list of commands that should be shown in the result menu for a
    362   * given result from the provider. All commands returned by this method should
    363   * be handled by implementing `onEngagement()` with the possible exception of
    364   * commands automatically handled by the urlbar, like "help".
    365   */
    366  getResultCommands() {
    367    /** @type {UrlbarResultCommand[]} */
    368    let commands = [
    369      {
    370        name: RESULT_MENU_COMMAND.INACCURATE_LOCATION,
    371        l10n: {
    372          id: "urlbar-result-menu-report-inaccurate-location",
    373        },
    374      },
    375    ];
    376 
    377    if (this.canShowLessFrequently) {
    378      commands.push({
    379        name: RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY,
    380        l10n: {
    381          id: "urlbar-result-menu-show-less-frequently",
    382        },
    383      });
    384    }
    385 
    386    commands.push(
    387      {
    388        name: RESULT_MENU_COMMAND.DISMISS,
    389        l10n: {
    390          id: "urlbar-result-menu-dont-show-weather-suggestions",
    391        },
    392      },
    393      { name: "separator" },
    394      {
    395        name: RESULT_MENU_COMMAND.MANAGE,
    396        l10n: {
    397          id: "urlbar-result-menu-manage-firefox-suggest",
    398        },
    399      },
    400      {
    401        name: RESULT_MENU_COMMAND.HELP,
    402        l10n: {
    403          id: "urlbar-result-menu-learn-more-about-firefox-suggest",
    404        },
    405      }
    406    );
    407 
    408    return commands;
    409  }
    410 
    411  onEngagement(queryContext, controller, details, searchString) {
    412    let { result } = details;
    413    switch (details.selType) {
    414      case RESULT_MENU_COMMAND.HELP:
    415      case RESULT_MENU_COMMAND.MANAGE:
    416        // "help" and "manage" are handled by UrlbarInput, no need to do
    417        // anything here.
    418        break;
    419      // Note that selType == "dismiss" when the user presses the dismiss key
    420      // shortcut, in addition to the result menu command.
    421      case RESULT_MENU_COMMAND.DISMISS:
    422        this.logger.info("Dismissing weather result");
    423        lazy.UrlbarPrefs.set("suggest.weather", false);
    424        result.acknowledgeDismissalL10n = {
    425          id: "urlbar-dismissal-acknowledgment-weather",
    426        };
    427        controller.removeResult(result);
    428        break;
    429      case RESULT_MENU_COMMAND.INACCURATE_LOCATION:
    430        // Currently the only way we record this feedback is in the Glean
    431        // engagement event. As with all commands, it will be recorded with an
    432        // `engagement_type` value that is the command's name, in this case
    433        // `inaccurate_location`.
    434        controller.view.acknowledgeFeedback(result);
    435        break;
    436      case RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY:
    437        controller.view.acknowledgeFeedback(result);
    438        this.incrementShowLessFrequentlyCount();
    439        if (!this.canShowLessFrequently) {
    440          controller.view.invalidateResultMenuCommands();
    441        }
    442        lazy.UrlbarPrefs.set(
    443          "weather.minKeywordLength",
    444          searchString.length + 1
    445        );
    446        break;
    447    }
    448  }
    449 
    450  incrementShowLessFrequentlyCount() {
    451    if (this.canShowLessFrequently) {
    452      lazy.UrlbarPrefs.set(
    453        "weather.showLessFrequentlyCount",
    454        this.showLessFrequentlyCount + 1
    455      );
    456    }
    457  }
    458 
    459  get #config() {
    460    let { rustBackend } = lazy.QuickSuggest;
    461    let config = rustBackend.isEnabled
    462      ? rustBackend.getConfigForSuggestionType(this.rustSuggestionType)
    463      : null;
    464    return config || {};
    465  }
    466 
    467  get #minKeywordLength() {
    468    // Use the pref value if it has a user value, which means the user clicked
    469    // "Show less frequently" at least once. Otherwise, fall back to the Nimbus
    470    // value and then the config value. That lets us override the pref's default
    471    // value using Nimbus or the config, if necessary.
    472    let minLength = lazy.UrlbarPrefs.get("weather.minKeywordLength");
    473    if (
    474      !Services.prefs.prefHasUserValue(
    475        "browser.urlbar.weather.minKeywordLength"
    476      )
    477    ) {
    478      let nimbusValue = lazy.UrlbarPrefs.get("weatherKeywordsMinimumLength");
    479      if (nimbusValue !== null) {
    480        minLength = nimbusValue;
    481      } else if (!isNaN(this.#config.minKeywordLength)) {
    482        minLength = this.#config.minKeywordLength;
    483      }
    484    }
    485    return Math.max(minLength, 0);
    486  }
    487 
    488  async #fetchMerinoSuggestion(cityGeoname) {
    489    if (!this.#merino) {
    490      this.#merino = new lazy.MerinoClient(this.constructor.name, {
    491        allowOhttp: true,
    492        cachePeriodMs: MERINO_WEATHER_CACHE_PERIOD_MS,
    493      });
    494    }
    495 
    496    let merino = this.#merino;
    497    let fetchInstance = (this.#fetchInstance = {});
    498 
    499    // Set up location params to pass to Merino. We need to null-check each
    500    // suggestion property because `MerinoClient` will stringify null values.
    501    let otherParams = { source: "urlbar" };
    502    if (cityGeoname) {
    503      if (cityGeoname.name) {
    504        otherParams.city = cityGeoname.name;
    505      }
    506      if (cityGeoname.countryCode) {
    507        otherParams.country = cityGeoname.countryCode;
    508      }
    509      // The admin codes are a `Map` from integer levels to codes. Convert it to
    510      // a comma-separated string of codes sorted by level ascending.
    511      let adminCodes = [...cityGeoname.adminDivisionCodes.entries()]
    512        .sort(([level1, _admin1], [level2, _admin2]) => level1 - level2)
    513        .map(([_, admin]) => admin)
    514        .join(",");
    515      if (adminCodes) {
    516        otherParams.region = adminCodes;
    517      }
    518    } else {
    519      let geolocation = await lazy.GeolocationUtils.geolocation();
    520 
    521      if (
    522        !geolocation ||
    523        fetchInstance != this.#fetchInstance ||
    524        merino != this.#merino
    525      ) {
    526        return null;
    527      }
    528 
    529      if (geolocation.country_code) {
    530        otherParams.country = geolocation.country_code;
    531      }
    532      let region = geolocation.region_code || geolocation.region;
    533      if (region) {
    534        otherParams.region = region;
    535      }
    536      let city = geolocation.city || geolocation.region;
    537      if (city) {
    538        otherParams.city = city;
    539      }
    540    }
    541 
    542    let merinoSuggestions = await merino.fetch({
    543      query: "",
    544      otherParams,
    545      providers: [MERINO_PROVIDER],
    546      timeoutMs: this.#timeoutMs,
    547    });
    548    if (fetchInstance != this.#fetchInstance || merino != this.#merino) {
    549      return null;
    550    }
    551 
    552    return merinoSuggestions[0] ?? null;
    553  }
    554 
    555  async #getTitleL10n(cityGeoname, merinoSuggestion) {
    556    let displayCity = "";
    557    let displayRegion = "";
    558    let displayCountry = "";
    559 
    560    if (!cityGeoname) {
    561      displayCity = merinoSuggestion.city_name;
    562      displayRegion = merinoSuggestion.region_code;
    563    } else {
    564      // Fetch localized names for the city.
    565      let alts =
    566        await lazy.QuickSuggest.rustBackend.fetchGeonameAlternates(cityGeoname);
    567 
    568      displayCity = alts.geoname.localized || alts.geoname.primary;
    569 
    570      // For cities in Canada and the US, always show the province/state using
    571      // its usual two-char abbreviation. For other countries we won't show any
    572      // admin divisions at all; there's maybe room for improvement here.
    573      if (NORTH_AMERICA_COUNTRY_CODES.has(cityGeoname.countryCode)) {
    574        displayRegion =
    575          alts.adminDivisions.get(1)?.abbreviation ||
    576          alts.adminDivisions.get(1)?.localized ||
    577          alts.adminDivisions.get(1)?.primary;
    578      }
    579 
    580      // If the city's country is different from the user's, show it.
    581      if (cityGeoname.countryCode != lazy.Region.home) {
    582        displayCountry = alts.country?.localized || alts.country?.primary;
    583      }
    584    }
    585 
    586    if (displayRegion && displayCountry) {
    587      return {
    588        id: "urlbar-result-weather-title-with-country",
    589        args: {
    590          city: displayCity,
    591          region: displayRegion,
    592          country: displayCountry,
    593        },
    594      };
    595    }
    596 
    597    // This is a little confusing but if we only have a country, show it as the
    598    // "region". Don't get hung up on the name of this l10n string variable. It
    599    // just means the final string will be "{city}, {country}".
    600    let region = displayRegion || displayCountry;
    601    if (region) {
    602      return {
    603        id: "urlbar-result-weather-title",
    604        args: {
    605          region,
    606          city: displayCity,
    607        },
    608      };
    609    }
    610 
    611    return {
    612      id: "urlbar-result-weather-title-city-only",
    613      args: {
    614        city: displayCity,
    615      },
    616    };
    617  }
    618 
    619  get _test_merino() {
    620    return this.#merino;
    621  }
    622 
    623  _test_setTimeoutMs(ms) {
    624    this.#timeoutMs = ms < 0 ? MERINO_TIMEOUT_MS : ms;
    625  }
    626 
    627  #fetchInstance = null;
    628  #merino = null;
    629  #timeoutMs = MERINO_TIMEOUT_MS;
    630 }