diff options
Diffstat (limited to 'browser/components/urlbar/UrlbarProviderQuickActions.sys.mjs')
-rw-r--r-- | browser/components/urlbar/UrlbarProviderQuickActions.sys.mjs | 359 |
1 files changed, 359 insertions, 0 deletions
diff --git a/browser/components/urlbar/UrlbarProviderQuickActions.sys.mjs b/browser/components/urlbar/UrlbarProviderQuickActions.sys.mjs new file mode 100644 index 0000000000..83eea9fcf6 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderQuickActions.sys.mjs @@ -0,0 +1,359 @@ +/* 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 ( + queryContext.trimmedSearchString.length < 50 && + 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, + inQuickActionsSearchMode: + queryContext.searchMode?.source == UrlbarUtils.RESULT_SOURCE.ACTIONS, + } + ); + result.suggestedIndex = SUGGESTED_INDEX; + addCallback(this, result); + this.#resultFromLastQuery = result; + } + + getViewTemplate(result) { + return { + children: [ + { + name: "buttons", + tag: "div", + attributes: { + "data-is-quickactions-searchmode": + result.payload.inQuickActionsSearchMode, + }, + children: result.payload.results.map(({ key }, i) => { + let action = this.#actions.get(key); + let inActive = "isActive" in action && !action.isActive(); + return { + name: `button-${i}`, + tag: "span", + attributes: { + "data-key": key, + "data-input-length": result.payload.inputLength, + class: "urlbarView-quickaction-button", + role: inActive ? "" : "button", + disabled: inActive, + }, + 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" }, + }, + ], + }; + }), + }, + ], + }; + } + + 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(); + } + } + + onEngagement(state, queryContext, details, controller) { + // 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(controller.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(); |