fune/browser/components/urlbar/UrlbarProviderQuickActions.sys.mjs
Drew Willcoxon a3b0a1fd4c Bug 1827762 - Replace UrlbarProvider.pickResult() and blockResult() with onEngagement() r=mak
This removes `UrlbarProvider.pickResult()` and `blockResult()` in favor of
handling picks and dismissals through `onEngagement()`. A number of providers
use those two methods, so this revision touches a lot of files.

Handling dismissals through `onEngagement()` means `UrlbarInput.pickResult()`
can no longer tell whether a result is successfully dismissed, so it can't
remove the result anymore. (Maybe `onEngagement()` could return some value
indicating it dismissed the result, but I don't want to go down that road.)
Instead, I split `UrlbarController.handleDeleteEntry()` into two methods: a
public one that removes the result and notifies listeners, and a private one
that handles dismissing the selected result internally in
UrlbarController. Providers that have dismissable results should now implement
`onEngagement()` and call `controller.removeResult()`.

I made some other improvements to engagement handling. There's still room for
more but this patch is big enough already.

Other notable changes:

Include the engaged result in engagement notifications so providers have easy
access to it and can respond to clicks and dismissals more easily. That also
lets us stop passing `selIndex` and `provider` to `engagementEvent.record()`
since now it can compute those from the passed-in result.

Add the concept of `isSessionOngoing` to engagement notifications so providers
can tell whether an engagement ended the search session. Right now, providers
like quick suggest that record a bunch of provider-specific legacy telemetry
assume that `onEngagement()` ends the session, but that's no longer true.

Unify result buttons and result menu commands by setting
`element.dataset.command` on buttons (hopefully we can remove buttons soon, at
least the ones that aren't tip buttons)

Make sure we always notify providers on engagement even on dismissals or
when skipping legacy telemetry

Move dismissal of restyled search suggestions and history results from
`UrlbarController.handleDeleteEntry()` to the Places provider

Move dismissal of form history results from
`UrlbarController.handleDeleteEntry()` to the search suggestions provider

In the Places provider, remove the unused `_addSearchEngineMatch()` method. Also
remove the code in the "searchengine" case that creates a non-search-history
result. This code is unreached because the only time the provider creates a
"searchengine" match it also sets `isSearchHistory` to true.

In `UrlbarTestUtils.promiseAutocompleteResultPopup()`, change the default value
of the `fireInputEvent` param from false to true. This is necessary because
without a starting input event, the start event info in `engagementEvent` will
be null, so when `engagementEvent.record()` is called at the end of the
engagement, it will bail, and providers will not be notified of the engagement.
IMO true is a better default value anyway because input events will typically be
fired when the user performs a search.

Differential Revision: https://phabricator.services.mozilla.com/D174941
2023-04-13 06:03:33 +00:00

372 lines
11 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/. */
import {
UrlbarProvider,
UrlbarUtils,
} from "resource:///modules/UrlbarUtils.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
QuickActionsLoaderDefault:
"resource:///modules/QuickActionsLoaderDefault.sys.mjs",
UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs",
});
// These prefs are relative to the `browser.urlbar` branch.
const ENABLED_PREF = "quickactions.enabled";
const SUGGEST_PREF = "suggest.quickactions";
const MATCH_IN_PHRASE_PREF = "quickactions.matchInPhrase";
const MIN_SEARCH_PREF = "quickactions.minimumSearchString";
const DYNAMIC_TYPE_NAME = "quickactions";
// When the urlbar is first focused and no search term has been
// entered we show a limited number of results.
const ACTIONS_SHOWN_FOCUS = 4;
// Default icon shown for actions if no custom one is provided.
const DEFAULT_ICON = "chrome://global/skin/icons/settings.svg";
// The suggestion index of the actions row within the urlbar results.
const SUGGESTED_INDEX = 1;
/**
* A provider that returns a suggested url to the user based on what
* they have currently typed so they can navigate directly.
*/
class ProviderQuickActions extends UrlbarProvider {
constructor() {
super();
lazy.UrlbarResult.addDynamicResultType(DYNAMIC_TYPE_NAME);
Services.tm.idleDispatchToMainThread(() =>
lazy.QuickActionsLoaderDefault.load()
);
}
/**
* Returns the name of this provider.
*
* @returns {string} the name of this provider.
*/
get name() {
return DYNAMIC_TYPE_NAME;
}
/**
* The type of the provider.
*
* @returns {UrlbarUtils.PROVIDER_TYPE}
*/
get type() {
return UrlbarUtils.PROVIDER_TYPE.PROFILE;
}
getPriority(context) {
if (!context.searchString) {
return 1;
}
return 0;
}
/**
* 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) {
return (
lazy.UrlbarPrefs.get(ENABLED_PREF) &&
((lazy.UrlbarPrefs.get(SUGGEST_PREF) && !queryContext.searchMode) ||
queryContext.searchMode?.source == UrlbarUtils.RESULT_SOURCE.ACTIONS)
);
}
/**
* 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 input = queryContext.trimmedSearchString.toLowerCase();
if (
!queryContext.searchMode &&
input.length < lazy.UrlbarPrefs.get(MIN_SEARCH_PREF)
) {
return;
}
let results = [...(this.#prefixes.get(input) ?? [])];
if (lazy.UrlbarPrefs.get(MATCH_IN_PHRASE_PREF)) {
for (let [keyword, key] of this.#keywords) {
if (input.includes(keyword)) {
results.push(key);
}
}
}
// Ensure results are unique.
results = [...new Set(results)];
// Remove invisible actions.
results = results.filter(key => {
const action = this.#actions.get(key);
return !action.isVisible || action.isVisible();
});
if (!results?.length) {
return;
}
// If all actions are inactive, don't show anything.
if (
results.every(key => {
const action = this.#actions.get(key);
return action.isActive && !action.isActive();
})
) {
return;
}
// If we are in the Actions searchMode then we want to show all the actions
// but not when we are in the normal url mode on first focus.
if (
results.length > ACTIONS_SHOWN_FOCUS &&
!input &&
!queryContext.searchMode
) {
results.length = ACTIONS_SHOWN_FOCUS;
}
const result = new lazy.UrlbarResult(
UrlbarUtils.RESULT_TYPE.DYNAMIC,
UrlbarUtils.RESULT_SOURCE.ACTIONS,
{
results: results.map(key => ({ key })),
dynamicType: DYNAMIC_TYPE_NAME,
inputLength: input.length,
}
);
result.suggestedIndex = SUGGESTED_INDEX;
addCallback(this, result);
this.#resultFromLastQuery = result;
}
getViewTemplate(result) {
return {
children: [
{
name: "buttons",
tag: "div",
children: result.payload.results.map(({ key }, i) => {
let action = this.#actions.get(key);
let inActive = "isActive" in action && !action.isActive();
let row = {
name: `button-${i}`,
tag: "span",
attributes: {
"data-key": key,
"data-input-length": result.payload.inputLength,
class: "urlbarView-quickaction-row",
role: inActive ? "" : "button",
},
children: [
{
name: `icon-${i}`,
tag: "div",
attributes: { class: "urlbarView-favicon" },
children: [
{
name: `image-${i}`,
tag: "img",
attributes: {
class: "urlbarView-favicon-img",
src: action.icon || DEFAULT_ICON,
},
},
],
},
{
name: `label-${i}`,
tag: "span",
attributes: { class: "urlbarView-label" },
},
],
};
if (inActive) {
row.attributes.disabled = "disabled";
}
return row;
}),
},
],
};
}
getViewUpdate(result) {
let viewUpdate = {};
result.payload.results.forEach(({ key }, i) => {
let action = this.#actions.get(key);
viewUpdate[`label-${i}`] = {
l10n: { id: action.label, cacheable: true },
};
});
return viewUpdate;
}
#pickResult(result, itemPicked) {
let { key, inputLength } = itemPicked.dataset;
// We clamp the input length to limit the number of keys to
// the number of actions * 10.
inputLength = Math.min(inputLength, 10);
Services.telemetry.keyedScalarAdd(
`quickaction.picked`,
`${key}-${inputLength}`,
1
);
let options = this.#actions.get(itemPicked.dataset.key).onPick() ?? {};
if (options.focusContent) {
itemPicked.ownerGlobal.gBrowser.selectedBrowser.focus();
}
}
/**
* 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;
}
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);
}
result?.payload.results.forEach(({ key }) => {
Services.telemetry.keyedScalarAdd(
`quickaction.impression`,
`${key}-${queryContext.trimmedSearchString.length}`,
1
);
});
}
// Handle picks.
if (details.result?.providerName == this.name) {
this.#pickResult(details.result, details.element);
}
this.#resultFromLastQuery = null;
}
/**
* Adds a new QuickAction.
*
* @param {string} key A key to identify this action.
* @param {string} definition An object that describes the action.
*/
addAction(key, definition) {
this.#actions.set(key, definition);
definition.commands.forEach(cmd => this.#keywords.set(cmd, key));
this.#loopOverPrefixes(definition.commands, prefix => {
let result = this.#prefixes.get(prefix);
if (result) {
if (!result.includes(key)) {
result.push(key);
}
} else {
result = [key];
}
this.#prefixes.set(prefix, result);
});
}
/**
* Removes an action.
*
* @param {string} key A key to identify this action.
*/
removeAction(key) {
let definition = this.#actions.get(key);
this.#actions.delete(key);
definition.commands.forEach(cmd => this.#keywords.delete(cmd));
this.#loopOverPrefixes(definition.commands, prefix => {
let result = this.#prefixes.get(prefix);
if (result) {
result = result.filter(val => val != key);
}
this.#prefixes.set(prefix, result);
});
}
// A map from keywords to an action.
#keywords = new Map();
// A map of all prefixes to an array of actions.
#prefixes = new Map();
// The actions that have been added.
#actions = new Map();
// The result we added during the most recent query.
#resultFromLastQuery = null;
#loopOverPrefixes(commands, fun) {
for (const command of commands) {
// Loop over all the prefixes of the word, ie
// "", "w", "wo", "wor", stopping just before the full
// word itself which will be matched by the whole
// phrase matching.
for (let i = 1; i <= command.length; i++) {
let prefix = command.substring(0, command.length - i);
fun(prefix);
}
}
}
#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.
return view?.visibleResults?.find(r => r.providerName == this.name);
}
}
export var UrlbarProviderQuickActions = new ProviderQuickActions();