diff options
Diffstat (limited to 'browser/components/urlbar/UrlbarProviderTabToSearch.sys.mjs')
-rw-r--r-- | browser/components/urlbar/UrlbarProviderTabToSearch.sys.mjs | 475 |
1 files changed, 475 insertions, 0 deletions
diff --git a/browser/components/urlbar/UrlbarProviderTabToSearch.sys.mjs b/browser/components/urlbar/UrlbarProviderTabToSearch.sys.mjs new file mode 100644 index 0000000000..a328ea0922 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderTabToSearch.sys.mjs @@ -0,0 +1,475 @@ +/* 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/. */ + +/** + * This module exports a provider that offers a search engine when the user is + * typing a search engine domain. + */ + +import { + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + UrlbarView: "resource:///modules/UrlbarView.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarProviderAutofill: "resource:///modules/UrlbarProviderAutofill.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs", +}); + +const DYNAMIC_RESULT_TYPE = "onboardTabToSearch"; +const VIEW_TEMPLATE = { + attributes: { + selectable: true, + }, + children: [ + { + name: "no-wrap", + tag: "span", + classList: ["urlbarView-no-wrap"], + children: [ + { + name: "icon", + tag: "img", + classList: ["urlbarView-favicon"], + }, + { + name: "text-container", + tag: "span", + children: [ + { + name: "first-row-container", + tag: "span", + children: [ + { + name: "title", + tag: "span", + classList: ["urlbarView-title"], + children: [ + { + name: "titleStrong", + tag: "strong", + }, + ], + }, + { + name: "title-separator", + tag: "span", + classList: ["urlbarView-title-separator"], + }, + { + name: "action", + tag: "span", + classList: ["urlbarView-action"], + attributes: { + "slide-in": true, + }, + }, + ], + }, + { + name: "description", + tag: "span", + }, + ], + }, + ], + }, + ], +}; + +/** + * Initializes this provider's dynamic result. To be called after the creation + * of the provider singleton. + */ +function initializeDynamicResult() { + lazy.UrlbarResult.addDynamicResultType(DYNAMIC_RESULT_TYPE); + lazy.UrlbarView.addDynamicViewTemplate(DYNAMIC_RESULT_TYPE, VIEW_TEMPLATE); +} + +/** + * Class used to create the provider. + */ +class ProviderTabToSearch extends UrlbarProvider { + constructor() { + super(); + this.enginesShown = { + onboarding: new Set(), + regular: new Set(), + }; + } + + /** + * Returns the name of this provider. + * + * @returns {string} the name of this provider. + */ + get name() { + return "TabToSearch"; + } + + /** + * Returns the type of this provider. + * + * @returns {integer} one of the types from UrlbarUtils.PROVIDER_TYPE.* + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.PROFILE; + } + + /** + * 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. + */ + async isActive(queryContext) { + return ( + queryContext.searchString && + queryContext.tokens.length == 1 && + !queryContext.searchMode && + lazy.UrlbarPrefs.get("suggest.engines") + ); + } + + /** + * Gets the provider's priority. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {number} The provider's priority for the given query. + */ + getPriority(queryContext) { + return 0; + } + + /** + * 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. + * @returns {object} An object describing the view update. + */ + getViewUpdate(result, idsByName) { + return { + icon: { + attributes: { + src: result.payload.icon, + }, + }, + titleStrong: { + l10n: { + id: "urlbar-result-action-search-w-engine", + args: { + engine: result.payload.engine, + }, + }, + }, + action: { + l10n: { + id: result.payload.isGeneralPurposeEngine + ? "urlbar-result-action-tabtosearch-web" + : "urlbar-result-action-tabtosearch-other-engine", + args: { + engine: result.payload.engine, + }, + }, + }, + description: { + l10n: { + id: "urlbar-tabtosearch-onboard", + }, + }, + }; + } + + /** + * Called when a result from the provider is selected. "Selected" refers to + * the user highlighing the result with the arrow keys/Tab, before it is + * picked. onSelection is also called when a user clicks a result. In the + * event of a click, onSelection is called just before onEngagement. + * + * @param {UrlbarResult} result + * The result that was selected. + * @param {Element} element + * The element in the result's view that was selected. + */ + onSelection(result, element) { + // We keep track of the number of times the user interacts with + // tab-to-search onboarding results so we stop showing them after + // `tabToSearch.onboard.interactionsLeft` interactions. + // Also do not increment the counter if the result was interacted with less + // than 5 minutes ago. This is a guard against the user running up the + // counter by interacting with the same result repeatedly. + if ( + result.payload.dynamicType && + (!this.onboardingInteractionAtTime || + this.onboardingInteractionAtTime < Date.now() - 1000 * 60 * 5) + ) { + let interactionsLeft = lazy.UrlbarPrefs.get( + "tabToSearch.onboard.interactionsLeft" + ); + + if (interactionsLeft > 0) { + lazy.UrlbarPrefs.set( + "tabToSearch.onboard.interactionsLeft", + --interactionsLeft + ); + } + + this.onboardingInteractionAtTime = Date.now(); + } + } + + onEngagement(state, queryContext, details, controller) { + let { result, element } = details; + if ( + result?.providerName == this.name && + result.type == UrlbarUtils.RESULT_TYPE.DYNAMIC + ) { + // Confirm search mode, but only for the onboarding (dynamic) result. The + // input will handle confirming search mode for the non-onboarding + // `RESULT_TYPE.SEARCH` result since it sets `providesSearchMode`. + element.ownerGlobal.gURLBar.maybeConfirmSearchModeFromResult({ + result, + checkValue: false, + }); + } + + if (!this.enginesShown.regular.size && !this.enginesShown.onboarding.size) { + return; + } + + try { + // urlbar.tabtosearch.* is prerelease-only/opt-in for now. See bug 1686330. + for (let engine of this.enginesShown.regular) { + let scalarKey = lazy.UrlbarSearchUtils.getSearchModeScalarKey({ + engineName: engine, + }); + Services.telemetry.keyedScalarAdd( + "urlbar.tabtosearch.impressions", + scalarKey, + 1 + ); + } + for (let engine of this.enginesShown.onboarding) { + let scalarKey = lazy.UrlbarSearchUtils.getSearchModeScalarKey({ + engineName: engine, + }); + Services.telemetry.keyedScalarAdd( + "urlbar.tabtosearch.impressions_onboarding", + scalarKey, + 1 + ); + } + + // We also record in urlbar.tips because only it has been approved for use + // in release channels. + Services.telemetry.keyedScalarAdd( + "urlbar.tips", + "tabtosearch-shown", + this.enginesShown.regular.size + ); + Services.telemetry.keyedScalarAdd( + "urlbar.tips", + "tabtosearch_onboard-shown", + this.enginesShown.onboarding.size + ); + } catch (ex) { + // If your test throws this error or causes another test to throw it, it + // is likely because your test showed a tab-to-search result but did not + // start and end the engagement in which it was shown. Be sure to fire an + // input event to start an engagement and blur the Urlbar to end it. + this.logger.error( + `Exception while recording TabToSearch telemetry: ${ex})` + ); + } finally { + // Even if there's an exception, we want to clear these Sets. Otherwise, + // we might get into a state where we repeatedly run the same engines + // through the code above and never record telemetry, because there's an + // error every time. + this.enginesShown.regular.clear(); + this.enginesShown.onboarding.clear(); + } + } + + /** + * Defines whether the view should defer user selection events while waiting + * for the first result from this provider. + * + * @returns {boolean} Whether the provider wants to defer user selection + * events. + */ + get deferUserSelection() { + return true; + } + + /** + * Starts querying. + * + * @param {object} queryContext The query context object + * @param {Function} addCallback Callback invoked by the provider to add a new + * result. + * @returns {Promise} resolved when the query stops. + */ + async startQuery(queryContext, addCallback) { + // enginesForDomainPrefix only matches against engine domains. + // Remove trailing slashes and www. from the search string and check if the + // resulting string is worth matching. + let [searchStr] = UrlbarUtils.stripPrefixAndTrim( + queryContext.searchString, + { + stripWww: true, + trimSlash: true, + } + ); + // Skip any string that cannot be an origin. + if ( + !lazy.UrlbarTokenizer.looksLikeOrigin(searchStr, { + ignoreKnownDomains: true, + noIp: true, + }) + ) { + return; + } + + // Also remove the public suffix, if present, to allow for partial matches. + if (searchStr.includes(".")) { + searchStr = UrlbarUtils.stripPublicSuffixFromHost(searchStr); + } + + // Add all matching engines. + let engines = await lazy.UrlbarSearchUtils.enginesForDomainPrefix( + searchStr, + { + matchAllDomainLevels: true, + onlyEnabled: true, + } + ); + if (!engines.length) { + return; + } + + const onboardingInteractionsLeft = lazy.UrlbarPrefs.get( + "tabToSearch.onboard.interactionsLeft" + ); + + // If the engine host begins with the search string, autofill may happen + // for it, and the Muxer will retain the result only if there's a matching + // autofill heuristic result. + // Otherwise, we may have a partial match, where the search string is at + // the boundary of a host part, for example "wiki" in "en.wikipedia.org". + // We put those engines apart, and later we check if their host satisfies + // the autofill threshold. If they do, we mark them with the + // "satisfiesAutofillThreshold" payload property, so the muxer can avoid + // filtering them out. + let partialMatchEnginesByHost = new Map(); + + for (let engine of engines) { + // Trim the engine host. This will also be set as the result url, so the + // Muxer can use it to filter. + let [host] = UrlbarUtils.stripPrefixAndTrim(engine.searchUrlDomain, { + stripWww: true, + }); + // Check if the host may be autofilled. + if (host.startsWith(searchStr.toLocaleLowerCase())) { + if (onboardingInteractionsLeft > 0) { + addCallback(this, makeOnboardingResult(engine)); + } else { + addCallback(this, makeResult(queryContext, engine)); + } + continue; + } + + // Otherwise it may be a partial match that would not be autofilled. + if (host.includes("." + searchStr.toLocaleLowerCase())) { + partialMatchEnginesByHost.set(engine.searchUrlDomain, engine); + // Don't continue here, we are looking for more partial matches. + } + // We also try to match the searchForm domain, because otherwise for an + // engine like ebay, we'd check rover.ebay.com, when the user is likely + // to visit ebay.LANG. The searchForm URL often points to the main host. + let searchFormHost; + try { + searchFormHost = new URL(engine.searchForm).host; + } catch (ex) { + // Invalid url or no searchForm. + } + if (searchFormHost?.includes("." + searchStr)) { + partialMatchEnginesByHost.set(searchFormHost, engine); + } + } + if (partialMatchEnginesByHost.size) { + let host = await lazy.UrlbarProviderAutofill.getTopHostOverThreshold( + queryContext, + Array.from(partialMatchEnginesByHost.keys()) + ); + if (host) { + let engine = partialMatchEnginesByHost.get(host); + if (onboardingInteractionsLeft > 0) { + addCallback(this, makeOnboardingResult(engine, true)); + } else { + addCallback(this, makeResult(queryContext, engine, true)); + } + } + } + } +} + +function makeOnboardingResult(engine, satisfiesAutofillThreshold = false) { + let [url] = UrlbarUtils.stripPrefixAndTrim(engine.searchUrlDomain, { + stripWww: true, + }); + url = url.substr(0, url.length - engine.searchUrlPublicSuffix.length); + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.DYNAMIC, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { + engine: engine.name, + url, + providesSearchMode: true, + icon: UrlbarUtils.ICON.SEARCH_GLASS, + dynamicType: DYNAMIC_RESULT_TYPE, + satisfiesAutofillThreshold, + } + ); + result.resultSpan = 2; + result.suggestedIndex = 1; + return result; +} + +function makeResult(context, engine, satisfiesAutofillThreshold = false) { + let [url] = UrlbarUtils.stripPrefixAndTrim(engine.searchUrlDomain, { + stripWww: true, + }); + url = url.substr(0, url.length - engine.searchUrlPublicSuffix.length); + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + ...lazy.UrlbarResult.payloadAndSimpleHighlights(context.tokens, { + engine: engine.name, + isGeneralPurposeEngine: engine.isGeneralPurposeEngine, + url, + providesSearchMode: true, + icon: UrlbarUtils.ICON.SEARCH_GLASS, + query: "", + satisfiesAutofillThreshold, + }) + ); + result.suggestedIndex = 1; + return result; +} + +export var UrlbarProviderTabToSearch = new ProviderTabToSearch(); +initializeDynamicResult(); |