Files
tubestation/browser/components/urlbar/private/Weather.sys.mjs
Drew Willcoxon ba5a33f806 Bug 1931221 - Urlbar weather suggestions: Include the region in the title and set the input value to the URL when selected. r=daisuke,fluent-reviewers
This makes two simple changes:

* Include the region in the suggestion title. The Merino team added a
  `region_code` that we can use for this. (Right now we only need to worry about
  U.S. and Canadian locations.)
* When the suggestion is selected, set the input value to the suggestion URL.
  Currently the input becomes empty, which is strange IMO and isn't how other
  suggestions behave.

Depends on D229090

Differential Revision: https://phabricator.services.mozilla.com/D229091
2024-11-16 03:04:13 +00:00

605 lines
18 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/. */
import { BaseFeature } from "resource:///modules/urlbar/private/BaseFeature.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
MerinoClient: "resource:///modules/MerinoClient.sys.mjs",
QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs",
UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs",
UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
UrlbarView: "resource:///modules/UrlbarView.sys.mjs",
});
const MERINO_PROVIDER = "accuweather";
const MERINO_TIMEOUT_MS = 5000; // 5s
const HISTOGRAM_LATENCY = "FX_URLBAR_MERINO_LATENCY_WEATHER_MS";
const HISTOGRAM_RESPONSE = "FX_URLBAR_MERINO_RESPONSE_WEATHER";
// Cache period for Merino's weather response. This is intentionally a small
// amount of time. See the `cachePeriodMs` discussion in `MerinoClient`. In
// addition, caching also helps prevent the weather suggestion from flickering
// out of and into the view as the user matches the same suggestion with each
// keystroke, especially when Merino has high latency.
const MERINO_WEATHER_CACHE_PERIOD_MS = 60000; // 1 minute
// The mean Earth radius used in distance calculations.
const EARTH_RADIUS_KM = 6371.009;
const RESULT_MENU_COMMAND = {
INACCURATE_LOCATION: "inaccurate_location",
MANAGE: "manage",
NOT_INTERESTED: "not_interested",
NOT_RELEVANT: "not_relevant",
SHOW_LESS_FREQUENTLY: "show_less_frequently",
};
const WEATHER_PROVIDER_DISPLAY_NAME = "AccuWeather";
const WEATHER_DYNAMIC_TYPE = "weather";
const WEATHER_VIEW_TEMPLATE = {
attributes: {
selectable: true,
},
children: [
{
name: "currentConditions",
tag: "span",
children: [
{
name: "currently",
tag: "div",
},
{
name: "currentTemperature",
tag: "div",
children: [
{
name: "temperature",
tag: "span",
},
{
name: "weatherIcon",
tag: "img",
},
],
},
],
},
{
name: "summary",
tag: "span",
overflowable: true,
children: [
{
name: "top",
tag: "div",
children: [
{
name: "topNoWrap",
tag: "span",
children: [
{ name: "title", tag: "span", classList: ["urlbarView-title"] },
{
name: "titleSeparator",
tag: "span",
classList: ["urlbarView-title-separator"],
},
],
},
{
name: "url",
tag: "span",
classList: ["urlbarView-url"],
},
],
},
{
name: "middle",
tag: "div",
children: [
{
name: "middleNoWrap",
tag: "span",
overflowable: true,
children: [
{
name: "summaryText",
tag: "span",
},
{
name: "summaryTextSeparator",
tag: "span",
},
{
name: "highLow",
tag: "span",
},
],
},
{
name: "highLowWrap",
tag: "span",
},
],
},
{
name: "bottom",
tag: "div",
},
],
},
],
};
/**
* A feature that periodically fetches weather suggestions from Merino.
*/
export class Weather extends BaseFeature {
constructor(...args) {
super(...args);
lazy.UrlbarResult.addDynamicResultType(WEATHER_DYNAMIC_TYPE);
lazy.UrlbarView.addDynamicViewTemplate(
WEATHER_DYNAMIC_TYPE,
WEATHER_VIEW_TEMPLATE
);
}
get shouldEnable() {
return (
lazy.UrlbarPrefs.get("suggest.quicksuggest.sponsored") &&
lazy.UrlbarPrefs.get("weatherFeatureGate") &&
lazy.UrlbarPrefs.get("suggest.weather")
);
}
get enablingPreferences() {
return ["suggest.quicksuggest.sponsored", "suggest.weather"];
}
get rustSuggestionTypes() {
return ["Weather"];
}
get showLessFrequentlyCount() {
const count = lazy.UrlbarPrefs.get("weather.showLessFrequentlyCount") || 0;
return Math.max(count, 0);
}
get canShowLessFrequently() {
const cap =
lazy.UrlbarPrefs.get("weatherShowLessFrequentlyCap") ||
lazy.QuickSuggest.backend.config?.showLessFrequentlyCap ||
0;
return !cap || this.showLessFrequentlyCount < cap;
}
isSuggestionSponsored(_suggestion) {
return true;
}
getSuggestionTelemetryType() {
return "weather";
}
enable(enabled) {
if (!enabled) {
this.#merino = null;
}
}
async filterSuggestions(suggestions) {
// If the query didn't include a city, Rust will return at most one
// suggestion. If the query matched multiple cities, Rust will return one
// suggestion per city. All suggestions will have the same score, and
// they'll be ordered by population size from largest to smallest.
if (suggestions.length <= 1) {
return suggestions;
}
let geo = await lazy.QuickSuggest.geolocation();
return [
this.#bestSuggestionByDistance(geo, suggestions) ||
this.#bestSuggestionByRegion(geo, suggestions) ||
suggestions[0],
];
}
/**
* Returns the suggestion with the city nearest the client's geolocation based
* on the great-circle distance between the coordinates [1]. This isn't
* necessarily super accurate, but that's OK since it's stable and accurate
* enough to find a good matching suggestion.
*
* [1] https://en.wikipedia.org/wiki/Great-circle_distance
*
* @param {object} geo
* The `geolocation` object returned by Merino's geolocation provider. It's
* expected to look like the following, but we gracefully handle exceptions:
*
* `{ location: { latitude, longitude, radius }}`
*
* The coordinates are expected to be in decimal and the radius is expected
* to be in km.
* @param {Array} suggestions
* Array of candidate weather suggestions.
* @returns {object|null}
* The nearest suggestion as described above. If there are multiple nearest
* cities within the accuracy radius, the most populous one is returned. If
* the `geo` does not include a location or coordinates, null is returned.
*/
#bestSuggestionByDistance(geo, suggestions) {
let geoLat = geo?.location?.latitude;
let geoLong = geo?.location?.longitude;
if (isNaN(geoLat) || isNaN(geoLong)) {
return null;
}
// All distances are in km.
[geoLat, geoLong] = [geoLat, geoLong].map(toRadians);
let geoLatSin = Math.sin(geoLat);
let geoLatCos = Math.cos(geoLat);
let geoRadius = geo?.location?.radius || 5;
let best;
let dMin = Infinity;
for (let s of suggestions) {
let [sLat, sLong] = [s.latitude, s.longitude].map(toRadians);
let d =
EARTH_RADIUS_KM *
Math.acos(
geoLatSin * Math.sin(sLat) +
geoLatCos * Math.cos(sLat) * Math.cos(Math.abs(geoLong - sLong))
);
if (
!best ||
// `s` is closer to the client than `best`.
d + geoRadius < dMin ||
// `s` is the same distance from the client as `best`, i.e., the
// difference between `s` and `best` is within the accuracy radius.
// Choose `s` if it has a larger population.
(Math.abs(d - dMin) <= geoRadius && best.population < s.population)
) {
dMin = d;
best = s;
}
}
return best;
}
/**
* Returns the first suggestion with a city located in the same region and
* country as the client's geolocation. If there is no such suggestion, the
* first suggestion in the same country is returned. If there is no suggestion
* in the same country, null is returned. Since `suggestions` is ordered by
* population, if multiple cities match any of these criteria, the one that's
* returned will be the most populous.
*
* @param {object} geo
* The `geolocation` object returned by Merino's geolocation provider. It's
* expected to look like the following, but we gracefully handle exceptions:
*
* `{ region_code, country_code }`
* @param {Array} suggestions
* Array of candidate weather suggestions.
* @returns {object|null}
* The suggestion as described above or null.
*/
#bestSuggestionByRegion(geo, suggestions) {
let region = geo?.region_code?.toLowerCase();
let country = geo?.country_code?.toLowerCase();
if (!region && !country) {
return null;
}
let sameCountrySuggestion = null;
for (let s of suggestions) {
let sameRegion = s.region.toLowerCase() == region;
let sameCountry = s.country.toLowerCase() == country;
if (sameRegion && sameCountry) {
// This is the most populous city (since suggestions are ordered by
// population) in the client's region. Can't get better than this.
return s;
}
if (sameCountry && !sameCountrySuggestion) {
sameCountrySuggestion = s;
}
}
return sameCountrySuggestion;
}
async makeResult(queryContext, suggestion, searchString) {
if (searchString.length < this.#minKeywordLength) {
return null;
}
if (!this.#merino) {
this.#merino = new lazy.MerinoClient(this.constructor.name, {
cachePeriodMs: MERINO_WEATHER_CACHE_PERIOD_MS,
});
}
// Set up location params to pass to Merino. We need to null-check each
// suggestion property because `MerinoClient` will stringify null values.
let otherParams = {};
for (let key of ["city", "region", "country"]) {
if (suggestion[key]) {
otherParams[key] = suggestion[key];
}
}
let merino = this.#merino;
let fetchInstance = (this.#fetchInstance = {});
let suggestions = await merino.fetch({
query: "",
otherParams,
providers: [MERINO_PROVIDER],
timeoutMs: this.#timeoutMs,
extraLatencyHistogram: HISTOGRAM_LATENCY,
extraResponseHistogram: HISTOGRAM_RESPONSE,
});
if (fetchInstance != this.#fetchInstance || merino != this.#merino) {
return null;
}
if (!suggestions.length) {
return null;
}
suggestion = suggestions[0];
let unit = Services.locale.regionalPrefsLocales[0] == "en-US" ? "f" : "c";
return Object.assign(
new lazy.UrlbarResult(
lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC,
lazy.UrlbarUtils.RESULT_SOURCE.SEARCH,
{
url: suggestion.url,
input: suggestion.url,
iconId: suggestion.current_conditions.icon_id,
requestId: suggestion.request_id,
dynamicType: WEATHER_DYNAMIC_TYPE,
city: suggestion.city_name,
region: suggestion.region_code,
temperatureUnit: unit,
temperature: suggestion.current_conditions.temperature[unit],
currentConditions: suggestion.current_conditions.summary,
forecast: suggestion.forecast.summary,
high: suggestion.forecast.high[unit],
low: suggestion.forecast.low[unit],
}
),
{
showFeedbackMenu: true,
suggestedIndex: searchString ? 1 : 0,
}
);
}
getViewUpdate(result) {
let uppercaseUnit = result.payload.temperatureUnit.toUpperCase();
return {
currently: {
l10n: {
id: "firefox-suggest-weather-currently",
cacheable: true,
},
},
temperature: {
l10n: {
id: "firefox-suggest-weather-temperature",
args: {
value: result.payload.temperature,
unit: uppercaseUnit,
},
cacheable: true,
excludeArgsFromCacheKey: true,
},
},
weatherIcon: {
attributes: { iconId: result.payload.iconId },
},
title: {
l10n: {
id: "firefox-suggest-weather-title",
args: { city: result.payload.city, region: result.payload.region },
cacheable: true,
excludeArgsFromCacheKey: true,
},
},
url: {
textContent: result.payload.url,
},
summaryText: lazy.UrlbarPrefs.get("weatherSimpleUI")
? { textContent: result.payload.currentConditions }
: {
l10n: {
id: "firefox-suggest-weather-summary-text",
args: {
currentConditions: result.payload.currentConditions,
forecast: result.payload.forecast,
},
cacheable: true,
excludeArgsFromCacheKey: true,
},
},
highLow: {
l10n: {
id: "firefox-suggest-weather-high-low",
args: {
high: result.payload.high,
low: result.payload.low,
unit: uppercaseUnit,
},
cacheable: true,
excludeArgsFromCacheKey: true,
},
},
highLowWrap: {
l10n: {
id: "firefox-suggest-weather-high-low",
args: {
high: result.payload.high,
low: result.payload.low,
unit: uppercaseUnit,
},
},
},
bottom: {
l10n: {
id: "firefox-suggest-weather-sponsored",
args: { provider: WEATHER_PROVIDER_DISPLAY_NAME },
cacheable: true,
},
},
};
}
getResultCommands() {
let commands = [
{
name: RESULT_MENU_COMMAND.INACCURATE_LOCATION,
l10n: {
id: "firefox-suggest-weather-command-inaccurate-location",
},
},
];
if (this.canShowLessFrequently) {
commands.push({
name: RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY,
l10n: {
id: "firefox-suggest-command-show-less-frequently",
},
});
}
commands.push(
{
l10n: {
id: "firefox-suggest-command-dont-show-this",
},
children: [
{
name: RESULT_MENU_COMMAND.NOT_RELEVANT,
l10n: {
id: "firefox-suggest-command-not-relevant",
},
},
{
name: RESULT_MENU_COMMAND.NOT_INTERESTED,
l10n: {
id: "firefox-suggest-command-not-interested",
},
},
],
},
{ name: "separator" },
{
name: RESULT_MENU_COMMAND.MANAGE,
l10n: {
id: "urlbar-result-menu-manage-firefox-suggest",
},
}
);
return commands;
}
handleCommand(view, result, selType, searchString) {
switch (selType) {
case RESULT_MENU_COMMAND.MANAGE:
// "manage" is handled by UrlbarInput, no need to do anything here.
break;
// selType == "dismiss" when the user presses the dismiss key shortcut.
case "dismiss":
case RESULT_MENU_COMMAND.NOT_INTERESTED:
case RESULT_MENU_COMMAND.NOT_RELEVANT:
this.logger.info("Dismissing weather result");
lazy.UrlbarPrefs.set("suggest.weather", false);
result.acknowledgeDismissalL10n = {
id: "firefox-suggest-dismissal-acknowledgment-all",
};
view.controller.removeResult(result);
break;
case RESULT_MENU_COMMAND.INACCURATE_LOCATION:
// Currently the only way we record this feedback is in the Glean
// engagement event. As with all commands, it will be recorded with an
// `engagement_type` value that is the command's name, in this case
// `inaccurate_location`.
view.acknowledgeFeedback(result);
break;
case RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY:
view.acknowledgeFeedback(result);
this.incrementShowLessFrequentlyCount();
if (!this.canShowLessFrequently) {
view.invalidateResultMenuCommands();
}
lazy.UrlbarPrefs.set(
"weather.minKeywordLength",
searchString.length + 1
);
break;
}
}
incrementShowLessFrequentlyCount() {
if (this.canShowLessFrequently) {
lazy.UrlbarPrefs.set(
"weather.showLessFrequentlyCount",
this.showLessFrequentlyCount + 1
);
}
}
get #config() {
let { rustBackend } = lazy.QuickSuggest;
let config = rustBackend.isEnabled
? rustBackend.getConfigForSuggestionType(this.rustSuggestionTypes[0])
: null;
return config || {};
}
get #minKeywordLength() {
// Use the pref value if it has a user value, which means the user clicked
// "Show less frequently" at least once. Otherwise, fall back to the Nimbus
// value and then the config value. That lets us override the pref's default
// value using Nimbus or the config, if necessary.
let minLength = lazy.UrlbarPrefs.get("weather.minKeywordLength");
if (
!Services.prefs.prefHasUserValue(
"browser.urlbar.weather.minKeywordLength"
)
) {
let nimbusValue = lazy.UrlbarPrefs.get("weatherKeywordsMinimumLength");
if (nimbusValue !== null) {
minLength = nimbusValue;
} else if (!isNaN(this.#config.minKeywordLength)) {
minLength = this.#config.minKeywordLength;
}
}
return Math.max(minLength, 0);
}
get _test_merino() {
return this.#merino;
}
_test_setTimeoutMs(ms) {
this.#timeoutMs = ms < 0 ? MERINO_TIMEOUT_MS : ms;
}
#fetchInstance = null;
#merino = null;
#timeoutMs = MERINO_TIMEOUT_MS;
}
function toRadians(deg) {
return (deg * Math.PI) / 180;
}