Source code
Revision control
Copy as Markdown
Other Tools
/* 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(
).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(
).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(
).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);
};