273 lines
7.6 KiB
JavaScript
273 lines
7.6 KiB
JavaScript
/* 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/. */
|
|
|
|
const lazy = {};
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
MerinoClient: "resource:///modules/MerinoClient.sys.mjs",
|
|
clearTimeout: "resource://gre/modules/Timer.sys.mjs",
|
|
setTimeout: "resource://gre/modules/Timer.sys.mjs",
|
|
PersistentCache: "resource://newtab/lib/PersistentCache.sys.mjs",
|
|
});
|
|
|
|
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 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.lastUpdated = null;
|
|
this.locationData = {};
|
|
this.fetchTimer = 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.lastUpdated = null;
|
|
}
|
|
|
|
isEnabled() {
|
|
return (
|
|
this.store.getState().Prefs.values[PREF_SHOW_WEATHER] &&
|
|
this.store.getState().Prefs.values[PREF_SYSTEM_SHOW_WEATHER]
|
|
);
|
|
}
|
|
|
|
async init() {
|
|
await this.loadWeather(true /* isStartup */);
|
|
}
|
|
|
|
stopFetching() {
|
|
if (!this.merino) {
|
|
return;
|
|
}
|
|
|
|
lazy.clearTimeout(this.fetchTimer);
|
|
this.merino = null;
|
|
this.suggestions = null;
|
|
this.fetchTimer = 0;
|
|
}
|
|
|
|
/**
|
|
* This thin wrapper around the fetch call makes it easier for us to write
|
|
* automated tests that simulate responses.
|
|
*/
|
|
async fetchHelper(retries = 3) {
|
|
this.restartFetchTimer();
|
|
const weatherQuery = this.store.getState().Prefs.values[PREF_WEATHER_QUERY];
|
|
let suggestions = [];
|
|
let retry = 0;
|
|
while (retry++ < retries && suggestions.length === 0) {
|
|
try {
|
|
suggestions = await this.merino.fetch({
|
|
query: weatherQuery || "",
|
|
providers: MERINO_PROVIDER,
|
|
timeoutMs: 7000,
|
|
otherParams: {
|
|
request_type: "weather",
|
|
},
|
|
});
|
|
} catch (error) {
|
|
// We don't need to do anything with this right now.
|
|
}
|
|
}
|
|
|
|
// results from the API or empty array if null
|
|
this.suggestions = suggestions ?? [];
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
await this.fetchHelper();
|
|
|
|
if (this.suggestions.length) {
|
|
const hasLocationData =
|
|
!this.store.getState().Prefs.values[PREF_WEATHER_QUERY];
|
|
this.lastUpdated = this.Date().now();
|
|
await this.cache.set("weather", {
|
|
suggestions: this.suggestions,
|
|
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.lastUpdated = weather.lastUpdated;
|
|
this.update();
|
|
}
|
|
}
|
|
|
|
update() {
|
|
this.store.dispatch(
|
|
ac.BroadcastToContent({
|
|
type: at.WEATHER_UPDATE,
|
|
data: {
|
|
suggestions: this.suggestions,
|
|
lastUpdated: this.lastUpdated,
|
|
locationData: this.locationData,
|
|
},
|
|
})
|
|
);
|
|
}
|
|
|
|
restartFetchTimer(ms = this.fetchIntervalMs) {
|
|
lazy.clearTimeout(this.fetchTimer);
|
|
this.fetchTimer = lazy.setTimeout(() => {
|
|
this.fetch();
|
|
}, ms);
|
|
}
|
|
|
|
async fetchLocationAutocomplete() {
|
|
if (!this.merino) {
|
|
this.merino = await this.MerinoClient(MERINO_CLIENT_KEY);
|
|
}
|
|
|
|
const query = this.store.getState().Weather.locationSearchString;
|
|
let response = await this.merino.fetch({
|
|
query: query || "",
|
|
providers: MERINO_PROVIDER,
|
|
timeoutMs: 7000,
|
|
otherParams: {
|
|
request_type: "location",
|
|
},
|
|
});
|
|
const 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:
|
|
if (this.isEnabled() && action.data.value) {
|
|
await this.loadWeather();
|
|
} else {
|
|
await this.resetWeather();
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
async onAction(action) {
|
|
switch (action.type) {
|
|
case at.INIT:
|
|
if (this.isEnabled()) {
|
|
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:
|
|
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) {
|
|
await this.cache.set("locationData", {
|
|
city: action.data.city,
|
|
adminName: action.data.adminName,
|
|
country: action.data.country,
|
|
});
|
|
this.locationData = action.data;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creating a thin wrapper around MerinoClient, PersistentCache, and Date.
|
|
* This makes it easier for us to write automated tests that simulate responses.
|
|
*/
|
|
WeatherFeed.prototype.MerinoClient = (...args) => {
|
|
return new lazy.MerinoClient(...args);
|
|
};
|
|
WeatherFeed.prototype.PersistentCache = (...args) => {
|
|
return new lazy.PersistentCache(...args);
|
|
};
|
|
WeatherFeed.prototype.Date = () => {
|
|
return Date;
|
|
};
|