summaryrefslogtreecommitdiffstats
path: root/browser/components/urlbar/UrlbarProviderWeather.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--browser/components/urlbar/UrlbarProviderWeather.sys.mjs590
1 files changed, 590 insertions, 0 deletions
diff --git a/browser/components/urlbar/UrlbarProviderWeather.sys.mjs b/browser/components/urlbar/UrlbarProviderWeather.sys.mjs
new file mode 100644
index 0000000000..b67fa61645
--- /dev/null
+++ b/browser/components/urlbar/UrlbarProviderWeather.sys.mjs
@@ -0,0 +1,590 @@
+/* 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 {
+ UrlbarProvider,
+ UrlbarUtils,
+} from "resource:///modules/UrlbarUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs",
+ UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
+ UrlbarProviderTopSites: "resource:///modules/UrlbarProviderTopSites.sys.mjs",
+ UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs",
+ UrlbarView: "resource:///modules/UrlbarView.sys.mjs",
+});
+
+const WEATHER_PROVIDER_DISPLAY_NAME = "AccuWeather";
+
+const TELEMETRY_PREFIX = "contextual.services.quicksuggest";
+
+const TELEMETRY_SCALARS = {
+ BLOCK: `${TELEMETRY_PREFIX}.block_weather`,
+ CLICK: `${TELEMETRY_PREFIX}.click_weather`,
+ HELP: `${TELEMETRY_PREFIX}.help_weather`,
+ IMPRESSION: `${TELEMETRY_PREFIX}.impression_weather`,
+};
+
+const RESULT_MENU_COMMAND = {
+ HELP: "help",
+ INACCURATE_LOCATION: "inaccurate_location",
+ NOT_INTERESTED: "not_interested",
+ NOT_RELEVANT: "not_relevant",
+ SHOW_LESS_FREQUENTLY: "show_less_frequently",
+};
+
+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 provider that returns a suggested url to the user based on what
+ * they have currently typed so they can navigate directly.
+ */
+class ProviderWeather extends UrlbarProvider {
+ constructor(...args) {
+ super(...args);
+ lazy.UrlbarResult.addDynamicResultType(WEATHER_DYNAMIC_TYPE);
+ lazy.UrlbarView.addDynamicViewTemplate(
+ WEATHER_DYNAMIC_TYPE,
+ WEATHER_VIEW_TEMPLATE
+ );
+ }
+
+ /**
+ * Returns the name of this provider.
+ *
+ * @returns {string} the name of this provider.
+ */
+ get name() {
+ return "Weather";
+ }
+
+ /**
+ * The type of the provider.
+ *
+ * @returns {UrlbarUtils.PROVIDER_TYPE}
+ */
+ get type() {
+ return UrlbarUtils.PROVIDER_TYPE.NETWORK;
+ }
+
+ /**
+ * @returns {object} An object mapping from mnemonics to scalar names.
+ */
+ get TELEMETRY_SCALARS() {
+ return { ...TELEMETRY_SCALARS };
+ }
+
+ getPriority(context) {
+ if (!context.searchString) {
+ // Zero-prefix suggestions have the same priority as top sites.
+ return lazy.UrlbarProviderTopSites.PRIORITY;
+ }
+ return super.getPriority(context);
+ }
+
+ /**
+ * Whether this provider should be invoked for the given context.
+ * If this method returns false, the providers manager won't start a query
+ * with this provider, to save on resources.
+ *
+ * @param {UrlbarQueryContext} queryContext The query context object
+ * @returns {boolean} Whether this provider should be invoked for the search.
+ */
+ isActive(queryContext) {
+ this.#resultFromLastQuery = null;
+
+ // If the sources don't include search or the user used a restriction
+ // character other than search, don't allow any suggestions.
+ if (
+ !queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.SEARCH) ||
+ (queryContext.restrictSource &&
+ queryContext.restrictSource != UrlbarUtils.RESULT_SOURCE.SEARCH)
+ ) {
+ return false;
+ }
+
+ if (
+ queryContext.isPrivate ||
+ queryContext.searchMode ||
+ // `QuickSuggest.weather` will be undefined if `QuickSuggest` hasn't been
+ // initialized.
+ !lazy.QuickSuggest.weather?.suggestion
+ ) {
+ return false;
+ }
+
+ let { keywords } = lazy.QuickSuggest.weather;
+ if (!keywords) {
+ return false;
+ }
+
+ return keywords.has(queryContext.searchString.trim().toLocaleLowerCase());
+ }
+
+ /**
+ * Starts querying. Extended classes should return a Promise resolved when the
+ * provider is done searching AND returning results.
+ *
+ * @param {UrlbarQueryContext} queryContext The query context object
+ * @param {Function} addCallback Callback invoked by the provider to add a new
+ * result. A UrlbarResult should be passed to it.
+ * @returns {Promise}
+ */
+ async startQuery(queryContext, addCallback) {
+ let { suggestion } = lazy.QuickSuggest.weather;
+ if (!suggestion) {
+ return;
+ }
+
+ let unit = Services.locale.regionalPrefsLocales[0] == "en-US" ? "f" : "c";
+ let result = new lazy.UrlbarResult(
+ UrlbarUtils.RESULT_TYPE.DYNAMIC,
+ UrlbarUtils.RESULT_SOURCE.SEARCH,
+ {
+ url: suggestion.url,
+ iconId: suggestion.current_conditions.icon_id,
+ helpUrl: lazy.QuickSuggest.HELP_URL,
+ // TODO: Remove helpL10n, isBlockable, and blockL10n once the telemetry
+ // test is updated for the result menu.
+ helpL10n: {
+ id: lazy.UrlbarPrefs.get("resultMenu")
+ ? "urlbar-result-menu-learn-more-about-firefox-suggest"
+ : "firefox-suggest-urlbar-learn-more",
+ },
+ isBlockable: true,
+ blockL10n: {
+ id: lazy.UrlbarPrefs.get("resultMenu")
+ ? "urlbar-result-menu-dismiss-firefox-suggest"
+ : "firefox-suggest-urlbar-block",
+ },
+ requestId: suggestion.request_id,
+ source: suggestion.source,
+ merinoProvider: suggestion.provider,
+ dynamicType: WEATHER_DYNAMIC_TYPE,
+ city: suggestion.city_name,
+ 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],
+ shouldNavigate: true,
+ }
+ );
+
+ result.showFeedbackMenu = true;
+ result.suggestedIndex = queryContext.searchString ? 1 : 0;
+
+ addCallback(this, result);
+ this.#resultFromLastQuery = result;
+ }
+
+ getResultCommands(result) {
+ let commands = [
+ {
+ name: RESULT_MENU_COMMAND.INACCURATE_LOCATION,
+ l10n: {
+ id: "firefox-suggest-weather-command-inaccurate-location",
+ },
+ },
+ ];
+
+ if (lazy.QuickSuggest.weather.canIncrementMinKeywordLength) {
+ 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.HELP,
+ l10n: {
+ id: "urlbar-result-menu-learn-more-about-firefox-suggest",
+ },
+ }
+ );
+
+ return commands;
+ }
+
+ /**
+ * This is called only for dynamic result types, when the urlbar view updates
+ * the view of one of the results of the provider. It should return an object
+ * describing the view update.
+ *
+ * @param {UrlbarResult} result
+ * The result whose view will be updated.
+ * @param {Map} idsByName
+ * A Map from an element's name, as defined by the provider; to its ID in
+ * the DOM, as defined by the browser.This is useful if parts of the view
+ * update depend on element IDs, as some ARIA attributes do.
+ * @returns {object} An object describing the view update.
+ */
+ getViewUpdate(result, idsByName) {
+ 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 },
+ cacheable: true,
+ excludeArgsFromCacheKey: true,
+ },
+ },
+ url: {
+ textContent: result.payload.url,
+ },
+ summaryText: {
+ 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,
+ },
+ },
+ };
+ }
+
+ onEngagement(isPrivate, state, queryContext, details) {
+ // Ignore engagements on other results that didn't end the session.
+ if (details.result?.providerName != this.name && details.isSessionOngoing) {
+ return;
+ }
+
+ // Impression and clicked telemetry are both recorded on engagement. We
+ // define "impression" to mean a weather result was present in the view when
+ // any result was picked.
+ if (state == "engagement" && queryContext) {
+ // Get the result that's visible in the view. `details.result` is the
+ // engaged result, if any; if it's from this provider, then that's the
+ // visible result. Otherwise fall back to #getVisibleResultFromLastQuery.
+ let { result } = details;
+ if (result?.providerName != this.name) {
+ result = this.#getVisibleResultFromLastQuery(queryContext.view);
+ }
+
+ if (result) {
+ this.#recordEngagementTelemetry(
+ result,
+ isPrivate,
+ details.result == result ? details.selType : ""
+ );
+ }
+ }
+
+ // Handle commands.
+ if (details.result?.providerName == this.name) {
+ this.#handlePossibleCommand(
+ queryContext,
+ details.result,
+ details.selType
+ );
+ }
+
+ this.#resultFromLastQuery = null;
+ }
+
+ #getVisibleResultFromLastQuery(view) {
+ let result = this.#resultFromLastQuery;
+
+ if (
+ result?.rowIndex >= 0 &&
+ view?.visibleResults?.[result.rowIndex] == result
+ ) {
+ // The result was visible.
+ return result;
+ }
+
+ // Find a visible result.
+ return view?.visibleResults?.find(r => r.providerName == this.name);
+ }
+
+ /**
+ * Records engagement telemetry. This should be called only at the end of an
+ * engagement when a weather result is present or when a weather result is
+ * dismissed.
+ *
+ * @param {UrlbarResult} result
+ * The weather result that was present (and possibly picked) at the end of
+ * the engagement or that was dismissed.
+ * @param {boolean} isPrivate
+ * Whether the engagement is in a private context.
+ * @param {string} selType
+ * This parameter indicates the part of the row the user picked, if any, and
+ * should be one of the following values:
+ *
+ * - "": The user didn't pick the row or any part of it
+ * - "weather": The user picked the main part of the row
+ * - "help": The user picked the help button
+ * - "dismiss": The user dismissed the result
+ *
+ * An empty string means the user picked some other row to end the
+ * engagement, not the weather row. In that case only impression telemetry
+ * will be recorded.
+ *
+ * A non-empty string means the user picked the weather row or some part of
+ * it, and both impression and click telemetry will be recorded. The
+ * non-empty-string values come from the `details.selType` passed in to
+ * `onEngagement()`; see `TelemetryEvent.typeFromElement()`.
+ */
+ #recordEngagementTelemetry(result, isPrivate, selType) {
+ // Indexes recorded in quick suggest telemetry are 1-based, so add 1 to the
+ // 0-based `result.rowIndex`.
+ let telemetryResultIndex = result.rowIndex + 1;
+
+ // impression scalars
+ Services.telemetry.keyedScalarAdd(
+ TELEMETRY_SCALARS.IMPRESSION,
+ telemetryResultIndex,
+ 1
+ );
+
+ // scalars related to clicking the result and other elements in its row
+ let clickScalars = [];
+ let eventObject;
+ switch (selType) {
+ case "weather":
+ clickScalars.push(TELEMETRY_SCALARS.CLICK);
+ eventObject = "click";
+ break;
+ case "help":
+ clickScalars.push(TELEMETRY_SCALARS.HELP);
+ eventObject = "help";
+ break;
+ case "dismiss":
+ clickScalars.push(TELEMETRY_SCALARS.BLOCK);
+ eventObject = "block";
+ break;
+ default:
+ if (selType) {
+ eventObject = "other";
+ }
+ break;
+ }
+ for (let scalar of clickScalars) {
+ Services.telemetry.keyedScalarAdd(scalar, telemetryResultIndex, 1);
+ }
+
+ // engagement event
+ Services.telemetry.recordEvent(
+ lazy.QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ "engagement",
+ eventObject || "impression_only",
+ "",
+ {
+ match_type: "firefox-suggest",
+ position: String(telemetryResultIndex),
+ suggestion_type: "weather",
+ source: result.payload.source,
+ }
+ );
+ }
+
+ #handlePossibleCommand(queryContext, result, selType) {
+ switch (selType) {
+ case RESULT_MENU_COMMAND.HELP:
+ // "help" 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);
+ queryContext.view.acknowledgeDismissal(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`.
+ queryContext.view.acknowledgeFeedback(result);
+ break;
+ case RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY:
+ queryContext.view.acknowledgeFeedback(result);
+ lazy.QuickSuggest.weather.incrementMinKeywordLength();
+ if (!lazy.QuickSuggest.weather.canIncrementMinKeywordLength) {
+ queryContext.view.invalidateResultMenuCommands();
+ }
+ break;
+ }
+ }
+
+ // The result we added during the most recent query.
+ #resultFromLastQuery = null;
+}
+
+export var UrlbarProviderWeather = new ProviderWeather();