Provider `onEngagement` hooks run parent-side (in `UrlbarProvidersManager`, from the parent controller). On the actor message path the parent controller has no child, so `controller.input`/`controller.view` are undefined and `details.element` is dropped on the wire, breaking the hooks that reach them (`GlobalActions`, `Autofill`, `ActionsSearchMode`, `Calculator`, `ContextualSearch`, `UnitConversion`). Resolve the element reads from data that survives the wire: `GlobalActions` reads the picked action key from `details.pickedActionKey` (resolved content-side by the telemetry snapshot); `QuickActions`' `pickAction` and `ActionsSearchMode` take the action key and input length from the result payload instead of the picked element, and `ActionsSearchMode` recomputes the disabled state from the action; `UnitConversion` copies its conversion output from the result payload instead of the picked element's text. Route the input/view side effects to the content side: the parent-side `UrlbarChildControllerProxy` exposes `input`/`view` stand-ins whose allowed methods (`search`, `_setValue`, `startQuery`, `close`, `startTail150`) forward to the real content objects as an `InvokeContentAction` message, so providers keep calling `controller.input.X()`/`controller.view.X()` unchanged. `ContextualSearch` passes the engine's name and aliases (all `search()` reads) as a plain object so it stays structured-cloneable. `this.input` is now a forwarding stand-in on the message path, so the `receiveResults` first-result guard checks for the `onFirstResult` method rather than mere truthiness (it only runs on the in-process input; the content side runs `onFirstResult` itself). The underscore-prefixed `UrlbarInput._setValue` is forwarded as-is here; a follow-up renames it to `setValue` now that it's part of the actor-facing surface. `browser_glean_telemetry_reenter.js` exercises a provider `onEngagement` that blurs the input. On the message path the hook fires only after the pick's engagement has been recorded and its session cleared, so the blur's abandonment record is a no-op rather than the synchronous `record()` re-entry the direct path sees; the re-entry assertion is gated to the direct path. Both paths still record exactly one engagement and no abandonment. The default in-process path is unaffected. Differential Revision: https://phabricator.services.mozilla.com/D310360
215 lines
5.9 KiB
JavaScript
215 lines
5.9 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 {
|
|
ActionsProvider,
|
|
ActionsResult,
|
|
} from "moz-src:///browser/components/urlbar/ActionsProvider.sys.mjs";
|
|
|
|
const lazy = {};
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
QuickActionsLoaderDefault:
|
|
"moz-src:///browser/components/urlbar/QuickActionsLoaderDefault.sys.mjs",
|
|
UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs",
|
|
});
|
|
|
|
// These prefs are relative to the `browser.urlbar` branch.
|
|
const ENABLED_PREF = "suggest.quickactions";
|
|
const MATCH_IN_PHRASE_PREF = "quickactions.matchInPhrase";
|
|
const MIN_SEARCH_PREF = "quickactions.minimumSearchString";
|
|
|
|
/**
|
|
* @typedef QuickActionsDefinition
|
|
* @property {string[]} commands
|
|
* The possible typed entries that this command will be displayed for.
|
|
* @property {string} icon
|
|
* The URI of the icon associated with this command.
|
|
* @property {string} label
|
|
* The id of the label for the result element.
|
|
* @property {() => boolean} [isVisible]
|
|
* A function to call to check if this action should be visible or not.
|
|
* @property {(queryContext, controller, window) => null|{focusContent: boolean}} onPick
|
|
* The function to call when the quick action is picked. It may return an object
|
|
* with property focusContent to indicate if the content area should be focussed
|
|
* after the pick.
|
|
*/
|
|
|
|
/**
|
|
* A provider that matches the urlbar input to built in actions.
|
|
*/
|
|
class ProviderQuickActions extends ActionsProvider {
|
|
get name() {
|
|
return "ActionsProviderQuickActions";
|
|
}
|
|
|
|
isActive(queryContext) {
|
|
return (
|
|
queryContext.sapName == "urlbar" &&
|
|
lazy.UrlbarPrefs.get(ENABLED_PREF) &&
|
|
!queryContext.restrictInSearchMode() &&
|
|
queryContext.trimmedSearchString.length < 50 &&
|
|
queryContext.trimmedSearchString.length >=
|
|
lazy.UrlbarPrefs.get(MIN_SEARCH_PREF)
|
|
);
|
|
}
|
|
|
|
async queryActions(queryContext) {
|
|
let input = queryContext.trimmedLowerCaseSearchString;
|
|
let results = await this.getActions({ input });
|
|
|
|
if (lazy.UrlbarPrefs.get(MATCH_IN_PHRASE_PREF)) {
|
|
for (let [keyword, keys] of this.#keywords) {
|
|
if (input.includes(keyword) && keys.length) {
|
|
keys.forEach(key => results.add(key));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove invisible actions.
|
|
results.forEach(key => {
|
|
const action = this.#actions.get(key);
|
|
if (!(action.isVisible?.() ?? true)) {
|
|
results.delete(key);
|
|
}
|
|
});
|
|
|
|
if (!results.size) {
|
|
return null;
|
|
}
|
|
|
|
return [...results].map(key => {
|
|
let action = this.#actions.get(key);
|
|
return new ActionsResult({
|
|
providerName: this.name,
|
|
key,
|
|
l10nId: action.label,
|
|
icon: action.icon,
|
|
dataset: {
|
|
action: key,
|
|
inputLength: queryContext.trimmedSearchString.length,
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
async getActions({ input, includesExactMatch = false }) {
|
|
await lazy.QuickActionsLoaderDefault.ensureLoaded();
|
|
|
|
let results = new Set(this.#prefixes.get(input));
|
|
|
|
if (includesExactMatch) {
|
|
let actions = this.#keywords.get(input);
|
|
actions?.forEach(action => results.add(action));
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
getAction(key) {
|
|
return this.#actions.get(key);
|
|
}
|
|
|
|
onPick(queryContext, controller, actionResult) {
|
|
this.pickAction(
|
|
queryContext,
|
|
controller,
|
|
actionResult.key,
|
|
actionResult.dataset.inputLength
|
|
);
|
|
}
|
|
|
|
pickAction(queryContext, controller, key, inputLength) {
|
|
inputLength = Math.min(inputLength, 10);
|
|
Glean.urlbarQuickaction.picked[`${key}-${inputLength}`].add(1);
|
|
let options = this.#actions.get(key).onPick(queryContext, controller);
|
|
if (options?.focusContent) {
|
|
controller.browserWindow.gBrowser.selectedBrowser.focus();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds a new QuickAction.
|
|
*
|
|
* @param {string} key A key to identify this action.
|
|
* @param {QuickActionsDefinition} definition An object that describes the action.
|
|
*/
|
|
addAction(key, definition) {
|
|
this.#actions.set(key, definition);
|
|
definition.commands.forEach(cmd => {
|
|
let keys = this.#keywords.get(cmd) ?? [];
|
|
keys.push(key);
|
|
this.#keywords.set(cmd, keys);
|
|
});
|
|
this.#loopOverPrefixes(definition.commands, prefix => {
|
|
let result = this.#prefixes.get(prefix);
|
|
if (result) {
|
|
result.add(key);
|
|
} else {
|
|
result = new Set([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 => {
|
|
let keys = this.#keywords.get(cmd) ?? [];
|
|
this.#keywords.set(
|
|
cmd,
|
|
keys.filter(k => k != key)
|
|
);
|
|
});
|
|
this.#loopOverPrefixes(definition.commands, prefix => {
|
|
let result = this.#prefixes.get(prefix);
|
|
if (result) {
|
|
result.delete(key);
|
|
}
|
|
this.#prefixes.set(prefix, result);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* A map from keywords to an action.
|
|
*
|
|
* @type {Map<string, Array>}
|
|
*/
|
|
#keywords = new Map();
|
|
|
|
/**
|
|
* A map of all prefixes to an array of actions.
|
|
*
|
|
* @type {Map<string, Set>}
|
|
*/
|
|
#prefixes = new Map();
|
|
|
|
/**
|
|
* The actions that have been added.
|
|
*
|
|
* @type {Map<string, QuickActionsDefinition>}
|
|
*/
|
|
#actions = new Map();
|
|
|
|
#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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export var ActionsProviderQuickActions = new ProviderQuickActions();
|