diff options
Diffstat (limited to 'browser/components/urlbar/UrlbarProviderSearchSuggestions.sys.mjs')
-rw-r--r-- | browser/components/urlbar/UrlbarProviderSearchSuggestions.sys.mjs | 579 |
1 files changed, 579 insertions, 0 deletions
diff --git a/browser/components/urlbar/UrlbarProviderSearchSuggestions.sys.mjs b/browser/components/urlbar/UrlbarProviderSearchSuggestions.sys.mjs new file mode 100644 index 0000000000..f0f4b50af9 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderSearchSuggestions.sys.mjs @@ -0,0 +1,579 @@ +/* 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 search engine suggestions. + */ + +import { + SkippableTimer, + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FormHistory: "resource://gre/modules/FormHistory.sys.mjs", + SearchSuggestionController: + "resource://gre/modules/SearchSuggestionController.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarProviderTopSites: "resource:///modules/UrlbarProviderTopSites.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs", +}); + +/** + * Returns whether the passed in string looks like a url. + * + * @param {string} str + * The string to check. + * @param {boolean} [ignoreAlphanumericHosts] + * If true, don't consider a string with an alphanumeric host to be a URL. + * @returns {boolean} + * True if the query looks like a URL. + */ +function looksLikeUrl(str, ignoreAlphanumericHosts = false) { + // Single word including special chars. + return ( + !lazy.UrlbarTokenizer.REGEXP_SPACES.test(str) && + (["/", "@", ":", "["].some(c => str.includes(c)) || + (ignoreAlphanumericHosts + ? /^([\[\]A-Z0-9-]+\.){3,}[^.]+$/i.test(str) + : str.includes("."))) + ); +} + +/** + * Class used to create the provider. + */ +class ProviderSearchSuggestions extends UrlbarProvider { + constructor() { + super(); + } + + /** + * Returns the name of this provider. + * + * @returns {string} the name of this provider. + */ + get name() { + return "SearchSuggestions"; + } + + /** + * Returns the type of this provider. + * + * @returns {integer} one of the types from UrlbarUtils.PROVIDER_TYPE.* + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.NETWORK; + } + + /** + * 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) { + // 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; + } + + // No suggestions for empty search strings, unless we are restricting to + // search or showing trending suggestions. + if ( + !queryContext.trimmedSearchString && + !this._isTokenOrRestrictionPresent(queryContext) && + !this.#shouldFetchTrending(queryContext) + ) { + return false; + } + + if (!this._allowSuggestions(queryContext)) { + return false; + } + + let wantsLocalSuggestions = + lazy.UrlbarPrefs.get("maxHistoricalSearchSuggestions") && + (queryContext.trimmedSearchString || + lazy.UrlbarPrefs.get("update2.emptySearchBehavior") != 0); + + return wantsLocalSuggestions || this._allowRemoteSuggestions(queryContext); + } + + /** + * Returns whether the user typed a token alias or restriction token, or is in + * search mode. We use this value to override the pref to disable search + * suggestions in the Urlbar. + * + * @param {UrlbarQueryContext} queryContext The query context object. + * @returns {boolean} True if the user typed a token alias or search + * restriction token. + */ + _isTokenOrRestrictionPresent(queryContext) { + return ( + queryContext.searchString.startsWith("@") || + (queryContext.restrictSource && + queryContext.restrictSource == UrlbarUtils.RESULT_SOURCE.SEARCH) || + queryContext.tokens.some( + t => t.type == lazy.UrlbarTokenizer.TYPE.RESTRICT_SEARCH + ) || + (queryContext.searchMode && + queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.SEARCH)) + ); + } + + /** + * Returns whether suggestions in general are allowed for a given query + * context. If this returns false, then we shouldn't fetch either form + * history or remote suggestions. + * + * @param {object} queryContext The query context object + * @returns {boolean} True if suggestions in general are allowed and false if + * not. + */ + _allowSuggestions(queryContext) { + if ( + // If the user typed a restriction token or token alias, we ignore the + // pref to disable suggestions in the Urlbar. + (!lazy.UrlbarPrefs.get("suggest.searches") && + !this._isTokenOrRestrictionPresent(queryContext)) || + !lazy.UrlbarPrefs.get("browser.search.suggest.enabled") || + (queryContext.isPrivate && + !lazy.UrlbarPrefs.get("browser.search.suggest.enabled.private")) + ) { + return false; + } + return true; + } + + /** + * Returns whether remote suggestions are allowed for a given query context. + * + * @param {object} queryContext The query context object + * @param {string} [searchString] The effective search string without + * restriction tokens or aliases. Defaults to the context searchString. + * @returns {boolean} True if remote suggestions are allowed and false if not. + */ + _allowRemoteSuggestions( + queryContext, + searchString = queryContext.searchString + ) { + // This is checked by `queryContext.allowRemoteResults` below, but we can + // short-circuit that call with the `_isTokenOrRestrictionPresent` block + // before that. Make sure we don't allow remote suggestions if this is set. + if (queryContext.prohibitRemoteResults) { + return false; + } + + // Allow remote suggestions if trending suggestions are enabled. + if (this.#shouldFetchTrending(queryContext)) { + return true; + } + + if (!searchString.trim()) { + return false; + } + + // Skip all remaining checks and allow remote suggestions at this point if + // the user used a token alias or restriction token. We want "@engine query" + // to return suggestions from the engine. We'll return early from startQuery + // if the query doesn't match an alias. + if (this._isTokenOrRestrictionPresent(queryContext)) { + return true; + } + + // If the user is just adding on to a query that previously didn't return + // many remote suggestions, we are unlikely to get any more results. + if ( + !!this._lastLowResultsSearchSuggestion && + searchString.length > this._lastLowResultsSearchSuggestion.length && + searchString.startsWith(this._lastLowResultsSearchSuggestion) + ) { + return false; + } + + return queryContext.allowRemoteResults( + searchString, + lazy.UrlbarPrefs.get("trending.featureGate") + ); + } + + /** + * 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) { + let instance = this.queryInstance; + + let aliasEngine = await this._maybeGetAlias(queryContext); + if (!aliasEngine) { + // Autofill matches queries starting with "@" to token alias engines. + // If the string starts with "@", but an alias engine is not yet + // matched, then autofill might still be filtering token alias + // engine results. We don't want to mix search suggestions with those + // engine results, so we return early. See bug 1551049 comment 1 for + // discussion on how to improve this behavior. + if (queryContext.searchString.startsWith("@")) { + return; + } + } + + let query = aliasEngine + ? aliasEngine.query + : UrlbarUtils.substringAt( + queryContext.searchString, + queryContext.tokens[0]?.value || "" + ).trim(); + + let leadingRestrictionToken = null; + if ( + lazy.UrlbarTokenizer.isRestrictionToken(queryContext.tokens[0]) && + (queryContext.tokens.length > 1 || + queryContext.tokens[0].type == + lazy.UrlbarTokenizer.TYPE.RESTRICT_SEARCH) + ) { + leadingRestrictionToken = queryContext.tokens[0].value; + } + + // Strip a leading search restriction char, because we prepend it to text + // when the search shortcut is used and it's not user typed. Don't strip + // other restriction chars, so that it's possible to search for things + // including one of those (e.g. "c#"). + if (leadingRestrictionToken === lazy.UrlbarTokenizer.RESTRICT.SEARCH) { + query = UrlbarUtils.substringAfter(query, leadingRestrictionToken).trim(); + } + + // Find our search engine. It may have already been set with an alias. + let engine; + if (aliasEngine) { + engine = aliasEngine.engine; + } else if (queryContext.searchMode?.engineName) { + engine = lazy.UrlbarSearchUtils.getEngineByName( + queryContext.searchMode.engineName + ); + } else { + engine = lazy.UrlbarSearchUtils.getDefaultEngine(queryContext.isPrivate); + } + + if (!engine) { + return; + } + + let alias = (aliasEngine && aliasEngine.alias) || ""; + let results = await this._fetchSearchSuggestions( + queryContext, + engine, + query, + alias + ); + + if (!results || instance != this.queryInstance) { + return; + } + + for (let result of results) { + addCallback(this, result); + } + } + + /** + * Gets the provider's priority. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {number} The provider's priority for the given query. + */ + getPriority(queryContext) { + if (this.#shouldFetchTrending(queryContext)) { + return lazy.UrlbarProviderTopSites.PRIORITY; + } + return 0; + } + + /** + * Cancels a running query. + * + * @param {object} queryContext The query context object + */ + cancelQuery(queryContext) { + if (this._suggestionsController) { + this._suggestionsController.stop(); + this._suggestionsController = null; + } + } + + onEngagement(isPrivate, state, queryContext, details) { + let { result } = details; + if (result?.providerName != this.name) { + return; + } + + if (details.selType == "dismiss" && queryContext.formHistoryName) { + lazy.FormHistory.update({ + op: "remove", + fieldname: queryContext.formHistoryName, + value: result.payload.suggestion, + }).catch(error => + console.error(`Removing form history failed: ${error}`) + ); + queryContext.view.controller.removeResult(result); + } + } + + async _fetchSearchSuggestions(queryContext, engine, searchString, alias) { + if (!engine) { + return null; + } + + this._suggestionsController = new lazy.SearchSuggestionController( + queryContext.formHistoryName + ); + + // If there's a form history entry that equals the search string, the search + // suggestions controller will include it, and we'll make a result for it. + // If the heuristic result ends up being a search result, the muxer will + // discard the form history result since it dupes the heuristic, and the + // final list of results would be left with `count` - 1 form history results + // instead of `count`. Therefore we request `count` + 1 entries. The muxer + // will dedupe and limit the final form history count as appropriate. + this._suggestionsController.maxLocalResults = queryContext.maxResults + 1; + + // Request maxResults + 1 remote suggestions for the same reason we request + // maxResults + 1 form history entries. + let allowRemote = this._allowRemoteSuggestions(queryContext, searchString); + this._suggestionsController.maxRemoteResults = allowRemote + ? queryContext.maxResults + 1 + : 0; + + if (allowRemote && this.#shouldFetchTrending(queryContext)) { + if ( + queryContext.searchMode && + lazy.UrlbarPrefs.get("trending.maxResultsSearchMode") != -1 + ) { + this._suggestionsController.maxRemoteResults = lazy.UrlbarPrefs.get( + "trending.maxResultsSearchMode" + ); + } else if ( + !queryContext.searchMode && + lazy.UrlbarPrefs.get("trending.maxResultsNoSearchMode") != -1 + ) { + this._suggestionsController.maxRemoteResults = lazy.UrlbarPrefs.get( + "trending.maxResultsNoSearchMode" + ); + } + } + + this._suggestionsFetchCompletePromise = this._suggestionsController.fetch( + searchString, + queryContext.isPrivate, + engine, + queryContext.userContextId, + this._isTokenOrRestrictionPresent(queryContext), + false, + this.#shouldFetchTrending(queryContext) + ); + + // See `SearchSuggestionsController.fetch` documentation for a description + // of `fetchData`. + let fetchData = await this._suggestionsFetchCompletePromise; + // The fetch was canceled. + if (!fetchData) { + return null; + } + + let results = []; + + // maxHistoricalSearchSuggestions used to determine the initial number of + // form history results, with the special case where zero means to never + // show form history at all. With the introduction of flexed result + // groups, we now use it only as a boolean: Zero means don't show form + // history at all (as before), non-zero means show it. + if (lazy.UrlbarPrefs.get("maxHistoricalSearchSuggestions")) { + for (let entry of fetchData.local) { + results.push(makeFormHistoryResult(queryContext, engine, entry)); + } + } + + // If we don't return many results, then keep track of the query. If the + // user just adds on to the query, we won't fetch more suggestions if the + // query is very long since we are unlikely to get any. + if ( + allowRemote && + !fetchData.remote.length && + searchString.length > lazy.UrlbarPrefs.get("maxCharsForSearchSuggestions") + ) { + this._lastLowResultsSearchSuggestion = searchString; + } + + // If we have only tail suggestions, we only show them if we have no other + // results. We need to wait for other results to arrive to avoid flickering. + // We will wait for this timer unless we have suggestions that don't have a + // tail. + let tailTimer = new SkippableTimer({ + name: "ProviderSearchSuggestions", + time: 100, + logger: this.logger, + }); + + for (let entry of fetchData.remote) { + if (looksLikeUrl(entry.value)) { + continue; + } + + let tail = entry.tail; + let tailPrefix = entry.matchPrefix; + + // Skip tail suggestions if the pref is disabled. + if (tail && !lazy.UrlbarPrefs.get("richSuggestions.tail")) { + continue; + } + + if (!tail) { + await tailTimer.fire().catch(ex => this.logger.error(ex)); + } + + try { + results.push( + new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + ...lazy.UrlbarResult.payloadAndSimpleHighlights( + queryContext.tokens, + { + engine: [engine.name, UrlbarUtils.HIGHLIGHT.TYPED], + suggestion: [entry.value, UrlbarUtils.HIGHLIGHT.SUGGESTED], + lowerCaseSuggestion: entry.value.toLocaleLowerCase(), + tailPrefix, + tail: [tail, UrlbarUtils.HIGHLIGHT.SUGGESTED], + tailOffsetIndex: tail ? entry.tailOffsetIndex : undefined, + keyword: [ + alias ? alias : undefined, + UrlbarUtils.HIGHLIGHT.TYPED, + ], + trending: entry.trending, + isRichSuggestion: !!entry.icon, + description: entry.description || undefined, + query: [searchString.trim(), UrlbarUtils.HIGHLIGHT.NONE], + icon: !entry.value ? engine.iconURI?.spec : entry.icon, + } + ) + ) + ); + } catch (err) { + this.logger.error(err); + continue; + } + } + + await tailTimer.promise; + return results; + } + + /** + * @typedef {object} EngineAlias + * + * @property {nsISearchEngine} engine + * The search engine + * @property {string} alias + * The search engine's alias + * @property {string} query + * The remainder of the search engine string after the alias + */ + + /** + * Searches for an engine alias given the queryContext. + * + * @param {UrlbarQueryContext} queryContext + * The query context object. + * @returns {EngineAlias?} aliasEngine + * A representation of the aliased engine. Null if there's no match. + */ + async _maybeGetAlias(queryContext) { + if (queryContext.searchMode) { + // If we're in search mode, don't try to parse an alias at all. + return null; + } + + let possibleAlias = queryContext.tokens[0]?.value; + // "@" on its own is handled by UrlbarProviderTokenAliasEngines and returns + // a list of every available token alias. + if (!possibleAlias || possibleAlias == "@") { + return null; + } + + let query = UrlbarUtils.substringAfter( + queryContext.searchString, + possibleAlias + ); + + // Match an alias only when it has a space after it. If there's no trailing + // space, then continue to treat it as part of the search string. + if (!lazy.UrlbarTokenizer.REGEXP_SPACES_START.test(query)) { + return null; + } + + // Check if the user entered an engine alias directly. + let engineMatch = await lazy.UrlbarSearchUtils.engineForAlias( + possibleAlias + ); + if (engineMatch) { + return { + engine: engineMatch, + alias: possibleAlias, + query: query.trim(), + }; + } + + return null; + } + + /** + * Whether we should show trending suggestions. These are shown when the + * user enters a specific engines searchMode when enabled, the + * seperate `requireSearchMode` pref controls whether they are visible + * when the urlbar is first opened without any search mode. + * + * @param {UrlbarQueryContext} queryContext + * The query context object. + * @returns {boolean} + * Whether we should fetch trending results. + */ + #shouldFetchTrending(queryContext) { + return !!( + queryContext.searchString == "" && + lazy.UrlbarPrefs.get("trending.featureGate") && + (queryContext.searchMode || + !lazy.UrlbarPrefs.get("trending.requireSearchMode")) + ); + } +} + +function makeFormHistoryResult(queryContext, engine, entry) { + return new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.HISTORY, + ...lazy.UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + engine: engine.name, + suggestion: [entry.value, UrlbarUtils.HIGHLIGHT.SUGGESTED], + lowerCaseSuggestion: entry.value.toLocaleLowerCase(), + }) + ); +} + +export var UrlbarProviderSearchSuggestions = new ProviderSearchSuggestions(); |