diff options
Diffstat (limited to 'browser/components/urlbar/UrlbarResult.sys.mjs')
-rw-r--r-- | browser/components/urlbar/UrlbarResult.sys.mjs | 382 |
1 files changed, 382 insertions, 0 deletions
diff --git a/browser/components/urlbar/UrlbarResult.sys.mjs b/browser/components/urlbar/UrlbarResult.sys.mjs new file mode 100644 index 0000000000..81f9aa118d --- /dev/null +++ b/browser/components/urlbar/UrlbarResult.sys.mjs @@ -0,0 +1,382 @@ +/* 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 urlbar result class, each representing a single result + * found by a provider that can be passed from the model to the view through + * the controller. It is mainly defined by a result type, and a payload, + * containing the data. A few getters allow to retrieve information common to all + * the result types. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + JsonSchemaValidator: + "resource://gre/modules/components-utils/JsonSchemaValidator.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + BrowserUIUtils: "resource:///modules/BrowserUIUtils.jsm", +}); + +/** + * Class used to create a single result. + */ +export class UrlbarResult { + /** + * Creates a result. + * + * @param {integer} resultType one of UrlbarUtils.RESULT_TYPE.* values + * @param {integer} resultSource one of UrlbarUtils.RESULT_SOURCE.* values + * @param {object} payload data for this result. A payload should always + * contain a way to extract a final url to visit. The url getter + * should have a case for each of the types. + * @param {object} [payloadHighlights] payload highlights, if any. Each + * property in the payload may have a corresponding property in this + * object. The value of each property should be an array of [index, + * length] tuples. Each tuple indicates a substring in the corresponding + * payload property. + */ + constructor(resultType, resultSource, payload, payloadHighlights = {}) { + // Type describes the payload and visualization that should be used for + // this result. + if (!Object.values(lazy.UrlbarUtils.RESULT_TYPE).includes(resultType)) { + throw new Error("Invalid result type"); + } + this.type = resultType; + + // Source describes which data has been used to derive this result. In case + // multiple sources are involved, use the more privacy restricted. + if (!Object.values(lazy.UrlbarUtils.RESULT_SOURCE).includes(resultSource)) { + throw new Error("Invalid result source"); + } + this.source = resultSource; + + // UrlbarView is responsible for updating this. + this.rowIndex = -1; + + // May be used to indicate an heuristic result. Heuristic results can bypass + // source filters in the ProvidersManager, that otherwise may skip them. + this.heuristic = false; + + // Exposure specific properties. These allow us to track the exposure + // of a result through the query process. + // A non-zero value here indicates that this result's exposure should be + // recorded in the exposure event. + this.exposureResultType = ""; + + // Determines if the exposure result should be hidden from the view. + this.exposureResultHidden = false; + + // The payload contains result data. Some of the data is common across + // multiple types, but most of it will vary. + if (!payload || typeof payload != "object") { + throw new Error("Invalid result payload"); + } + this.payload = this.validatePayload(payload); + + if (!payloadHighlights || typeof payloadHighlights != "object") { + throw new Error("Invalid result payload highlights"); + } + this.payloadHighlights = payloadHighlights; + + // Make sure every property in the payload has an array of highlights. If a + // payload property does not have a highlights array, then give it one now. + // That way the consumer doesn't need to check whether it exists. + for (let name in payload) { + if (!(name in this.payloadHighlights)) { + this.payloadHighlights[name] = []; + } + } + } + + /** + * Returns a title that could be used as a label for this result. + * + * @returns {string} The label to show in a simplified title / url view. + */ + get title() { + return this._titleAndHighlights[0]; + } + + /** + * Returns an array of highlights for the title. + * + * @returns {Array} The array of highlights. + */ + get titleHighlights() { + return this._titleAndHighlights[1]; + } + + /** + * Returns an array [title, highlights]. + * + * @returns {Array} The title and array of highlights. + */ + get _titleAndHighlights() { + switch (this.type) { + case lazy.UrlbarUtils.RESULT_TYPE.KEYWORD: + case lazy.UrlbarUtils.RESULT_TYPE.TAB_SWITCH: + case lazy.UrlbarUtils.RESULT_TYPE.URL: + case lazy.UrlbarUtils.RESULT_TYPE.OMNIBOX: + case lazy.UrlbarUtils.RESULT_TYPE.REMOTE_TAB: + if (this.payload.qsSuggestion) { + return [ + // We will initially only be targeting en-US users with this experiment + // but will need to change this to work properly with l10n. + this.payload.qsSuggestion + " — " + this.payload.title, + this.payloadHighlights.qsSuggestion, + ]; + } + + if (this.payload.fallbackTitle) { + return [ + this.payload.fallbackTitle, + this.payloadHighlights.fallbackTitle, + ]; + } + + if (this.payload.title) { + return [this.payload.title, this.payloadHighlights.title]; + } + + return [this.payload.url ?? "", this.payloadHighlights.url ?? []]; + case lazy.UrlbarUtils.RESULT_TYPE.SEARCH: + if (this.payload.providesSearchMode) { + return ["", []]; + } + if (this.payload.tail && this.payload.tailOffsetIndex >= 0) { + return [this.payload.tail, this.payloadHighlights.tail]; + } else if (this.payload.suggestion) { + return [this.payload.suggestion, this.payloadHighlights.suggestion]; + } + return [this.payload.query, this.payloadHighlights.query]; + default: + return ["", []]; + } + } + + /** + * Returns an icon url. + * + * @returns {string} url of the icon. + */ + get icon() { + return this.payload.icon; + } + + /** + * Returns whether the result's `suggestedIndex` property is defined. + * `suggestedIndex` is an optional hint to the muxer that can be set to + * suggest a specific position among the results. + * + * @returns {boolean} Whether `suggestedIndex` is defined. + */ + get hasSuggestedIndex() { + return typeof this.suggestedIndex == "number"; + } + + /** + * Returns the given payload if it's valid or throws an error if it's not. + * The schemas in UrlbarUtils.RESULT_PAYLOAD_SCHEMA are used for validation. + * + * @param {object} payload The payload object. + * @returns {object} `payload` if it's valid. + */ + validatePayload(payload) { + let schema = lazy.UrlbarUtils.getPayloadSchema(this.type); + if (!schema) { + throw new Error(`Unrecognized result type: ${this.type}`); + } + let result = lazy.JsonSchemaValidator.validate(payload, schema, { + allowExplicitUndefinedProperties: true, + allowNullAsUndefinedProperties: true, + allowExtraProperties: this.type == lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC, + }); + if (!result.valid) { + throw result.error; + } + return payload; + } + + /** + * A convenience function that takes a payload annotated with + * UrlbarUtils.HIGHLIGHT enums and returns the payload and the payload's + * highlights. Use this function when the highlighting required by your + * payload is based on simple substring matching, as done by + * UrlbarUtils.getTokenMatches(). Pass the return values as the `payload` and + * `payloadHighlights` params of the UrlbarResult constructor. + * `payloadHighlights` is optional. If omitted, payload will not be + * highlighted. + * + * If the payload doesn't have a title or has an empty title, and it also has + * a URL, then this function also sets the title to the URL's domain. + * + * @param {Array} tokens The tokens that should be highlighted in each of the + * payload properties. + * @param {object} payloadInfo An object that looks like this: + * { payloadPropertyName: payloadPropertyInfo } + * + * Each payloadPropertyInfo may be either a string or an array. If + * it's a string, then the property value will be that string, and no + * highlighting will be applied to it. If it's an array, then it + * should look like this: [payloadPropertyValue, highlightType]. + * payloadPropertyValue may be a string or an array of strings. If + * it's a string, then the payloadHighlights in the return value will + * be an array of match highlights as described in + * UrlbarUtils.getTokenMatches(). If it's an array, then + * payloadHighlights will be an array of arrays of match highlights, + * one element per element in payloadPropertyValue. + * @returns {Array} An array [payload, payloadHighlights]. + */ + static payloadAndSimpleHighlights(tokens, payloadInfo) { + // Convert scalar values in payloadInfo to [value] arrays. + for (let [name, info] of Object.entries(payloadInfo)) { + if (!Array.isArray(info)) { + payloadInfo[name] = [info]; + } + } + + if ( + (!payloadInfo.title || !payloadInfo.title[0]) && + !payloadInfo.fallbackTitle && + payloadInfo.url && + typeof payloadInfo.url[0] == "string" + ) { + // If there's no title, show the domain as the title. Not all valid URLs + // have a domain. + payloadInfo.title = payloadInfo.title || [ + "", + lazy.UrlbarUtils.HIGHLIGHT.TYPED, + ]; + try { + payloadInfo.title[0] = new URL(payloadInfo.url[0]).host; + } catch (e) {} + } + + if (payloadInfo.url) { + // For display purposes we need to unescape the url. + payloadInfo.displayUrl = [...payloadInfo.url]; + let url = payloadInfo.displayUrl[0]; + if (url && lazy.UrlbarPrefs.get("trimURLs")) { + url = lazy.BrowserUIUtils.removeSingleTrailingSlashFromURL(url); + if (url.startsWith("https://")) { + url = url.substring(8); + if (url.startsWith("www.")) { + url = url.substring(4); + } + } + } + payloadInfo.displayUrl[0] = lazy.UrlbarUtils.unEscapeURIForUI(url); + } + + // For performance reasons limit excessive string lengths, to reduce the + // amount of string matching we do here, and avoid wasting resources to + // handle long textruns that the user would never see anyway. + for (let prop of ["displayUrl", "title", "suggestion"]) { + let val = payloadInfo[prop]?.[0]; + if (typeof val == "string") { + payloadInfo[prop][0] = val.substring( + 0, + lazy.UrlbarUtils.MAX_TEXT_LENGTH + ); + } + } + + let entries = Object.entries(payloadInfo); + return [ + entries.reduce((payload, [name, [val, _]]) => { + payload[name] = val; + return payload; + }, {}), + entries.reduce((highlights, [name, [val, highlightType]]) => { + if (highlightType) { + highlights[name] = !Array.isArray(val) + ? lazy.UrlbarUtils.getTokenMatches(tokens, val || "", highlightType) + : val.map(subval => + lazy.UrlbarUtils.getTokenMatches(tokens, subval, highlightType) + ); + } + return highlights; + }, {}), + ]; + } + + static _dynamicResultTypesByName = new Map(); + + /** + * Registers a dynamic result type. Dynamic result types are types that are + * created at runtime, for example by an extension. A particular type should + * be added only once; if this method is called for a type more than once, the + * `type` in the last call overrides those in previous calls. + * + * @param {string} name + * The name of the type. This is used in CSS selectors, so it shouldn't + * contain any spaces or punctuation except for -, _, etc. + * @param {object} type + * An object that describes the type. Currently types do not have any + * associated metadata, so this object should be empty. + */ + static addDynamicResultType(name, type = {}) { + if (/[^a-z0-9_-]/i.test(name)) { + this.logger.error(`Illegal dynamic type name: ${name}`); + return; + } + this._dynamicResultTypesByName.set(name, type); + } + + /** + * Unregisters a dynamic result type. + * + * @param {string} name + * The name of the type. + */ + static removeDynamicResultType(name) { + let type = this._dynamicResultTypesByName.get(name); + if (type) { + this._dynamicResultTypesByName.delete(name); + } + } + + /** + * Returns an object describing a registered dynamic result type. + * + * @param {string} name + * The name of the type. + * @returns {object} + * Currently types do not have any associated metadata, so the return value + * is an empty object if the type exists. If the type doesn't exist, + * undefined is returned. + */ + static getDynamicResultType(name) { + return this._dynamicResultTypesByName.get(name); + } + + /** + * This is useful for logging results. If you need the full payload, then it's + * better to JSON.stringify the result object itself. + * + * @returns {string} string representation of the result. + */ + toString() { + if (this.payload.url) { + return this.payload.title + " - " + this.payload.url.substr(0, 100); + } + if (this.payload.keyword) { + return this.payload.keyword + " - " + this.payload.query; + } + if (this.payload.suggestion) { + return this.payload.engine + " - " + this.payload.suggestion; + } + if (this.payload.engine) { + return this.payload.engine + " - " + this.payload.query; + } + return JSON.stringify(this); + } +} |