summaryrefslogtreecommitdiffstats
path: root/browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs')
-rw-r--r--browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs1004
1 files changed, 1004 insertions, 0 deletions
diff --git a/browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs b/browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs
new file mode 100644
index 0000000000..c57678df69
--- /dev/null
+++ b/browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs
@@ -0,0 +1,1004 @@
+/* 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, {
+ CONTEXTUAL_SERVICES_PING_TYPES:
+ "resource:///modules/PartnerLinkAttribution.sys.mjs",
+ MerinoClient: "resource:///modules/MerinoClient.sys.mjs",
+ PartnerLinkAttribution: "resource:///modules/PartnerLinkAttribution.sys.mjs",
+ 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 MERINO_PROVIDER_WEATHER = "accuweather";
+const WEATHER_PROVIDER_DISPLAY_NAME = "AccuWeather";
+
+const TELEMETRY_PREFIX = "contextual.services.quicksuggest";
+
+const TELEMETRY_SCALARS = {
+ BLOCK_SPONSORED: `${TELEMETRY_PREFIX}.block_sponsored`,
+ BLOCK_SPONSORED_BEST_MATCH: `${TELEMETRY_PREFIX}.block_sponsored_bestmatch`,
+ BLOCK_DYNAMIC_WIKIPEDIA: `${TELEMETRY_PREFIX}.block_dynamic_wikipedia`,
+ BLOCK_NONSPONSORED: `${TELEMETRY_PREFIX}.block_nonsponsored`,
+ BLOCK_NONSPONSORED_BEST_MATCH: `${TELEMETRY_PREFIX}.block_nonsponsored_bestmatch`,
+ BLOCK_WEATHER: `${TELEMETRY_PREFIX}.block_weather`,
+ CLICK_SPONSORED: `${TELEMETRY_PREFIX}.click_sponsored`,
+ CLICK_NONSPONSORED: `${TELEMETRY_PREFIX}.click_nonsponsored`,
+ CLICK_NONSPONSORED_BEST_MATCH: `${TELEMETRY_PREFIX}.click_nonsponsored_bestmatch`,
+ CLICK_SPONSORED_BEST_MATCH: `${TELEMETRY_PREFIX}.click_sponsored_bestmatch`,
+ CLICK_DYNAMIC_WIKIPEDIA: `${TELEMETRY_PREFIX}.click_dynamic_wikipedia`,
+ CLICK_WEATHER: `${TELEMETRY_PREFIX}.click_weather`,
+ EXPOSURE_WEATHER: `${TELEMETRY_PREFIX}.exposure_weather`,
+ HELP_SPONSORED: `${TELEMETRY_PREFIX}.help_sponsored`,
+ HELP_NONSPONSORED: `${TELEMETRY_PREFIX}.help_nonsponsored`,
+ HELP_NONSPONSORED_BEST_MATCH: `${TELEMETRY_PREFIX}.help_nonsponsored_bestmatch`,
+ HELP_SPONSORED_BEST_MATCH: `${TELEMETRY_PREFIX}.help_sponsored_bestmatch`,
+ HELP_DYNAMIC_WIKIPEDIA: `${TELEMETRY_PREFIX}.help_dynamic_wikipedia`,
+ HELP_WEATHER: `${TELEMETRY_PREFIX}.help_weather`,
+ IMPRESSION_SPONSORED: `${TELEMETRY_PREFIX}.impression_sponsored`,
+ IMPRESSION_NONSPONSORED: `${TELEMETRY_PREFIX}.impression_nonsponsored`,
+ IMPRESSION_NONSPONSORED_BEST_MATCH: `${TELEMETRY_PREFIX}.impression_nonsponsored_bestmatch`,
+ IMPRESSION_SPONSORED_BEST_MATCH: `${TELEMETRY_PREFIX}.impression_sponsored_bestmatch`,
+ IMPRESSION_DYNAMIC_WIKIPEDIA: `${TELEMETRY_PREFIX}.impression_dynamic_wikipedia`,
+ IMPRESSION_WEATHER: `${TELEMETRY_PREFIX}.impression_weather`,
+};
+
+const WEATHER_DYNAMIC_TYPE = "weather";
+const WEATHER_VIEW_TEMPLATE = {
+ attributes: {
+ role: "group",
+ 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",
+ 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",
+ children: [
+ {
+ name: "summaryText",
+ tag: "span",
+ },
+ {
+ name: "summaryTextSeparator",
+ tag: "span",
+ },
+ ],
+ },
+ {
+ name: "highLow",
+ 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 ProviderQuickSuggest 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 "UrlbarProviderQuickSuggest";
+ }
+
+ /**
+ * 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 (
+ !lazy.UrlbarPrefs.get("quickSuggestEnabled") ||
+ queryContext.isPrivate ||
+ queryContext.searchMode
+ ) {
+ return false;
+ }
+
+ // Trim only the start of the search string because a trailing space can
+ // affect the suggestions.
+ this._searchString = queryContext.searchString.trimStart();
+
+ if (!this._searchString) {
+ return !!lazy.QuickSuggest.weather.suggestion;
+ }
+ return (
+ lazy.UrlbarPrefs.get("suggest.quicksuggest.nonsponsored") ||
+ lazy.UrlbarPrefs.get("suggest.quicksuggest.sponsored") ||
+ lazy.UrlbarPrefs.get("quicksuggest.dataCollection.enabled")
+ );
+ }
+
+ /**
+ * 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" } },
+ temperature: {
+ l10n: {
+ id: "firefox-suggest-weather-temperature",
+ args: {
+ value: result.payload.temperature,
+ unit: uppercaseUnit,
+ },
+ },
+ },
+ weatherIcon: {
+ attributes: { iconId: result.payload.iconId },
+ },
+ title: {
+ l10n: {
+ id: "firefox-suggest-weather-title",
+ args: { city: result.payload.city },
+ },
+ },
+ url: {
+ textContent: result.payload.url,
+ },
+ summaryText: {
+ l10n: {
+ id: "firefox-suggest-weather-summary-text",
+ args: {
+ currentConditions: result.payload.currentConditions,
+ forecast: result.payload.forecast,
+ },
+ },
+ },
+ highLow: {
+ 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 },
+ },
+ },
+ };
+ }
+
+ /**
+ * 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 instance = this.queryInstance;
+ let searchString = this._searchString;
+
+ if (!searchString) {
+ let result = this._makeWeatherResult();
+ if (result) {
+ addCallback(this, result);
+ this._resultFromLastQuery = result;
+ }
+ return;
+ }
+
+ // There are two sources for quick suggest: remote settings and Merino.
+ let promises = [];
+ if (lazy.UrlbarPrefs.get("quickSuggestRemoteSettingsEnabled")) {
+ promises.push(lazy.QuickSuggest.remoteSettings.fetch(searchString));
+ }
+ if (
+ lazy.UrlbarPrefs.get("merinoEnabled") &&
+ lazy.UrlbarPrefs.get("quicksuggest.dataCollection.enabled") &&
+ queryContext.allowRemoteResults()
+ ) {
+ promises.push(this._fetchMerinoSuggestions(queryContext, searchString));
+ }
+
+ // Wait for both sources to finish before adding a suggestion.
+ let allSuggestions = await Promise.all(promises);
+ if (instance != this.queryInstance) {
+ return;
+ }
+
+ // Filter suggestions, keeping in mind both the remote settings and Merino
+ // fetches return null when there are no matches. Take the remaining one
+ // with the largest score.
+ allSuggestions = await Promise.all(
+ allSuggestions
+ .flat()
+ .map(async s => (s && (await this._canAddSuggestion(s)) ? s : null))
+ );
+ if (instance != this.queryInstance) {
+ return;
+ }
+ const suggestion = allSuggestions
+ .filter(Boolean)
+ .sort((a, b) => b.score - a.score)[0];
+
+ if (!suggestion) {
+ return;
+ }
+
+ // Replace the suggestion's template substrings, but first save the original
+ // URL before its timestamp template is replaced.
+ let originalUrl = suggestion.url;
+ lazy.QuickSuggest.replaceSuggestionTemplates(suggestion);
+
+ let payload = {
+ originalUrl,
+ url: suggestion.url,
+ urlTimestampIndex: suggestion.urlTimestampIndex,
+ icon: suggestion.icon,
+ sponsoredImpressionUrl: suggestion.impression_url,
+ sponsoredClickUrl: suggestion.click_url,
+ sponsoredBlockId: suggestion.block_id,
+ sponsoredAdvertiser: suggestion.advertiser,
+ sponsoredIabCategory: suggestion.iab_category,
+ isSponsored: suggestion.is_sponsored,
+ helpUrl: lazy.QuickSuggest.HELP_URL,
+ helpL10n: {
+ id: lazy.UrlbarPrefs.get("resultMenu")
+ ? "urlbar-result-menu-learn-more-about-firefox-suggest"
+ : "firefox-suggest-urlbar-learn-more",
+ },
+ blockL10n: {
+ id: lazy.UrlbarPrefs.get("resultMenu")
+ ? "urlbar-result-menu-dismiss-firefox-suggest"
+ : "firefox-suggest-urlbar-block",
+ },
+ source: suggestion.source,
+ requestId: suggestion.request_id,
+ };
+
+ // Determine if the suggestion itself is a best match.
+ let isSuggestionBestMatch = false;
+ if (suggestion.is_top_pick) {
+ isSuggestionBestMatch = true;
+ } else if (lazy.QuickSuggest.remoteSettings.config.best_match) {
+ let { best_match } = lazy.QuickSuggest.remoteSettings.config;
+ isSuggestionBestMatch =
+ best_match.min_search_string_length <= searchString.length &&
+ !best_match.blocked_suggestion_ids.includes(suggestion.block_id);
+ }
+
+ // Determine if the urlbar result should be a best match.
+ let isResultBestMatch =
+ isSuggestionBestMatch &&
+ lazy.UrlbarPrefs.get("bestMatchEnabled") &&
+ lazy.UrlbarPrefs.get("suggest.bestmatch");
+ if (isResultBestMatch) {
+ // Show the result as a best match. Best match titles don't include the
+ // `full_keyword`, and the user's search string is highlighted.
+ payload.title = [suggestion.title, UrlbarUtils.HIGHLIGHT.TYPED];
+ payload.isBlockable = lazy.UrlbarPrefs.get("bestMatchBlockingEnabled");
+ } else {
+ // Show the result as a usual quick suggest. Include the `full_keyword`
+ // and highlight the parts that aren't in the search string.
+ payload.title = suggestion.title;
+ payload.isBlockable = lazy.UrlbarPrefs.get("quickSuggestBlockingEnabled");
+ payload.qsSuggestion = [
+ suggestion.full_keyword,
+ UrlbarUtils.HIGHLIGHT.SUGGESTED,
+ ];
+ }
+
+ let result = new lazy.UrlbarResult(
+ UrlbarUtils.RESULT_TYPE.URL,
+ UrlbarUtils.RESULT_SOURCE.SEARCH,
+ ...lazy.UrlbarResult.payloadAndSimpleHighlights(
+ queryContext.tokens,
+ payload
+ )
+ );
+
+ if (isResultBestMatch) {
+ result.isBestMatch = true;
+ result.suggestedIndex = 1;
+ } else if (
+ !isNaN(suggestion.position) &&
+ lazy.UrlbarPrefs.get("quickSuggestAllowPositionInSuggestions")
+ ) {
+ result.suggestedIndex = suggestion.position;
+ } else {
+ result.isSuggestedIndexRelativeToGroup = true;
+ result.suggestedIndex = lazy.UrlbarPrefs.get(
+ suggestion.is_sponsored
+ ? "quickSuggestSponsoredIndex"
+ : "quickSuggestNonSponsoredIndex"
+ );
+ }
+
+ addCallback(this, result);
+
+ this._resultFromLastQuery = result;
+
+ // The user triggered a suggestion. Depending on the experiment the user is
+ // enrolled in (if any), we may need to record the Nimbus exposure event.
+ //
+ // If the user is in a best match experiment:
+ // Record if the suggestion is itself a best match and either of the
+ // following are true:
+ // * The best match feature is enabled (i.e., the user is in a treatment
+ // branch), and the user has not disabled best match
+ // * The best match feature is disabled (i.e., the user is in the control
+ // branch)
+ // Else if the user is not in a modal experiment:
+ // Record the event
+ if (
+ lazy.UrlbarPrefs.get("isBestMatchExperiment") ||
+ lazy.UrlbarPrefs.get("experimentType") === "best-match"
+ ) {
+ if (
+ isSuggestionBestMatch &&
+ (!lazy.UrlbarPrefs.get("bestMatchEnabled") ||
+ lazy.UrlbarPrefs.get("suggest.bestmatch"))
+ ) {
+ lazy.QuickSuggest.ensureExposureEventRecorded();
+ }
+ } else if (lazy.UrlbarPrefs.get("experimentType") !== "modal") {
+ lazy.QuickSuggest.ensureExposureEventRecorded();
+ }
+ }
+
+ /**
+ * Called when the result's block button is picked. If the provider can block
+ * the result, it should do so and return true. If the provider cannot block
+ * the result, it should return false. The meaning of "blocked" depends on the
+ * provider and the type of result.
+ *
+ * @param {UrlbarQueryContext} queryContext
+ * The query context.
+ * @param {UrlbarResult} result
+ * The result that should be blocked.
+ * @returns {boolean}
+ * Whether the result was blocked.
+ */
+ blockResult(queryContext, result) {
+ if (result.payload.merinoProvider == MERINO_PROVIDER_WEATHER) {
+ this.logger.info("Blocking weather result");
+ lazy.UrlbarPrefs.set("suggest.weather", false);
+ this._recordEngagementTelemetry(result, queryContext.isPrivate, "block");
+ return true;
+ }
+
+ if (
+ (!result.isBestMatch &&
+ !lazy.UrlbarPrefs.get("quickSuggestBlockingEnabled")) ||
+ (result.isBestMatch && !lazy.UrlbarPrefs.get("bestMatchBlockingEnabled"))
+ ) {
+ this.logger.info("Blocking disabled, ignoring block");
+ return false;
+ }
+
+ this.logger.info("Blocking result: " + JSON.stringify(result));
+ lazy.QuickSuggest.blockedSuggestions.add(result.payload.originalUrl);
+ this._recordEngagementTelemetry(result, queryContext.isPrivate, "block");
+ return true;
+ }
+
+ onResultsShown(queryContext, results) {
+ let weatherResult = results.find(
+ r => r.payload.merinoProvider == MERINO_PROVIDER_WEATHER
+ );
+ if (weatherResult) {
+ // Telemetry indexes are 1-based.
+ let telemetryResultIndex = weatherResult.rowIndex + 1;
+ Services.telemetry.keyedScalarAdd(
+ TELEMETRY_SCALARS.EXPOSURE_WEATHER,
+ telemetryResultIndex,
+ 1
+ );
+ }
+ }
+
+ /**
+ * Called when the user starts and ends an engagement with the urlbar. For
+ * details on parameters, see UrlbarProvider.onEngagement().
+ *
+ * @param {boolean} isPrivate
+ * True if the engagement is in a private context.
+ * @param {string} state
+ * The state of the engagement, one of: start, engagement, abandonment,
+ * discard
+ * @param {UrlbarQueryContext} queryContext
+ * The engagement's query context. This is *not* guaranteed to be defined
+ * when `state` is "start". It will always be defined for "engagement" and
+ * "abandonment".
+ * @param {object} details
+ * This is defined only when `state` is "engagement" or "abandonment", and
+ * it describes the search string and picked result.
+ */
+ onEngagement(isPrivate, state, queryContext, details) {
+ let result = this._resultFromLastQuery;
+ this._resultFromLastQuery = null;
+
+ // Reset the Merino session ID when an engagement ends. Per spec, for the
+ // user's privacy, we don't keep it around between engagements. It wouldn't
+ // hurt to do this on start too, it's just not necessary if we always do it
+ // on end.
+ if (state != "start") {
+ this._merino?.resetSession();
+ }
+
+ // Impression and clicked telemetry are both recorded on engagement. We
+ // define "impression" to mean a quick suggest result was present in the
+ // view when any result was picked.
+ if (state == "engagement") {
+ // Find the quick suggest result that's currently visible in the view.
+ // It's probably the result from the last query so check it first, but due
+ // to the async nature of how results are added to the view and made
+ // visible, it may not be.
+ if (
+ result &&
+ (result.rowIndex < 0 ||
+ queryContext.view?.visibleResults?.[result.rowIndex] != result)
+ ) {
+ // The result from the last query isn't visible.
+ result = null;
+ }
+
+ // If the result isn't visible, find a visible one. Quick suggest results
+ // typically appear last in the view, so do a reverse search.
+ if (!result) {
+ result = queryContext.view?.visibleResults?.findLast(
+ r => r.providerName == this.name
+ );
+ }
+
+ // Finally, record telemetry if there's a visible result.
+ if (result) {
+ this._recordEngagementTelemetry(
+ result,
+ isPrivate,
+ details.selIndex == result.rowIndex ? details.selType : ""
+ );
+ }
+ }
+ }
+
+ /**
+ * Records engagement telemetry. This should be called only at the end of an
+ * engagement when a quick suggest result is present or when a quick suggest
+ * result is blocked.
+ *
+ * @param {UrlbarResult} result
+ * The quick suggest result that was present (and possibly picked) at the
+ * end of the engagement or that was blocked.
+ * @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
+ * - "quicksuggest": The user picked the main part of the row
+ * - "help": The user picked the help button
+ * - "block": The user picked the block button or used the key shortcut
+ *
+ * An empty string means the user picked some other row to end the
+ * engagement, not the quick suggest row. In that case only impression
+ * telemetry will be recorded.
+ *
+ * A non-empty string means the user picked the quick suggest 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) {
+ // Update impression stats.
+ lazy.QuickSuggest.impressionCaps.updateStats(
+ result.payload.isSponsored ? "sponsored" : "nonsponsored"
+ );
+
+ // Indexes recorded in quick suggest telemetry are 1-based, so add 1 to the
+ // 0-based `result.rowIndex`.
+ let telemetryResultIndex = result.rowIndex + 1;
+ let isDynamicWikipedia =
+ result.payload.sponsoredAdvertiser == "dynamic-wikipedia";
+ let isWeather = result.payload.merinoProvider == MERINO_PROVIDER_WEATHER;
+
+ // impression scalars
+ Services.telemetry.keyedScalarAdd(
+ result.payload.isSponsored
+ ? TELEMETRY_SCALARS.IMPRESSION_SPONSORED
+ : TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED,
+ telemetryResultIndex,
+ 1
+ );
+
+ if (isDynamicWikipedia) {
+ Services.telemetry.keyedScalarAdd(
+ TELEMETRY_SCALARS.IMPRESSION_DYNAMIC_WIKIPEDIA,
+ telemetryResultIndex,
+ 1
+ );
+ }
+ if (isWeather) {
+ Services.telemetry.keyedScalarAdd(
+ TELEMETRY_SCALARS.IMPRESSION_WEATHER,
+ telemetryResultIndex,
+ 1
+ );
+ }
+ if (result.isBestMatch) {
+ Services.telemetry.keyedScalarAdd(
+ result.payload.isSponsored
+ ? TELEMETRY_SCALARS.IMPRESSION_SPONSORED_BEST_MATCH
+ : TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED_BEST_MATCH,
+ telemetryResultIndex,
+ 1
+ );
+ }
+
+ // scalars related to clicking the result and other elements in its row
+ let clickScalars = [];
+ switch (selType) {
+ case "quicksuggest":
+ clickScalars.push(
+ result.payload.isSponsored
+ ? TELEMETRY_SCALARS.CLICK_SPONSORED
+ : TELEMETRY_SCALARS.CLICK_NONSPONSORED
+ );
+ if (isDynamicWikipedia) {
+ clickScalars.push(TELEMETRY_SCALARS.CLICK_DYNAMIC_WIKIPEDIA);
+ }
+ if (isWeather) {
+ clickScalars.push(TELEMETRY_SCALARS.CLICK_WEATHER);
+ }
+ if (result.isBestMatch) {
+ clickScalars.push(
+ result.payload.isSponsored
+ ? TELEMETRY_SCALARS.CLICK_SPONSORED_BEST_MATCH
+ : TELEMETRY_SCALARS.CLICK_NONSPONSORED_BEST_MATCH
+ );
+ }
+ break;
+ case "help":
+ clickScalars.push(
+ result.payload.isSponsored
+ ? TELEMETRY_SCALARS.HELP_SPONSORED
+ : TELEMETRY_SCALARS.HELP_NONSPONSORED
+ );
+ if (isDynamicWikipedia) {
+ clickScalars.push(TELEMETRY_SCALARS.HELP_DYNAMIC_WIKIPEDIA);
+ }
+ if (isWeather) {
+ clickScalars.push(TELEMETRY_SCALARS.HELP_WEATHER);
+ }
+ if (result.isBestMatch) {
+ clickScalars.push(
+ result.payload.isSponsored
+ ? TELEMETRY_SCALARS.HELP_SPONSORED_BEST_MATCH
+ : TELEMETRY_SCALARS.HELP_NONSPONSORED_BEST_MATCH
+ );
+ }
+ break;
+ case "block":
+ clickScalars.push(
+ result.payload.isSponsored
+ ? TELEMETRY_SCALARS.BLOCK_SPONSORED
+ : TELEMETRY_SCALARS.BLOCK_NONSPONSORED
+ );
+ if (isDynamicWikipedia) {
+ clickScalars.push(TELEMETRY_SCALARS.BLOCK_DYNAMIC_WIKIPEDIA);
+ }
+ if (isWeather) {
+ clickScalars.push(TELEMETRY_SCALARS.BLOCK_WEATHER);
+ }
+ if (result.isBestMatch) {
+ clickScalars.push(
+ result.payload.isSponsored
+ ? TELEMETRY_SCALARS.BLOCK_SPONSORED_BEST_MATCH
+ : TELEMETRY_SCALARS.BLOCK_NONSPONSORED_BEST_MATCH
+ );
+ }
+ break;
+ default:
+ if (selType) {
+ this.logger.error(
+ "Engagement telemetry error, unknown selType: " + selType
+ );
+ }
+ break;
+ }
+ for (let scalar of clickScalars) {
+ Services.telemetry.keyedScalarAdd(scalar, telemetryResultIndex, 1);
+ }
+
+ // engagement event
+ let match_type = result.isBestMatch ? "best-match" : "firefox-suggest";
+ let suggestion_type;
+ if (isDynamicWikipedia) {
+ suggestion_type = "dynamic-wikipedia";
+ } else if (isWeather) {
+ suggestion_type = "weather";
+ } else {
+ suggestion_type = result.payload.isSponsored
+ ? "sponsored"
+ : "nonsponsored";
+ }
+ Services.telemetry.recordEvent(
+ lazy.QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ "engagement",
+ selType == "quicksuggest" ? "click" : selType || "impression_only",
+ "",
+ {
+ match_type,
+ position: String(telemetryResultIndex),
+ suggestion_type,
+ source: result.payload.source,
+ }
+ );
+
+ // custom engagement pings
+ if (!isPrivate) {
+ this._sendEngagementPings({
+ selType,
+ match_type,
+ result,
+ telemetryResultIndex,
+ });
+ }
+ }
+
+ _sendEngagementPings({ selType, match_type, result, telemetryResultIndex }) {
+ // Custom engagement pings are sent only for the main sponsored and non-
+ // sponsored suggestions with an advertiser in their payload, not for other
+ // types of suggestions like navigational suggestions, weather, etc.
+ if (!result.payload.sponsoredAdvertiser) {
+ return;
+ }
+
+ // `is_clicked` is whether the user clicked the suggestion. `selType` will
+ // be "quicksuggest" in that case. See this method's JSDoc for all
+ // possible `selType` values.
+ let is_clicked = selType == "quicksuggest";
+ let payload = {
+ match_type,
+ // Always use lowercase to make the reporting consistent
+ advertiser: result.payload.sponsoredAdvertiser.toLocaleLowerCase(),
+ block_id: result.payload.sponsoredBlockId,
+ improve_suggest_experience_checked: lazy.UrlbarPrefs.get(
+ "quicksuggest.dataCollection.enabled"
+ ),
+ position: telemetryResultIndex,
+ request_id: result.payload.requestId,
+ source: result.payload.source,
+ };
+
+ // impression
+ lazy.PartnerLinkAttribution.sendContextualServicesPing(
+ {
+ ...payload,
+ is_clicked,
+ reporting_url: result.payload.sponsoredImpressionUrl,
+ },
+ lazy.CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION
+ );
+
+ // click
+ if (is_clicked) {
+ lazy.PartnerLinkAttribution.sendContextualServicesPing(
+ {
+ ...payload,
+ reporting_url: result.payload.sponsoredClickUrl,
+ },
+ lazy.CONTEXTUAL_SERVICES_PING_TYPES.QS_SELECTION
+ );
+ }
+
+ // block
+ if (selType == "block") {
+ lazy.PartnerLinkAttribution.sendContextualServicesPing(
+ {
+ ...payload,
+ iab_category: result.payload.sponsoredIabCategory,
+ },
+ lazy.CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK
+ );
+ }
+ }
+
+ /**
+ * Cancels the current query.
+ *
+ * @param {UrlbarQueryContext} queryContext
+ * The query context.
+ */
+ cancelQuery(queryContext) {
+ // Cancel the Merino timeout timer so it doesn't fire and record a timeout.
+ // If it's already canceled or has fired, this is a no-op.
+ this._merino?.cancelTimeoutTimer();
+
+ // Don't abort the Merino fetch if one is ongoing. By design we allow
+ // fetches to finish so we can record their latency.
+ }
+
+ /**
+ * Fetches Merino suggestions.
+ *
+ * @param {UrlbarQueryContext} queryContext
+ * The query context.
+ * @param {string} searchString
+ * The search string.
+ * @returns {Array}
+ * The Merino suggestions or null if there's an error or unexpected
+ * response.
+ */
+ async _fetchMerinoSuggestions(queryContext, searchString) {
+ if (!this._merino) {
+ this._merino = new lazy.MerinoClient(this.name);
+ }
+
+ let providers;
+ if (
+ !lazy.UrlbarPrefs.get("suggest.quicksuggest.nonsponsored") &&
+ !lazy.UrlbarPrefs.get("suggest.quicksuggest.sponsored") &&
+ !lazy.UrlbarPrefs.get("merinoProviders")
+ ) {
+ // Data collection is enabled but suggestions are not. Use an empty list
+ // of providers to tell Merino not to fetch any suggestions.
+ providers = [];
+ }
+
+ let suggestions = await this._merino.fetch({
+ providers,
+ query: searchString,
+ });
+
+ return suggestions;
+ }
+
+ /**
+ * Returns a UrlbarResult for the current prefetched weather suggestion if
+ * there is one.
+ *
+ * @returns {UrlbarResult}
+ * A result or null if there's no weather suggestion.
+ */
+ _makeWeatherResult() {
+ let { suggestion } = lazy.QuickSuggest.weather;
+ if (!suggestion) {
+ return null;
+ }
+
+ 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,
+ 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],
+ isWeather: true,
+ shouldNavigate: true,
+ }
+ );
+
+ result.suggestedIndex = 0;
+ return result;
+ }
+
+ /**
+ * Returns whether a given suggestion can be added for a query, assuming the
+ * provider itself should be active.
+ *
+ * @param {object} suggestion
+ * The suggestion to check.
+ * @returns {boolean}
+ * Whether the suggestion can be added.
+ */
+ async _canAddSuggestion(suggestion) {
+ this.logger.info("Checking if suggestion can be added");
+ this.logger.debug(JSON.stringify({ suggestion }));
+
+ // Return false if suggestions are disabled.
+ if (
+ (suggestion.is_sponsored &&
+ !lazy.UrlbarPrefs.get("suggest.quicksuggest.sponsored")) ||
+ (!suggestion.is_sponsored &&
+ !lazy.UrlbarPrefs.get("suggest.quicksuggest.nonsponsored"))
+ ) {
+ this.logger.info("Suggestions disabled, not adding suggestion");
+ return false;
+ }
+
+ // Return false if an impression cap has been hit.
+ if (
+ (suggestion.is_sponsored &&
+ lazy.UrlbarPrefs.get("quickSuggestImpressionCapsSponsoredEnabled")) ||
+ (!suggestion.is_sponsored &&
+ lazy.UrlbarPrefs.get("quickSuggestImpressionCapsNonSponsoredEnabled"))
+ ) {
+ let type = suggestion.is_sponsored ? "sponsored" : "nonsponsored";
+ let hitStats = lazy.QuickSuggest.impressionCaps.getHitStats(type);
+ if (hitStats) {
+ this.logger.info("Impression cap(s) hit, not adding suggestion");
+ this.logger.debug(JSON.stringify({ type, hitStats }));
+ return false;
+ }
+ }
+
+ // Return false if the suggestion is blocked.
+ if (await lazy.QuickSuggest.blockedSuggestions.has(suggestion.url)) {
+ this.logger.info("Suggestion blocked, not adding suggestion");
+ return false;
+ }
+
+ this.logger.info("Suggestion can be added");
+ return true;
+ }
+
+ // The result we added during the most recent query.
+ _resultFromLastQuery = null;
+
+ // The Merino client.
+ _merino = null;
+}
+
+export var UrlbarProviderQuickSuggest = new ProviderQuickSuggest();