forked from mirrors/gecko-dev
This is a follow up to D161866 and effectively reverts and replaces it with a different approach. Please see bug 1800810 for background. In short, engagement telemetry should be based on the result that's visible in the view, not on the result the provider added last. D161866 fixed the case where the last-added result is in the view but hidden. There's another case we need to handle, when the last-added result is not in the view at all. That can happen when you type something and hit enter really quickly, before the view can update. I was able to trigger it several times. When that happens, there are two possibilities: * No quick suggest result is visible in the view. `result.rowIndex` is its initial value of -1, so we record an engagement for a result that is not visible and that doesn't have a valid index. * A quick suggest result from a previous query is visible in the view. Here again, `result.rowIndex` is -1 so we record an engagement for a result that is not visible and that doesn't have a valid index, and we miss recording an engagement for the result that's actually visible. Right now it's not easy to fix this inside the provider because providers don't know anything about the view(s). I could record this telemetry in the view but that's not its role. I think it makes sense to include the view in the query context, so that's what this does. I added `view.visibleResults` to make getting the visible results easy. Daisuke's new Glean telemetry in D160193 uses `context.results`, which has this same problem of not being based on actually visible results. We'll need to use `context.view.visibleResults` there too, and there may be other existing cases as well. Depends on D161866 Differential Revision: https://phabricator.services.mozilla.com/D162182
362 lines
13 KiB
JavaScript
362 lines
13 KiB
JavaScript
/* 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, {
|
|
UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
|
|
UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
|
|
});
|
|
|
|
XPCOMUtils.defineLazyModuleGetters(lazy, {
|
|
BrowserUIUtils: "resource:///modules/BrowserUIUtils.jsm",
|
|
JsonSchemaValidator:
|
|
"resource://gre/modules/components-utils/JsonSchemaValidator.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 correspoding
|
|
* 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;
|
|
|
|
// 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 targetting 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,
|
|
];
|
|
}
|
|
return this.payload.title
|
|
? [this.payload.title, this.payloadHighlights.title]
|
|
: [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.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);
|
|
}
|
|
}
|