/* 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();