diff options
Diffstat (limited to '')
-rw-r--r-- | browser/components/urlbar/UrlbarProviderWeather.sys.mjs | 590 |
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(); |