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 };