/* 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 { WEATHER_OPTIN_REGIONS } from "./ActivityStream.sys.mjs"; // eslint-disable-next-line mozilla/use-static-import const { AppConstants } = ChromeUtils.importESModule( "resource://gre/modules/AppConstants.sys.mjs" ); const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { clearTimeout: "resource://gre/modules/Timer.sys.mjs", setTimeout: "resource://gre/modules/Timer.sys.mjs", PersistentCache: "resource://newtab/lib/PersistentCache.sys.mjs", Region: "resource://gre/modules/Region.sys.mjs", }); ChromeUtils.defineLazyGetter(lazy, "MerinoClient", () => { // @backward-compat { version 151 } // Bug 2018111 - TemporaryMerinoClientShim backports fetchWeatherReport() and // fetchHourlyForecasts() with endpoint URL support. Remove this block // and use MerinoClient directly once Firefox 151 ships to release. if (Services.vc.compare(AppConstants.MOZ_APP_VERSION, "151.0a1") < 0) { return ChromeUtils.importESModule( "resource://newtab/lib/TemporaryMerinoClientShim.sys.mjs" ).TemporaryMerinoClientShim; } try { return ChromeUtils.importESModule( "moz-src:///browser/components/urlbar/MerinoClient.sys.mjs" ).MerinoClient; } catch { // Fallback to URI format prior to FF 144. return ChromeUtils.importESModule( "resource:///modules/MerinoClient.sys.mjs" ).MerinoClient; } }); ChromeUtils.defineLazyGetter(lazy, "GeolocationUtils", () => { try { return ChromeUtils.importESModule( "moz-src:///browser/components/urlbar/private/GeolocationUtils.sys.mjs" ).GeolocationUtils; } catch { // Fallback to URI format prior to FF 144. return ChromeUtils.importESModule( "resource:///modules/urlbar/private/GeolocationUtils.sys.mjs" ).GeolocationUtils; } }); import { actionTypes as at, actionCreators as ac, } from "resource://newtab/common/Actions.mjs"; const CACHE_KEY = "weather_feed"; const WEATHER_UPDATE_TIME = 10 * 60 * 1000; // 10 minutes const MERINO_PROVIDER = ["accuweather"]; const RETRY_DELAY_MS = 60 * 1000; // 1 minute in ms. const MERINO_CLIENT_KEY = "HNT_WEATHER_FEED"; const PREF_WEATHER_QUERY = "weather.query"; const PREF_SHOW_WEATHER = "showWeather"; const PREF_SYSTEM_SHOW_WEATHER = "system.showWeather"; /** * A feature that periodically fetches weather suggestions from Merino for HNT. */ export class WeatherFeed { constructor() { this.loaded = false; this.merino = null; this.suggestions = []; this.hourlyForecasts = []; this.lastUpdated = null; this.locationData = {}; this.fetchTimer = null; this.retryTimer = null; this.fetchIntervalMs = 30 * 60 * 1000; // 30 minutes this.timeoutMS = 5000; this.lastFetchTimeMs = 0; this.fetchDelayAfterComingOnlineMs = 3000; // 3s this.cache = this.PersistentCache(CACHE_KEY, true); } async resetCache() { if (this.cache) { await this.cache.set("weather", {}); } } async resetWeather() { await this.resetCache(); this.suggestions = []; this.hourlyForecasts = []; this.lastUpdated = null; this.loaded = false; } isEnabled() { const { values } = this.store.getState().Prefs; const userValue = values[PREF_SHOW_WEATHER]; const systemValue = values[PREF_SYSTEM_SHOW_WEATHER]; const experimentValue = values.trainhopConfig?.weather?.enabled || false; return userValue && (systemValue || experimentValue); } async init() { await this.loadWeather(true /* isStartup */); } stopFetching() { if (!this.merino) { return; } this.clearTimeout(this.fetchTimer); this.clearTimeout(this.retryTimer); this.merino = null; this.suggestions = null; this.hourlyForecasts = null; this.fetchTimer = 0; this.retryTimer = 0; } async fetch() { // Keep a handle on the `MerinoClient` instance that exists at the start of // this fetch. If fetching stops or this `Weather` instance is uninitialized // during the fetch, `#merino` will be nulled, and the fetch should stop. We // can compare `merino` to `this.merino` to tell when this occurs. if (!this.merino) { this.merino = await this.MerinoClient(MERINO_CLIENT_KEY); } // @backward-compat { version 149 } // MerinoClient.fetchWeatherReport() and MerinoClient.fetchHourlyForecast() were introduced in 149 Nightly. // The fetchHelperUntil_149() does not use the function. // Starting from 148, TemporaryMerinoClientShim backports these methods so the new path is safe there too. if (Services.vc.compare(AppConstants.MOZ_APP_VERSION, "148.0a1") >= 0) { const { suggestions, hourlyForecasts } = await this._fetchHelper(); this.suggestions = suggestions; this.hourlyForecasts = hourlyForecasts; } else { this.suggestions = await this._fetchHelperUntil_149(); this.hourlyForecasts = []; } if (this.suggestions.length || this.hourlyForecasts.length) { const hasLocationData = !this.store.getState().Prefs.values[PREF_WEATHER_QUERY]; this.lastUpdated = this.Date().now(); await this.cache.set("weather", { suggestions: this.suggestions, hourlyForecasts: this.hourlyForecasts, lastUpdated: this.lastUpdated, }); // only calls to merino without the query parameter would return the location data (and only city name) if (hasLocationData && this.suggestions.length) { const [data] = this.suggestions; this.locationData = { city: data.city_name, adminArea: "", country: "", }; await this.cache.set("locationData", this.locationData); } } this.update(); } async loadWeather(isStartup = false) { const cachedData = (await this.cache.get()) || {}; const { weather, locationData } = cachedData; // if we have locationData in the cache set it to this.locationData so it is added to the redux store if (locationData?.city) { this.locationData = locationData; } // If we have nothing in cache, or cache has expired, we can make a fresh fetch. if ( !weather?.lastUpdated || !(this.Date().now() - weather.lastUpdated < WEATHER_UPDATE_TIME) ) { await this.fetch(isStartup); } else if (!this.lastUpdated) { this.suggestions = weather.suggestions; this.hourlyForecasts = weather.hourlyForecasts || []; this.lastUpdated = weather.lastUpdated; this.update(); } this.loaded = true; } update() { this.store.dispatch( ac.BroadcastToContent({ type: at.WEATHER_UPDATE, data: { suggestions: this.suggestions, hourlyForecasts: this.hourlyForecasts, lastUpdated: this.lastUpdated, locationData: this.locationData, }, }) ); } restartFetchTimer(ms = this.fetchIntervalMs) { this.clearTimeout(this.fetchTimer); this.clearTimeout(this.retryTimer); this.fetchTimer = this.setTimeout(() => { this.fetch(); }, ms); this.retryTimer = null; // tidy } async fetchLocationAutocomplete() { if (!this.merino) { this.merino = await this.MerinoClient(MERINO_CLIENT_KEY); } const query = this.store.getState().Weather.locationSearchString; let data; // @backward-compat { version 149 } // MerinoClient.autoCompleteWeatherLocation() was introduced in 149 Nightly. if (Services.vc.compare(AppConstants.MOZ_APP_VERSION, "149.0a1") >= 0) { data = await this.merino.autoCompleteWeatherLocation({ query, source: "newtab", timeoutMs: 7000, }); } else { let response = await this.merino.fetch({ query: query || "", providers: MERINO_PROVIDER, timeoutMs: 7000, otherParams: { request_type: "location", source: "newtab", }, }); data = response?.[0]; } if (data?.locations.length) { this.store.dispatch( ac.BroadcastToContent({ type: at.WEATHER_LOCATION_SUGGESTIONS_UPDATE, data: data.locations, }) ); } } async onPrefChangedAction(action) { switch (action.data.name) { case PREF_WEATHER_QUERY: await this.fetch(); break; case PREF_SHOW_WEATHER: case PREF_SYSTEM_SHOW_WEATHER: case "trainhopConfig": { const enabled = this.isEnabled(); if (enabled && !this.loaded) { await this.loadWeather(); } else if (!enabled && this.loaded) { await this.resetWeather(); } break; } case "weather.display": case "widgets.system.weatherForecast.enabled": { if (!this.hourlyForecasts?.length) { await this.fetch(); } break; } } } async checkOptInRegion() { const currentRegion = await lazy.Region.home; const optIn = this.isEnabled() && WEATHER_OPTIN_REGIONS.includes(currentRegion); this.store.dispatch(ac.SetPref("system.showWeatherOptIn", optIn)); return optIn; } async onAction(action) { switch (action.type) { case at.INIT: await this.checkOptInRegion(); if (this.isEnabled() && !this.loaded) { await this.init(); } break; case at.UNINIT: await this.resetWeather(); break; case at.DISCOVERY_STREAM_DEV_SYSTEM_TICK: case at.SYSTEM_TICK: if (this.isEnabled()) { await this.loadWeather(); } break; case at.PREF_CHANGED: if (action.data.name === "system.showWeather") { await this.checkOptInRegion(); } await this.onPrefChangedAction(action); break; case at.WEATHER_LOCATION_SEARCH_UPDATE: await this.fetchLocationAutocomplete(); break; case at.WEATHER_LOCATION_DATA_UPDATE: { // check that data is formatted correctly before adding to cache if (action.data.city) { this.locationData = action.data; await this.cache.set("locationData", { city: action.data.city, adminName: action.data.adminName, country: action.data.country, }); } // Remove static weather data once location has been set this.store.dispatch(ac.SetPref("weather.staticData.enabled", false)); break; } case at.WEATHER_USER_OPT_IN_LOCATION: { this.store.dispatch(ac.SetPref("weather.optInAccepted", true)); this.store.dispatch(ac.SetPref("weather.optInDisplayed", false)); const detectedLocation = await this._fetchNormalizedLocation(); if (detectedLocation) { // Build the payload exactly like manual search does this.store.dispatch( ac.BroadcastToContent({ type: at.WEATHER_LOCATION_DATA_UPDATE, data: { city: detectedLocation.localized_name, adminName: detectedLocation.administrative_area, country: detectedLocation.country, }, }) ); // Use the AccuWeather key (canonical ID) if (detectedLocation.key) { this.store.dispatch( ac.SetPref("weather.query", detectedLocation.key) ); } } break; } } } /** * This thin wrapper around the fetch call makes it easier for us to write * automated tests that simulate responses. */ async _fetchHelper(maxRetries = 1, queryOverride = null) { this.restartFetchTimer(); const weatherQuery = this.store.getState().Prefs.values[PREF_WEATHER_QUERY]; const locationName = queryOverride ?? weatherQuery; const attempt = async (retry = 0) => { try { // Because this can happen after a timeout, // we want to ensure if it was called later after a teardown, // we don't throw. If we throw, we end up in another retry. if (!this.merino) { return { suggestions: [], hourlyForecasts: [] }; } // Resolve geolocation once before the parallel fetch so both // fetchWeatherReport() and fetchHourlyForecasts() share the same // result, avoiding concurrent Merino requests that can result in a race condition. let city; let country; let region; if (!locationName) { const geolocation = await lazy.GeolocationUtils.geolocation(); if (!geolocation) { return { suggestions: [], hourlyForecasts: [] }; } country = geolocation.country_code; region = geolocation.region_code || geolocation.region || geolocation.city; city = geolocation.city || geolocation.region; if (!country || !region || !city) { return { suggestions: [], hourlyForecasts: [] }; } } const { values } = this.store.getState().Prefs; const weatherForecastWidgetEnabled = values["weather.display"] === "detailed" && (values["widgets.system.weatherForecast.enabled"] || values.trainhopConfig?.widgets?.weatherForecastEnabled); // @backward-compat { version 151 } // Read endpoint URLs from trainhopConfig or ActivityStream prefs so // they can be configured without a tree change. Remove once shipped; // MerinoClient will have the endpoints defined via UrlbarPrefs. const reportEndpointUrl = values.trainhopConfig?.weather?.reportEndpoint || values["weather.reportEndpoint"]; const hourlyEndpointUrl = values.trainhopConfig?.weather?.hourlyEndpoint || values["weather.hourlyEndpoint"]; const [reportResult, hourlyResult] = await Promise.all([ this.merino.fetchWeatherReport({ source: "newtab", locationName, city, region, country, timeoutMs: 7000, endpointUrl: reportEndpointUrl, }), weatherForecastWidgetEnabled ? // When locationName is set, city/region/country are unresolved // from geolocation and need to be populated in the fetch URL. // Fall back to this.locationData so the hourly endpoint can // resolve the selected location. this.merino.fetchHourlyForecasts({ source: "newtab", locationName, city: city || this.locationData?.city, region: region || this.locationData?.adminName?.id, country: country || this.locationData?.country?.id, endpointUrl: hourlyEndpointUrl, }) : Promise.resolve(null), ]); return { suggestions: reportResult ? [reportResult] : [], hourlyForecasts: hourlyResult ?? [], }; } catch (e) { // If we get an error, we try again in 1 minute, // and give up if we try more than maxRetries number of times. if (retry >= maxRetries) { return { suggestions: [], hourlyForecasts: [] }; } await new Promise(res => { // store the timeout so it can be cancelled elsewhere this.retryTimer = this.setTimeout(() => { this.retryTimer = null; // cleanup once it fires res(); }, RETRY_DELAY_MS); }); return attempt(retry + 1); } }; // results from the API or empty array return await attempt(); } /** * @backward-compat { version 149 } * * MerinoClient.fetchWeatherReport() was introduced in 149 Nightly. * This function does not use it. */ async _fetchHelperUntil_149(maxRetries = 1, queryOverride = null) { this.restartFetchTimer(); const weatherQuery = this.store.getState().Prefs.values[PREF_WEATHER_QUERY]; const query = queryOverride ?? weatherQuery ?? ""; const otherParams = { request_type: "weather", source: "newtab", }; if (!query) { let geolocation = await lazy.GeolocationUtils.geolocation(); if (!geolocation) { return []; } const country = geolocation.country_code; // Adding geolocation.city as an option for region to count for city-states (i.e. Singapore) const region = geolocation.region_code || geolocation.region || geolocation.city; const city = geolocation.city || geolocation.region; // Merino requires all three parameters (city, region, country) when query is not provided if (!country || !region || !city) { return []; } otherParams.country = country; otherParams.region = region; otherParams.city = city; } const attempt = async (retry = 0) => { try { // Because this can happen after a timeout, // we want to ensure if it was called later after a teardown, // we don't throw. If we throw, we end up in another retry. if (!this.merino) { return []; } return await this.merino.fetch({ query, providers: MERINO_PROVIDER, timeoutMs: 7000, otherParams, }); } catch (e) { // If we get an error, we try again in 1 minute, // and give up if we try more than maxRetries number of times. if (retry >= maxRetries) { return []; } await new Promise(res => { // store the timeout so it can be cancelled elsewhere this.retryTimer = this.setTimeout(() => { this.retryTimer = null; // cleanup once it fires res(); }, RETRY_DELAY_MS); }); return attempt(retry + 1); } }; // results from the API or empty array return await attempt(); } async _fetchNormalizedLocation() { const geolocation = await lazy.GeolocationUtils.geolocation(); if (!geolocation) { return null; } // "region" might be able to be city if geolocation.city is null const city = geolocation.city || geolocation.region; if (!city) { return null; } if (!this.merino) { this.merino = await this.MerinoClient(MERINO_CLIENT_KEY); } try { // We use the given city name look up to get the normalized merino response const locationData = await this.merino.fetch({ query: city, providers: MERINO_PROVIDER, timeoutMs: 7000, otherParams: { request_type: "location", source: "newtab", }, }); const response = locationData?.[0]?.locations?.[0]; return response; } catch (err) { console.error("WeatherFeed failed to get normalized location"); return null; } } } /** * Creating a thin wrapper around external tools. * This makes it easier for us to write automated tests that simulate responses. */ WeatherFeed.prototype.MerinoClient = name => { return new lazy.MerinoClient(name, { allowOhttp: true }); }; WeatherFeed.prototype.PersistentCache = (...args) => { return new lazy.PersistentCache(...args); }; WeatherFeed.prototype.Date = () => { return Date; }; WeatherFeed.prototype.setTimeout = (...args) => { return lazy.setTimeout(...args); }; WeatherFeed.prototype.clearTimeout = (...args) => { return lazy.clearTimeout(...args); };