WeatherSuggestions.sys.mjs (18595B)
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 { SuggestProvider } from "moz-src:///browser/components/urlbar/private/SuggestFeature.sys.mjs"; 6 7 const lazy = {}; 8 9 ChromeUtils.defineESModuleGetters(lazy, { 10 GeolocationUtils: 11 "moz-src:///browser/components/urlbar/private/GeolocationUtils.sys.mjs", 12 MerinoClient: "moz-src:///browser/components/urlbar/MerinoClient.sys.mjs", 13 QuickSuggest: "moz-src:///browser/components/urlbar/QuickSuggest.sys.mjs", 14 Region: "resource://gre/modules/Region.sys.mjs", 15 UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs", 16 UrlbarResult: "moz-src:///browser/components/urlbar/UrlbarResult.sys.mjs", 17 UrlbarUtils: "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs", 18 UrlbarView: "moz-src:///browser/components/urlbar/UrlbarView.sys.mjs", 19 }); 20 21 const MERINO_PROVIDER = "accuweather"; 22 const MERINO_TIMEOUT_MS = 5000; // 5s 23 24 // Cache period for Merino's weather response. This is intentionally a small 25 // amount of time. See the `cachePeriodMs` discussion in `MerinoClient`. In 26 // addition, caching also helps prevent the weather suggestion from flickering 27 // out of and into the view as the user matches the same suggestion with each 28 // keystroke, especially when Merino has high latency. 29 const MERINO_WEATHER_CACHE_PERIOD_MS = 60000; // 1 minute 30 31 const RESULT_MENU_COMMAND = { 32 DISMISS: "dismiss", 33 HELP: "help", 34 INACCURATE_LOCATION: "inaccurate_location", 35 MANAGE: "manage", 36 SHOW_LESS_FREQUENTLY: "show_less_frequently", 37 }; 38 39 const WEATHER_PROVIDER_DISPLAY_NAME = "AccuWeather®"; 40 41 const NORTH_AMERICA_COUNTRY_CODES = new Set(["CA", "US"]); 42 43 const WEATHER_DYNAMIC_TYPE = "weather"; 44 const WEATHER_VIEW_TEMPLATE = { 45 attributes: { 46 selectable: true, 47 }, 48 children: [ 49 { 50 name: "currentConditions", 51 tag: "span", 52 children: [ 53 { 54 name: "currently", 55 tag: "div", 56 }, 57 { 58 name: "currentTemperature", 59 tag: "div", 60 children: [ 61 { 62 name: "temperature", 63 tag: "span", 64 }, 65 { 66 name: "weatherIcon", 67 tag: "img", 68 }, 69 ], 70 }, 71 ], 72 }, 73 { 74 name: "summary", 75 tag: "span", 76 overflowable: true, 77 children: [ 78 { 79 name: "top", 80 tag: "div", 81 children: [ 82 { 83 name: "topNoWrap", 84 tag: "span", 85 children: [ 86 { name: "title", tag: "span", classList: ["urlbarView-title"] }, 87 { 88 name: "titleSeparator", 89 tag: "span", 90 classList: ["urlbarView-title-separator"], 91 }, 92 ], 93 }, 94 { 95 name: "url", 96 tag: "span", 97 classList: ["urlbarView-url"], 98 }, 99 ], 100 }, 101 { 102 name: "middle", 103 tag: "div", 104 children: [ 105 { 106 name: "middleNoWrap", 107 tag: "span", 108 overflowable: true, 109 children: [ 110 { 111 name: "summaryText", 112 tag: "span", 113 }, 114 { 115 name: "summaryTextSeparator", 116 tag: "span", 117 }, 118 { 119 name: "highLow", 120 tag: "span", 121 }, 122 ], 123 }, 124 { 125 name: "highLowWrap", 126 tag: "span", 127 }, 128 ], 129 }, 130 { 131 name: "bottom", 132 tag: "div", 133 }, 134 ], 135 }, 136 ], 137 }; 138 139 /** 140 * A feature that periodically fetches weather suggestions from Merino. 141 */ 142 export class WeatherSuggestions extends SuggestProvider { 143 constructor() { 144 super(); 145 lazy.UrlbarResult.addDynamicResultType(WEATHER_DYNAMIC_TYPE); 146 lazy.UrlbarView.addDynamicViewTemplate( 147 WEATHER_DYNAMIC_TYPE, 148 WEATHER_VIEW_TEMPLATE 149 ); 150 } 151 152 get enablingPreferences() { 153 return [ 154 "weatherFeatureGate", 155 "suggest.weather", 156 "suggest.quicksuggest.all", 157 "suggest.quicksuggest.sponsored", 158 ]; 159 } 160 161 get primaryUserControlledPreferences() { 162 return ["suggest.weather"]; 163 } 164 165 get rustSuggestionType() { 166 return "Weather"; 167 } 168 169 get showLessFrequentlyCount() { 170 const count = lazy.UrlbarPrefs.get("weather.showLessFrequentlyCount") || 0; 171 return Math.max(count, 0); 172 } 173 174 get canShowLessFrequently() { 175 const cap = 176 lazy.UrlbarPrefs.get("weatherShowLessFrequentlyCap") || 177 lazy.QuickSuggest.config.showLessFrequentlyCap || 178 0; 179 return !cap || this.showLessFrequentlyCount < cap; 180 } 181 182 isSuggestionSponsored(_suggestion) { 183 return true; 184 } 185 186 getSuggestionTelemetryType() { 187 return "weather"; 188 } 189 190 enable(enabled) { 191 if (!enabled) { 192 this.#merino = null; 193 } 194 } 195 196 async filterSuggestions(suggestions) { 197 // If the query didn't include a city, Rust will return at most one 198 // suggestion. If the query matched multiple cities, Rust will return one 199 // suggestion per city. All suggestions will have the same score, and 200 // they'll be ordered by population size from largest to smallest. 201 if (suggestions.length <= 1) { 202 return suggestions; 203 } 204 205 let suggestion = await lazy.GeolocationUtils.best(suggestions, s => ({ 206 latitude: s.city?.latitude, 207 longitude: s.city?.longitude, 208 country: s.city?.countryCode, 209 region: s.city?.adminDivisionCodes.get(1), 210 population: s.city?.population, 211 })); 212 213 return [suggestion]; 214 } 215 216 async makeResult(queryContext, suggestion, searchString) { 217 if (searchString.length < this.#minKeywordLength) { 218 return null; 219 } 220 221 // `suggestion` is a Rust suggestion that tells us weather intent was 222 // matched and possibly a city. Fetch the final suggestion from Merino. 223 let merinoSuggestion = await this.#fetchMerinoSuggestion(suggestion.city); 224 if (!merinoSuggestion) { 225 return null; 226 } 227 228 let unit = Services.locale.regionalPrefsLocales[0] == "en-US" ? "f" : "c"; 229 230 let treatment = lazy.UrlbarPrefs.get("weatherUiTreatment"); 231 if (treatment == 1 || treatment == 2) { 232 return this.#makeDynamicResult(merinoSuggestion, unit); 233 } 234 235 let titleL10n = await this.#getTitleL10n(suggestion.city, merinoSuggestion); 236 237 return new lazy.UrlbarResult({ 238 type: lazy.UrlbarUtils.RESULT_TYPE.URL, 239 source: lazy.UrlbarUtils.RESULT_SOURCE.SEARCH, 240 suggestedIndex: 1, 241 isRichSuggestion: true, 242 richSuggestionIconVariation: String( 243 merinoSuggestion.current_conditions.icon_id 244 ), 245 payload: { 246 url: merinoSuggestion.url, 247 titleL10n: { 248 id: titleL10n.id, 249 args: { 250 temperature: merinoSuggestion.current_conditions.temperature[unit], 251 unit: unit.toUpperCase(), 252 ...titleL10n.args, 253 }, 254 parseMarkup: true, 255 }, 256 bottomTextL10n: { 257 id: "urlbar-result-weather-provider-sponsored", 258 args: { provider: WEATHER_PROVIDER_DISPLAY_NAME }, 259 }, 260 helpUrl: lazy.QuickSuggest.HELP_URL, 261 }, 262 }); 263 } 264 265 #makeDynamicResult(suggestion, unit) { 266 return new lazy.UrlbarResult({ 267 type: lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC, 268 source: lazy.UrlbarUtils.RESULT_SOURCE.SEARCH, 269 showFeedbackMenu: true, 270 suggestedIndex: 1, 271 payload: { 272 url: suggestion.url, 273 input: suggestion.url, 274 iconId: suggestion.current_conditions.icon_id, 275 dynamicType: WEATHER_DYNAMIC_TYPE, 276 city: suggestion.city_name, 277 region: suggestion.region_code, 278 temperatureUnit: unit, 279 temperature: suggestion.current_conditions.temperature[unit], 280 currentConditions: suggestion.current_conditions.summary, 281 forecast: suggestion.forecast.summary, 282 high: suggestion.forecast.high[unit], 283 low: suggestion.forecast.low[unit], 284 showRowLabel: true, 285 helpUrl: lazy.QuickSuggest.HELP_URL, 286 }, 287 }); 288 } 289 290 getViewUpdate(result) { 291 let useSimplerUi = lazy.UrlbarPrefs.get("weatherUiTreatment") == 1; 292 let uppercaseUnit = result.payload.temperatureUnit.toUpperCase(); 293 return { 294 currently: { 295 l10n: { 296 id: "firefox-suggest-weather-currently", 297 }, 298 }, 299 temperature: { 300 l10n: { 301 id: "firefox-suggest-weather-temperature", 302 args: { 303 value: result.payload.temperature, 304 unit: uppercaseUnit, 305 }, 306 }, 307 }, 308 weatherIcon: { 309 attributes: { "icon-variation": result.payload.iconId }, 310 }, 311 title: { 312 l10n: { 313 id: "firefox-suggest-weather-title", 314 args: { city: result.payload.city, region: result.payload.region }, 315 }, 316 }, 317 url: { 318 textContent: result.payload.url, 319 }, 320 summaryText: useSimplerUi 321 ? { textContent: result.payload.currentConditions } 322 : { 323 l10n: { 324 id: "firefox-suggest-weather-summary-text", 325 args: { 326 currentConditions: result.payload.currentConditions, 327 forecast: result.payload.forecast, 328 }, 329 }, 330 }, 331 highLow: { 332 l10n: { 333 id: "firefox-suggest-weather-high-low", 334 args: { 335 high: result.payload.high, 336 low: result.payload.low, 337 unit: uppercaseUnit, 338 }, 339 }, 340 }, 341 highLowWrap: { 342 l10n: { 343 id: "firefox-suggest-weather-high-low", 344 args: { 345 high: result.payload.high, 346 low: result.payload.low, 347 unit: uppercaseUnit, 348 }, 349 }, 350 }, 351 bottom: { 352 l10n: { 353 id: "urlbar-result-weather-provider-sponsored", 354 args: { provider: WEATHER_PROVIDER_DISPLAY_NAME }, 355 }, 356 }, 357 }; 358 } 359 360 /** 361 * Gets the list of commands that should be shown in the result menu for a 362 * given result from the provider. All commands returned by this method should 363 * be handled by implementing `onEngagement()` with the possible exception of 364 * commands automatically handled by the urlbar, like "help". 365 */ 366 getResultCommands() { 367 /** @type {UrlbarResultCommand[]} */ 368 let commands = [ 369 { 370 name: RESULT_MENU_COMMAND.INACCURATE_LOCATION, 371 l10n: { 372 id: "urlbar-result-menu-report-inaccurate-location", 373 }, 374 }, 375 ]; 376 377 if (this.canShowLessFrequently) { 378 commands.push({ 379 name: RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY, 380 l10n: { 381 id: "urlbar-result-menu-show-less-frequently", 382 }, 383 }); 384 } 385 386 commands.push( 387 { 388 name: RESULT_MENU_COMMAND.DISMISS, 389 l10n: { 390 id: "urlbar-result-menu-dont-show-weather-suggestions", 391 }, 392 }, 393 { name: "separator" }, 394 { 395 name: RESULT_MENU_COMMAND.MANAGE, 396 l10n: { 397 id: "urlbar-result-menu-manage-firefox-suggest", 398 }, 399 }, 400 { 401 name: RESULT_MENU_COMMAND.HELP, 402 l10n: { 403 id: "urlbar-result-menu-learn-more-about-firefox-suggest", 404 }, 405 } 406 ); 407 408 return commands; 409 } 410 411 onEngagement(queryContext, controller, details, searchString) { 412 let { result } = details; 413 switch (details.selType) { 414 case RESULT_MENU_COMMAND.HELP: 415 case RESULT_MENU_COMMAND.MANAGE: 416 // "help" and "manage" are handled by UrlbarInput, no need to do 417 // anything here. 418 break; 419 // Note that selType == "dismiss" when the user presses the dismiss key 420 // shortcut, in addition to the result menu command. 421 case RESULT_MENU_COMMAND.DISMISS: 422 this.logger.info("Dismissing weather result"); 423 lazy.UrlbarPrefs.set("suggest.weather", false); 424 result.acknowledgeDismissalL10n = { 425 id: "urlbar-dismissal-acknowledgment-weather", 426 }; 427 controller.removeResult(result); 428 break; 429 case RESULT_MENU_COMMAND.INACCURATE_LOCATION: 430 // Currently the only way we record this feedback is in the Glean 431 // engagement event. As with all commands, it will be recorded with an 432 // `engagement_type` value that is the command's name, in this case 433 // `inaccurate_location`. 434 controller.view.acknowledgeFeedback(result); 435 break; 436 case RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY: 437 controller.view.acknowledgeFeedback(result); 438 this.incrementShowLessFrequentlyCount(); 439 if (!this.canShowLessFrequently) { 440 controller.view.invalidateResultMenuCommands(); 441 } 442 lazy.UrlbarPrefs.set( 443 "weather.minKeywordLength", 444 searchString.length + 1 445 ); 446 break; 447 } 448 } 449 450 incrementShowLessFrequentlyCount() { 451 if (this.canShowLessFrequently) { 452 lazy.UrlbarPrefs.set( 453 "weather.showLessFrequentlyCount", 454 this.showLessFrequentlyCount + 1 455 ); 456 } 457 } 458 459 get #config() { 460 let { rustBackend } = lazy.QuickSuggest; 461 let config = rustBackend.isEnabled 462 ? rustBackend.getConfigForSuggestionType(this.rustSuggestionType) 463 : null; 464 return config || {}; 465 } 466 467 get #minKeywordLength() { 468 // Use the pref value if it has a user value, which means the user clicked 469 // "Show less frequently" at least once. Otherwise, fall back to the Nimbus 470 // value and then the config value. That lets us override the pref's default 471 // value using Nimbus or the config, if necessary. 472 let minLength = lazy.UrlbarPrefs.get("weather.minKeywordLength"); 473 if ( 474 !Services.prefs.prefHasUserValue( 475 "browser.urlbar.weather.minKeywordLength" 476 ) 477 ) { 478 let nimbusValue = lazy.UrlbarPrefs.get("weatherKeywordsMinimumLength"); 479 if (nimbusValue !== null) { 480 minLength = nimbusValue; 481 } else if (!isNaN(this.#config.minKeywordLength)) { 482 minLength = this.#config.minKeywordLength; 483 } 484 } 485 return Math.max(minLength, 0); 486 } 487 488 async #fetchMerinoSuggestion(cityGeoname) { 489 if (!this.#merino) { 490 this.#merino = new lazy.MerinoClient(this.constructor.name, { 491 allowOhttp: true, 492 cachePeriodMs: MERINO_WEATHER_CACHE_PERIOD_MS, 493 }); 494 } 495 496 let merino = this.#merino; 497 let fetchInstance = (this.#fetchInstance = {}); 498 499 // Set up location params to pass to Merino. We need to null-check each 500 // suggestion property because `MerinoClient` will stringify null values. 501 let otherParams = { source: "urlbar" }; 502 if (cityGeoname) { 503 if (cityGeoname.name) { 504 otherParams.city = cityGeoname.name; 505 } 506 if (cityGeoname.countryCode) { 507 otherParams.country = cityGeoname.countryCode; 508 } 509 // The admin codes are a `Map` from integer levels to codes. Convert it to 510 // a comma-separated string of codes sorted by level ascending. 511 let adminCodes = [...cityGeoname.adminDivisionCodes.entries()] 512 .sort(([level1, _admin1], [level2, _admin2]) => level1 - level2) 513 .map(([_, admin]) => admin) 514 .join(","); 515 if (adminCodes) { 516 otherParams.region = adminCodes; 517 } 518 } else { 519 let geolocation = await lazy.GeolocationUtils.geolocation(); 520 521 if ( 522 !geolocation || 523 fetchInstance != this.#fetchInstance || 524 merino != this.#merino 525 ) { 526 return null; 527 } 528 529 if (geolocation.country_code) { 530 otherParams.country = geolocation.country_code; 531 } 532 let region = geolocation.region_code || geolocation.region; 533 if (region) { 534 otherParams.region = region; 535 } 536 let city = geolocation.city || geolocation.region; 537 if (city) { 538 otherParams.city = city; 539 } 540 } 541 542 let merinoSuggestions = await merino.fetch({ 543 query: "", 544 otherParams, 545 providers: [MERINO_PROVIDER], 546 timeoutMs: this.#timeoutMs, 547 }); 548 if (fetchInstance != this.#fetchInstance || merino != this.#merino) { 549 return null; 550 } 551 552 return merinoSuggestions[0] ?? null; 553 } 554 555 async #getTitleL10n(cityGeoname, merinoSuggestion) { 556 let displayCity = ""; 557 let displayRegion = ""; 558 let displayCountry = ""; 559 560 if (!cityGeoname) { 561 displayCity = merinoSuggestion.city_name; 562 displayRegion = merinoSuggestion.region_code; 563 } else { 564 // Fetch localized names for the city. 565 let alts = 566 await lazy.QuickSuggest.rustBackend.fetchGeonameAlternates(cityGeoname); 567 568 displayCity = alts.geoname.localized || alts.geoname.primary; 569 570 // For cities in Canada and the US, always show the province/state using 571 // its usual two-char abbreviation. For other countries we won't show any 572 // admin divisions at all; there's maybe room for improvement here. 573 if (NORTH_AMERICA_COUNTRY_CODES.has(cityGeoname.countryCode)) { 574 displayRegion = 575 alts.adminDivisions.get(1)?.abbreviation || 576 alts.adminDivisions.get(1)?.localized || 577 alts.adminDivisions.get(1)?.primary; 578 } 579 580 // If the city's country is different from the user's, show it. 581 if (cityGeoname.countryCode != lazy.Region.home) { 582 displayCountry = alts.country?.localized || alts.country?.primary; 583 } 584 } 585 586 if (displayRegion && displayCountry) { 587 return { 588 id: "urlbar-result-weather-title-with-country", 589 args: { 590 city: displayCity, 591 region: displayRegion, 592 country: displayCountry, 593 }, 594 }; 595 } 596 597 // This is a little confusing but if we only have a country, show it as the 598 // "region". Don't get hung up on the name of this l10n string variable. It 599 // just means the final string will be "{city}, {country}". 600 let region = displayRegion || displayCountry; 601 if (region) { 602 return { 603 id: "urlbar-result-weather-title", 604 args: { 605 region, 606 city: displayCity, 607 }, 608 }; 609 } 610 611 return { 612 id: "urlbar-result-weather-title-city-only", 613 args: { 614 city: displayCity, 615 }, 616 }; 617 } 618 619 get _test_merino() { 620 return this.#merino; 621 } 622 623 _test_setTimeoutMs(ms) { 624 this.#timeoutMs = ms < 0 ? MERINO_TIMEOUT_MS : ms; 625 } 626 627 #fetchInstance = null; 628 #merino = null; 629 #timeoutMs = MERINO_TIMEOUT_MS; 630 }