diff options
Diffstat (limited to 'browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs')
-rw-r--r-- | browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs | 869 |
1 files changed, 869 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..2ba3a17d98 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs @@ -0,0 +1,869 @@ +/* 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", + QuickSuggestRemoteSettings: + "resource:///modules/urlbar/private/QuickSuggestRemoteSettings.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", +}); + +const TELEMETRY_PREFIX = "contextual.services.quicksuggest"; + +const TELEMETRY_SCALARS = { + BLOCK_DYNAMIC_WIKIPEDIA: `${TELEMETRY_PREFIX}.block_dynamic_wikipedia`, + BLOCK_NONSPONSORED: `${TELEMETRY_PREFIX}.block_nonsponsored`, + BLOCK_NONSPONSORED_BEST_MATCH: `${TELEMETRY_PREFIX}.block_nonsponsored_bestmatch`, + BLOCK_SPONSORED: `${TELEMETRY_PREFIX}.block_sponsored`, + BLOCK_SPONSORED_BEST_MATCH: `${TELEMETRY_PREFIX}.block_sponsored_bestmatch`, + CLICK_DYNAMIC_WIKIPEDIA: `${TELEMETRY_PREFIX}.click_dynamic_wikipedia`, + CLICK_NAV_NOTMATCHED: `${TELEMETRY_PREFIX}.click_nav_notmatched`, + CLICK_NAV_SHOWN_HEURISTIC: `${TELEMETRY_PREFIX}.click_nav_shown_heuristic`, + CLICK_NAV_SHOWN_NAV: `${TELEMETRY_PREFIX}.click_nav_shown_nav`, + CLICK_NAV_SUPERCEDED: `${TELEMETRY_PREFIX}.click_nav_superceded`, + CLICK_NONSPONSORED: `${TELEMETRY_PREFIX}.click_nonsponsored`, + CLICK_NONSPONSORED_BEST_MATCH: `${TELEMETRY_PREFIX}.click_nonsponsored_bestmatch`, + CLICK_SPONSORED: `${TELEMETRY_PREFIX}.click_sponsored`, + CLICK_SPONSORED_BEST_MATCH: `${TELEMETRY_PREFIX}.click_sponsored_bestmatch`, + HELP_DYNAMIC_WIKIPEDIA: `${TELEMETRY_PREFIX}.help_dynamic_wikipedia`, + HELP_NONSPONSORED: `${TELEMETRY_PREFIX}.help_nonsponsored`, + HELP_NONSPONSORED_BEST_MATCH: `${TELEMETRY_PREFIX}.help_nonsponsored_bestmatch`, + HELP_SPONSORED: `${TELEMETRY_PREFIX}.help_sponsored`, + HELP_SPONSORED_BEST_MATCH: `${TELEMETRY_PREFIX}.help_sponsored_bestmatch`, + IMPRESSION_DYNAMIC_WIKIPEDIA: `${TELEMETRY_PREFIX}.impression_dynamic_wikipedia`, + IMPRESSION_NAV_NOTMATCHED: `${TELEMETRY_PREFIX}.impression_nav_notmatched`, + IMPRESSION_NAV_SHOWN: `${TELEMETRY_PREFIX}.impression_nav_shown`, + IMPRESSION_NAV_SUPERCEDED: `${TELEMETRY_PREFIX}.impression_nav_superceded`, + IMPRESSION_NONSPONSORED: `${TELEMETRY_PREFIX}.impression_nonsponsored`, + IMPRESSION_NONSPONSORED_BEST_MATCH: `${TELEMETRY_PREFIX}.impression_nonsponsored_bestmatch`, + IMPRESSION_SPONSORED: `${TELEMETRY_PREFIX}.impression_sponsored`, + IMPRESSION_SPONSORED_BEST_MATCH: `${TELEMETRY_PREFIX}.impression_sponsored_bestmatch`, +}; + +/** + * 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 { + /** + * 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 }; + } + + /** + * 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. + let trimmedSearchString = queryContext.searchString.trimStart(); + if (!trimmedSearchString) { + return false; + } + this._trimmedSearchString = trimmedSearchString; + + return true; + } + + /** + * 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._trimmedSearchString; + + // There are two sources for quick suggest: remote settings and Merino. + let promises = []; + if (lazy.UrlbarPrefs.get("quickSuggestRemoteSettingsEnabled")) { + promises.push(lazy.QuickSuggestRemoteSettings.query(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 values = await Promise.all(promises); + if (instance != this.queryInstance) { + return; + } + + let suggestions = values.flat().sort((a, b) => b.score - a.score); + + // Add a result for the first suggestion that can be shown. + for (let suggestion of suggestions) { + let canAdd = await this._canAddSuggestion(suggestion); + if (instance != this.queryInstance) { + return; + } + + let result; + if ( + canAdd && + (result = await this.#makeResult(queryContext, suggestion)) + ) { + this.#resultFromLastQuery = result; + addCallback(this, result); + return; + } + } + } + + /** + * 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) { + // Ignore engagements on other results that didn't end the session. + if (details.result?.providerName != this.name && details.isSessionOngoing) { + return; + } + + // Reset the Merino session ID when a session ends. By design for the user's + // privacy, we don't keep it around between engagements. + if (state != "start" && !details.isSessionOngoing) { + 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" && 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); + } + + this.#recordEngagement(queryContext, isPrivate, result, details); + } + + if (details.result?.providerName == this.name) { + if (details.result.payload.dynamicType === "addons") { + lazy.QuickSuggest.getFeature("AddonSuggestions").handlePossibleCommand( + queryContext, + details.result, + details.selType + ); + } else if (details.selType == "dismiss") { + // Handle dismissals. + this.#dismissResult(queryContext, details.result); + } + } + + this.#resultFromLastQuery = null; + } + + /** + * 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. + * @returns {object} An object describing the view update. + */ + getViewUpdate(result) { + // For now, we support only addons suggestion. + return lazy.QuickSuggest.getFeature("AddonSuggestions").getViewUpdate( + result + ); + } + + getResultCommands(result) { + if (result.payload.dynamicType === "addons") { + return lazy.QuickSuggest.getFeature("AddonSuggestions").getResultCommands( + result + ); + } + + return null; + } + + async #makeResult(queryContext, suggestion) { + let result; + switch (suggestion.provider) { + case "amo": + case "AddonSuggestions": + result = await lazy.QuickSuggest.getFeature( + "AddonSuggestions" + ).makeResult(queryContext, suggestion, this._trimmedSearchString); + break; + case "adm": // Merino + case "AdmWikipedia": // remote settings + result = await lazy.QuickSuggest.getFeature("AdmWikipedia").makeResult( + queryContext, + suggestion, + this._trimmedSearchString + ); + break; + default: + result = this.#makeDefaultResult(queryContext, suggestion); + break; + } + + if (!result) { + // Feature might return null, if the feature is disabled and so on. + return null; + } + + if (!result.hasSuggestedIndex) { + // When `bestMatchEnabled` is true, a "Top pick" checkbox appears in + // about:preferences. Show top pick suggestions as top picks only if that + // checkbox is checked. `suggest.bestMatch` is the corresponding pref. If + // `bestMatchEnabled` is false, the top pick feature is disabled, so show + // the suggestion as a usual Suggest result. + if ( + suggestion.is_top_pick && + lazy.UrlbarPrefs.get("bestMatchEnabled") && + lazy.UrlbarPrefs.get("suggest.bestmatch") + ) { + 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" + ); + } + } + + return result; + } + + #makeDefaultResult(queryContext, suggestion) { + let payload = { + url: suggestion.url, + icon: suggestion.icon, + isSponsored: suggestion.is_sponsored, + source: suggestion.source, + telemetryType: suggestion.provider, + helpUrl: lazy.QuickSuggest.HELP_URL, + helpL10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + isBlockable: true, + blockL10n: { + id: "urlbar-result-menu-dismiss-firefox-suggest", + }, + }; + + if (suggestion.full_keyword) { + payload.title = suggestion.title; + payload.qsSuggestion = [ + suggestion.full_keyword, + UrlbarUtils.HIGHLIGHT.SUGGESTED, + ]; + } else { + payload.title = [suggestion.title, UrlbarUtils.HIGHLIGHT.TYPED]; + } + + return new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.SEARCH, + ...lazy.UrlbarResult.payloadAndSimpleHighlights( + queryContext.tokens, + payload + ) + ); + } + + #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. Quick suggest results typically appear last in the + // view, so do a reverse search. + return view?.visibleResults?.findLast(r => r.providerName == this.name); + } + + #dismissResult(queryContext, result) { + if (!result.payload.isBlockable) { + this.logger.info("Dismissals disabled, ignoring dismissal"); + return; + } + + this.logger.info("Dismissing result: " + JSON.stringify(result)); + lazy.QuickSuggest.blockedSuggestions.add( + // adM results have `originalUrl`, which contains timestamp templates. + result.payload.originalUrl ?? result.payload.url + ); + queryContext.view.controller.removeResult(result); + } + + /** + * 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 dismissed. + * + * @param {UrlbarQueryContext} queryContext + * The query context. + * @param {boolean} isPrivate + * Whether the engagement is in a private context. + * @param {UrlbarResult} result + * The quick suggest result that was present (and possibly picked) at the + * end of the engagement or that was dismissed. Null if no quick suggest + * result was present. + * @param {object} details + * The `details` object that was passed to `onEngagement()`. It must look + * like this: `{ selType, selIndex }` + */ + #recordEngagement(queryContext, isPrivate, result, details) { + let resultSelType = ""; + let resultClicked = false; + if (result && details.result == result) { + resultSelType = details.selType; + resultClicked = + details.element?.tagName != "menuitem" && + !details.element?.classList.contains("urlbarView-button") && + details.selType != "dismiss"; + } + + if (result) { + // Update impression stats. + lazy.QuickSuggest.impressionCaps.updateStats( + result.payload.isSponsored ? "sponsored" : "nonsponsored" + ); + + // Record engagement scalars, event, and pings. + this.#recordEngagementScalars({ result, resultSelType, resultClicked }); + this.#recordEngagementEvent({ result, resultSelType, resultClicked }); + if (!isPrivate) { + this.#recordEngagementPings({ result, resultSelType, resultClicked }); + } + } + + // Navigational suggestions telemetry requires special handling and does not + // depend on a result being visible. + if ( + lazy.UrlbarPrefs.get("recordNavigationalSuggestionTelemetry") && + queryContext.heuristicResult + ) { + this.#recordNavSuggestionTelemetry({ + queryContext, + result, + resultSelType, + resultClicked, + details, + }); + } + } + + /** + * Helper for engagement telemetry that records engagement scalars. + * + * @param {object} options + * Options object + * @param {UrlbarResult} options.result + * The quick suggest result related to the engagement. Must not be null. + * @param {string} options.resultSelType + * If an element in the result's row was clicked, this should be its + * `selType`. Otherwise it should be an empty string. + * @param {boolean} options.resultClicked + * True if the main part of the result's row was clicked; false if a button + * like help or dismiss was clicked or if no part of the row was clicked. + */ + #recordEngagementScalars({ result, resultSelType, resultClicked }) { + // Navigational suggestion scalars are handled separately. + if (result.payload.telemetryType == "top_picks") { + return; + } + + // Indexes recorded in quick suggest telemetry are 1-based, so add 1 to the + // 0-based `result.rowIndex`. + let telemetryResultIndex = result.rowIndex + 1; + + let scalars = []; + switch (result.payload.telemetryType) { + case "adm_nonsponsored": + scalars.push(TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED); + if (resultClicked) { + scalars.push(TELEMETRY_SCALARS.CLICK_NONSPONSORED); + } else { + switch (resultSelType) { + case "help": + scalars.push(TELEMETRY_SCALARS.HELP_NONSPONSORED); + break; + case "dismiss": + scalars.push(TELEMETRY_SCALARS.BLOCK_NONSPONSORED); + break; + } + } + if (result.isBestMatch) { + scalars.push(TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED_BEST_MATCH); + if (resultClicked) { + scalars.push(TELEMETRY_SCALARS.CLICK_NONSPONSORED_BEST_MATCH); + } else { + switch (resultSelType) { + case "help": + scalars.push(TELEMETRY_SCALARS.HELP_NONSPONSORED_BEST_MATCH); + break; + case "dismiss": + scalars.push(TELEMETRY_SCALARS.BLOCK_NONSPONSORED_BEST_MATCH); + break; + } + } + } + break; + case "adm_sponsored": + scalars.push(TELEMETRY_SCALARS.IMPRESSION_SPONSORED); + if (resultClicked) { + scalars.push(TELEMETRY_SCALARS.CLICK_SPONSORED); + } else { + switch (resultSelType) { + case "help": + scalars.push(TELEMETRY_SCALARS.HELP_SPONSORED); + break; + case "dismiss": + scalars.push(TELEMETRY_SCALARS.BLOCK_SPONSORED); + break; + } + } + if (result.isBestMatch) { + scalars.push(TELEMETRY_SCALARS.IMPRESSION_SPONSORED_BEST_MATCH); + if (resultClicked) { + scalars.push(TELEMETRY_SCALARS.CLICK_SPONSORED_BEST_MATCH); + } else { + switch (resultSelType) { + case "help": + scalars.push(TELEMETRY_SCALARS.HELP_SPONSORED_BEST_MATCH); + break; + case "dismiss": + scalars.push(TELEMETRY_SCALARS.BLOCK_SPONSORED_BEST_MATCH); + break; + } + } + } + break; + case "wikipedia": + scalars.push(TELEMETRY_SCALARS.IMPRESSION_DYNAMIC_WIKIPEDIA); + if (resultClicked) { + scalars.push(TELEMETRY_SCALARS.CLICK_DYNAMIC_WIKIPEDIA); + } else { + switch (resultSelType) { + case "help": + scalars.push(TELEMETRY_SCALARS.HELP_DYNAMIC_WIKIPEDIA); + break; + case "dismiss": + scalars.push(TELEMETRY_SCALARS.BLOCK_DYNAMIC_WIKIPEDIA); + break; + } + } + break; + } + + for (let scalar of scalars) { + Services.telemetry.keyedScalarAdd(scalar, telemetryResultIndex, 1); + } + } + + /** + * Helper for engagement telemetry that records the legacy engagement event. + * + * @param {object} options + * Options object + * @param {UrlbarResult} options.result + * The quick suggest result related to the engagement. Must not be null. + * @param {string} options.resultSelType + * If an element in the result's row was clicked, this should be its + * `selType`. Otherwise it should be an empty string. + * @param {boolean} options.resultClicked + * True if the main part of the result's row was clicked; false if a button + * like help or dismiss was clicked or if no part of the row was clicked. + */ + #recordEngagementEvent({ result, resultSelType, resultClicked }) { + let eventType; + if (resultClicked) { + eventType = "click"; + } else if (!resultSelType) { + eventType = "impression_only"; + } else { + switch (resultSelType) { + case "dismiss": + eventType = "block"; + break; + case "help": + eventType = "help"; + break; + default: + eventType = "other"; + break; + } + } + + let suggestion_type; + switch (result.payload.telemetryType) { + case "adm_nonsponsored": + suggestion_type = "nonsponsored"; + break; + case "adm_sponsored": + suggestion_type = "sponsored"; + break; + case "top_picks": + suggestion_type = "navigational"; + break; + case "wikipedia": + suggestion_type = "dynamic-wikipedia"; + break; + default: + suggestion_type = result.payload.telemetryType; + break; + } + + Services.telemetry.recordEvent( + lazy.QuickSuggest.TELEMETRY_EVENT_CATEGORY, + "engagement", + eventType, + "", + { + suggestion_type, + match_type: result.isBestMatch ? "best-match" : "firefox-suggest", + // Quick suggest telemetry indexes are 1-based but `rowIndex` is 0-based + position: String(result.rowIndex + 1), + source: result.payload.source, + } + ); + } + + /** + * Helper for engagement telemetry that records custom contextual services + * pings. + * + * @param {object} options + * Options object + * @param {UrlbarResult} options.result + * The quick suggest result related to the engagement. Must not be null. + * @param {string} options.resultSelType + * If an element in the result's row was clicked, this should be its + * `selType`. Otherwise it should be an empty string. + * @param {boolean} options.resultClicked + * True if the main part of the result's row was clicked; false if a button + * like help or dismiss was clicked or if no part of the row was clicked. + */ + #recordEngagementPings({ result, resultSelType, resultClicked }) { + if ( + result.payload.telemetryType != "adm_sponsored" && + result.payload.telemetryType != "adm_nonsponsored" + ) { + return; + } + + let payload = { + match_type: result.isBestMatch ? "best-match" : "firefox-suggest", + // 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" + ), + // Quick suggest telemetry indexes are 1-based but `rowIndex` is 0-based + position: result.rowIndex + 1, + request_id: result.payload.requestId, + source: result.payload.source, + }; + + // impression + lazy.PartnerLinkAttribution.sendContextualServicesPing( + { + ...payload, + is_clicked: resultClicked, + reporting_url: result.payload.sponsoredImpressionUrl, + }, + lazy.CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION + ); + + // click + if (resultClicked) { + lazy.PartnerLinkAttribution.sendContextualServicesPing( + { + ...payload, + reporting_url: result.payload.sponsoredClickUrl, + }, + lazy.CONTEXTUAL_SERVICES_PING_TYPES.QS_SELECTION + ); + } + + // dismiss + if (resultSelType == "dismiss") { + lazy.PartnerLinkAttribution.sendContextualServicesPing( + { + ...payload, + iab_category: result.payload.sponsoredIabCategory, + }, + lazy.CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK + ); + } + } + + /** + * Helper for engagement telemetry that records telemetry specific to + * navigational suggestions. + * + * @param {object} options + * Options object + * @param {UrlbarQueryContext} options.queryContext + * The query context. + * @param {UrlbarResult} options.result + * The quick suggest result related to the engagement, or null if no result + * was present. + * @param {string} options.resultSelType + * If an element in the result's row was clicked, this should be its + * `selType`. Otherwise it should be an empty string. + * @param {boolean} options.resultClicked + * True if the main part of the result's row was clicked; false if a button + * like help or dismiss was clicked or if no part of the row was clicked. + * @param {object} options.details + * The `details` object that was passed to `onEngagement()`. It must look + * like this: `{ selType, selIndex }` + */ + #recordNavSuggestionTelemetry({ + queryContext, + result, + resultSelType, + resultClicked, + details, + }) { + let scalars = []; + let heuristicClicked = + details.selIndex == 0 && queryContext.heuristicResult; + + if (result?.payload.telemetryType == "top_picks") { + // nav suggestion shown + scalars.push(TELEMETRY_SCALARS.IMPRESSION_NAV_SHOWN); + if (resultClicked) { + scalars.push(TELEMETRY_SCALARS.CLICK_NAV_SHOWN_NAV); + } else if (heuristicClicked) { + scalars.push(TELEMETRY_SCALARS.CLICK_NAV_SHOWN_HEURISTIC); + } + } else if ( + this.#resultFromLastQuery?.payload.telemetryType == "top_picks" && + this.#resultFromLastQuery?.payload.dupedHeuristic + ) { + // nav suggestion duped heuristic + scalars.push(TELEMETRY_SCALARS.IMPRESSION_NAV_SUPERCEDED); + if (heuristicClicked) { + scalars.push(TELEMETRY_SCALARS.CLICK_NAV_SUPERCEDED); + } + } else { + // nav suggestion not matched or otherwise not shown + scalars.push(TELEMETRY_SCALARS.IMPRESSION_NAV_NOTMATCHED); + if (heuristicClicked) { + scalars.push(TELEMETRY_SCALARS.CLICK_NAV_NOTMATCHED); + } + } + + let heuristicType = UrlbarUtils.searchEngagementTelemetryType( + queryContext.heuristicResult + ); + for (let scalar of scalars) { + Services.telemetry.keyedScalarAdd(scalar, heuristicType, 1); + } + } + + /** + * 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 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; + } + + get _test_merino() { + return this.#merino; + } + + // The result we added during the most recent query. + #resultFromLastQuery = null; + + // The Merino client. + #merino = null; +} + +export var UrlbarProviderQuickSuggest = new ProviderQuickSuggest(); |