tor-browser

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

WeatherFeed.sys.mjs (13266B)


      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 { WEATHER_OPTIN_REGIONS } from "./ActivityStream.sys.mjs";
      6 
      7 const lazy = {};
      8 ChromeUtils.defineESModuleGetters(lazy, {
      9  clearTimeout: "resource://gre/modules/Timer.sys.mjs",
     10  setTimeout: "resource://gre/modules/Timer.sys.mjs",
     11  PersistentCache: "resource://newtab/lib/PersistentCache.sys.mjs",
     12  Region: "resource://gre/modules/Region.sys.mjs",
     13 });
     14 
     15 ChromeUtils.defineLazyGetter(lazy, "MerinoClient", () => {
     16  try {
     17    return ChromeUtils.importESModule(
     18      "moz-src:///browser/components/urlbar/MerinoClient.sys.mjs"
     19    ).MerinoClient;
     20  } catch {
     21    // Fallback to URI format prior to FF 144.
     22    return ChromeUtils.importESModule(
     23      "resource:///modules/MerinoClient.sys.mjs"
     24    ).MerinoClient;
     25  }
     26 });
     27 
     28 ChromeUtils.defineLazyGetter(lazy, "GeolocationUtils", () => {
     29  try {
     30    return ChromeUtils.importESModule(
     31      "moz-src:///browser/components/urlbar/private/GeolocationUtils.sys.mjs"
     32    ).GeolocationUtils;
     33  } catch {
     34    // Fallback to URI format prior to FF 144.
     35    return ChromeUtils.importESModule(
     36      "resource:///modules/urlbar/private/GeolocationUtils.sys.mjs"
     37    ).GeolocationUtils;
     38  }
     39 });
     40 
     41 import {
     42  actionTypes as at,
     43  actionCreators as ac,
     44 } from "resource://newtab/common/Actions.mjs";
     45 
     46 const CACHE_KEY = "weather_feed";
     47 const WEATHER_UPDATE_TIME = 10 * 60 * 1000; // 10 minutes
     48 const MERINO_PROVIDER = ["accuweather"];
     49 const RETRY_DELAY_MS = 60 * 1000; // 1 minute in ms.
     50 const MERINO_CLIENT_KEY = "HNT_WEATHER_FEED";
     51 
     52 const PREF_WEATHER_QUERY = "weather.query";
     53 const PREF_SHOW_WEATHER = "showWeather";
     54 const PREF_SYSTEM_SHOW_WEATHER = "system.showWeather";
     55 
     56 /**
     57 * A feature that periodically fetches weather suggestions from Merino for HNT.
     58 */
     59 export class WeatherFeed {
     60  constructor() {
     61    this.loaded = false;
     62    this.merino = null;
     63    this.suggestions = [];
     64    this.lastUpdated = null;
     65    this.locationData = {};
     66    this.fetchTimer = null;
     67    this.retryTimer = null;
     68    this.fetchIntervalMs = 30 * 60 * 1000; // 30 minutes
     69    this.timeoutMS = 5000;
     70    this.lastFetchTimeMs = 0;
     71    this.fetchDelayAfterComingOnlineMs = 3000; // 3s
     72    this.cache = this.PersistentCache(CACHE_KEY, true);
     73  }
     74 
     75  async resetCache() {
     76    if (this.cache) {
     77      await this.cache.set("weather", {});
     78    }
     79  }
     80 
     81  async resetWeather() {
     82    await this.resetCache();
     83    this.suggestions = [];
     84    this.lastUpdated = null;
     85    this.loaded = false;
     86  }
     87 
     88  isEnabled() {
     89    const { values } = this.store.getState().Prefs;
     90    const userValue = values[PREF_SHOW_WEATHER];
     91    const systemValue = values[PREF_SYSTEM_SHOW_WEATHER];
     92    const experimentValue = values.trainhopConfig?.weather?.enabled || false;
     93    return userValue && (systemValue || experimentValue);
     94  }
     95 
     96  async init() {
     97    await this.loadWeather(true /* isStartup */);
     98  }
     99 
    100  stopFetching() {
    101    if (!this.merino) {
    102      return;
    103    }
    104 
    105    this.clearTimeout(this.fetchTimer);
    106    this.clearTimeout(this.retryTimer);
    107    this.merino = null;
    108    this.suggestions = null;
    109    this.fetchTimer = 0;
    110    this.retryTimer = 0;
    111  }
    112 
    113  async fetch() {
    114    // Keep a handle on the `MerinoClient` instance that exists at the start of
    115    // this fetch. If fetching stops or this `Weather` instance is uninitialized
    116    // during the fetch, `#merino` will be nulled, and the fetch should stop. We
    117    // can compare `merino` to `this.merino` to tell when this occurs.
    118    if (!this.merino) {
    119      this.merino = await this.MerinoClient(MERINO_CLIENT_KEY);
    120    }
    121 
    122    this.suggestions = await this._fetchHelper();
    123 
    124    if (this.suggestions.length) {
    125      const hasLocationData =
    126        !this.store.getState().Prefs.values[PREF_WEATHER_QUERY];
    127      this.lastUpdated = this.Date().now();
    128      await this.cache.set("weather", {
    129        suggestions: this.suggestions,
    130        lastUpdated: this.lastUpdated,
    131      });
    132 
    133      // only calls to merino without the query parameter would return the location data (and only city name)
    134      if (hasLocationData && this.suggestions.length) {
    135        const [data] = this.suggestions;
    136        this.locationData = {
    137          city: data.city_name,
    138          adminArea: "",
    139          country: "",
    140        };
    141        await this.cache.set("locationData", this.locationData);
    142      }
    143    }
    144 
    145    this.update();
    146  }
    147 
    148  async loadWeather(isStartup = false) {
    149    const cachedData = (await this.cache.get()) || {};
    150    const { weather, locationData } = cachedData;
    151 
    152    // if we have locationData in the cache set it to this.locationData so it is added to the redux store
    153    if (locationData?.city) {
    154      this.locationData = locationData;
    155    }
    156    // If we have nothing in cache, or cache has expired, we can make a fresh fetch.
    157    if (
    158      !weather?.lastUpdated ||
    159      !(this.Date().now() - weather.lastUpdated < WEATHER_UPDATE_TIME)
    160    ) {
    161      await this.fetch(isStartup);
    162    } else if (!this.lastUpdated) {
    163      this.suggestions = weather.suggestions;
    164      this.lastUpdated = weather.lastUpdated;
    165      this.update();
    166    }
    167    this.loaded = true;
    168  }
    169 
    170  update() {
    171    this.store.dispatch(
    172      ac.BroadcastToContent({
    173        type: at.WEATHER_UPDATE,
    174        data: {
    175          suggestions: this.suggestions,
    176          lastUpdated: this.lastUpdated,
    177          locationData: this.locationData,
    178        },
    179      })
    180    );
    181  }
    182 
    183  restartFetchTimer(ms = this.fetchIntervalMs) {
    184    this.clearTimeout(this.fetchTimer);
    185    this.clearTimeout(this.retryTimer);
    186    this.fetchTimer = this.setTimeout(() => {
    187      this.fetch();
    188    }, ms);
    189    this.retryTimer = null; // tidy
    190  }
    191 
    192  async fetchLocationAutocomplete() {
    193    if (!this.merino) {
    194      this.merino = await this.MerinoClient(MERINO_CLIENT_KEY);
    195    }
    196 
    197    const query = this.store.getState().Weather.locationSearchString;
    198    let response = await this.merino.fetch({
    199      query: query || "",
    200      providers: MERINO_PROVIDER,
    201      timeoutMs: 7000,
    202      otherParams: {
    203        request_type: "location",
    204        source: "newtab",
    205      },
    206    });
    207    const data = response?.[0];
    208    if (data?.locations.length) {
    209      this.store.dispatch(
    210        ac.BroadcastToContent({
    211          type: at.WEATHER_LOCATION_SUGGESTIONS_UPDATE,
    212          data: data.locations,
    213        })
    214      );
    215    }
    216  }
    217 
    218  async onPrefChangedAction(action) {
    219    switch (action.data.name) {
    220      case PREF_WEATHER_QUERY:
    221        await this.fetch();
    222        break;
    223      case PREF_SHOW_WEATHER:
    224      case PREF_SYSTEM_SHOW_WEATHER:
    225      case "trainhopConfig": {
    226        const enabled = this.isEnabled();
    227        if (enabled && !this.loaded) {
    228          await this.loadWeather();
    229        } else if (!enabled && this.loaded) {
    230          await this.resetWeather();
    231        }
    232        break;
    233      }
    234    }
    235  }
    236 
    237  async checkOptInRegion() {
    238    const currentRegion = await lazy.Region.home;
    239    const optIn =
    240      this.isEnabled() && WEATHER_OPTIN_REGIONS.includes(currentRegion);
    241    this.store.dispatch(ac.SetPref("system.showWeatherOptIn", optIn));
    242    return optIn;
    243  }
    244 
    245  async onAction(action) {
    246    switch (action.type) {
    247      case at.INIT:
    248        await this.checkOptInRegion();
    249        if (this.isEnabled() && !this.loaded) {
    250          await this.init();
    251        }
    252        break;
    253      case at.UNINIT:
    254        await this.resetWeather();
    255        break;
    256      case at.DISCOVERY_STREAM_DEV_SYSTEM_TICK:
    257      case at.SYSTEM_TICK:
    258        if (this.isEnabled()) {
    259          await this.loadWeather();
    260        }
    261        break;
    262      case at.PREF_CHANGED:
    263        if (action.data.name === "system.showWeather") {
    264          await this.checkOptInRegion();
    265        }
    266        await this.onPrefChangedAction(action);
    267        break;
    268      case at.WEATHER_LOCATION_SEARCH_UPDATE:
    269        await this.fetchLocationAutocomplete();
    270        break;
    271      case at.WEATHER_LOCATION_DATA_UPDATE: {
    272        // check that data is formatted correctly before adding to cache
    273        if (action.data.city) {
    274          await this.cache.set("locationData", {
    275            city: action.data.city,
    276            adminName: action.data.adminName,
    277            country: action.data.country,
    278          });
    279          this.locationData = action.data;
    280        }
    281 
    282        // Remove static weather data once location has been set
    283        this.store.dispatch(ac.SetPref("weather.staticData.enabled", false));
    284        break;
    285      }
    286      case at.WEATHER_USER_OPT_IN_LOCATION: {
    287        this.store.dispatch(ac.SetPref("weather.optInAccepted", true));
    288        this.store.dispatch(ac.SetPref("weather.optInDisplayed", false));
    289 
    290        const detectedLocation = await this._fetchNormalizedLocation();
    291 
    292        if (detectedLocation) {
    293          // Build the payload exactly like manual search does
    294          this.store.dispatch(
    295            ac.BroadcastToContent({
    296              type: at.WEATHER_LOCATION_DATA_UPDATE,
    297              data: {
    298                city: detectedLocation.localized_name,
    299                adminName: detectedLocation.administrative_area,
    300                country: detectedLocation.country,
    301              },
    302            })
    303          );
    304 
    305          // Use the AccuWeather key (canonical ID)
    306          if (detectedLocation.key) {
    307            this.store.dispatch(
    308              ac.SetPref("weather.query", detectedLocation.key)
    309            );
    310          }
    311        }
    312        break;
    313      }
    314    }
    315  }
    316 
    317  /**
    318   * This thin wrapper around the fetch call makes it easier for us to write
    319   * automated tests that simulate responses.
    320   */
    321  async _fetchHelper(maxRetries = 1, queryOverride = null) {
    322    this.restartFetchTimer();
    323 
    324    const weatherQuery = this.store.getState().Prefs.values[PREF_WEATHER_QUERY];
    325    const query = queryOverride ?? weatherQuery ?? "";
    326    const otherParams = {
    327      request_type: "weather",
    328      source: "newtab",
    329    };
    330 
    331    if (!query) {
    332      let geolocation = await lazy.GeolocationUtils.geolocation();
    333      if (!geolocation) {
    334        return [];
    335      }
    336 
    337      const country = geolocation.country_code;
    338      // Adding geolocation.city as an option for region to count for city-states (i.e. Singapore)
    339      const region =
    340        geolocation.region_code || geolocation.region || geolocation.city;
    341      const city = geolocation.city || geolocation.region;
    342 
    343      // Merino requires all three parameters (city, region, country) when query is not provided
    344      if (!country || !region || !city) {
    345        return [];
    346      }
    347 
    348      otherParams.country = country;
    349      otherParams.region = region;
    350      otherParams.city = city;
    351    }
    352    const attempt = async (retry = 0) => {
    353      try {
    354        // Because this can happen after a timeout,
    355        // we want to ensure if it was called later after a teardown,
    356        // we don't throw. If we throw, we end up in another retry.
    357        if (!this.merino) {
    358          return [];
    359        }
    360        return await this.merino.fetch({
    361          query,
    362          providers: MERINO_PROVIDER,
    363          timeoutMs: 7000,
    364          otherParams,
    365        });
    366      } catch (e) {
    367        // If we get an error, we try again in 1 minute,
    368        // and give up if we try more than maxRetries number of times.
    369        if (retry >= maxRetries) {
    370          return [];
    371        }
    372        await new Promise(res => {
    373          // store the timeout so it can be cancelled elsewhere
    374          this.retryTimer = this.setTimeout(() => {
    375            this.retryTimer = null; // cleanup once it fires
    376            res();
    377          }, RETRY_DELAY_MS);
    378        });
    379        return attempt(retry + 1);
    380      }
    381    };
    382 
    383    // results from the API or empty array
    384    return await attempt();
    385  }
    386 
    387  async _fetchNormalizedLocation() {
    388    const geolocation = await lazy.GeolocationUtils.geolocation();
    389    if (!geolocation) {
    390      return null;
    391    }
    392 
    393    // "region" might be able to be city if geolocation.city is null
    394    const city = geolocation.city || geolocation.region;
    395    if (!city) {
    396      return null;
    397    }
    398 
    399    if (!this.merino) {
    400      this.merino = await this.MerinoClient(MERINO_CLIENT_KEY);
    401    }
    402 
    403    try {
    404      // We use the given city name look up to get the normalized merino response
    405      const locationData = await this.merino.fetch({
    406        query: city,
    407        providers: MERINO_PROVIDER,
    408        timeoutMs: 7000,
    409        otherParams: {
    410          request_type: "location",
    411          source: "newtab",
    412        },
    413      });
    414 
    415      const response = locationData?.[0]?.locations?.[0];
    416      return response;
    417    } catch (err) {
    418      console.error("WeatherFeed failed to get normalized location");
    419      return null;
    420    }
    421  }
    422 }
    423 
    424 /**
    425 * Creating a thin wrapper around external tools.
    426 * This makes it easier for us to write automated tests that simulate responses.
    427 */
    428 WeatherFeed.prototype.MerinoClient = (...args) => {
    429  return new lazy.MerinoClient({
    430    allowOhttp: true,
    431    ...args,
    432  });
    433 };
    434 WeatherFeed.prototype.PersistentCache = (...args) => {
    435  return new lazy.PersistentCache(...args);
    436 };
    437 WeatherFeed.prototype.Date = () => {
    438  return Date;
    439 };
    440 WeatherFeed.prototype.setTimeout = (...args) => {
    441  return lazy.setTimeout(...args);
    442 };
    443 WeatherFeed.prototype.clearTimeout = (...args) => {
    444  return lazy.clearTimeout(...args);
    445 };