summaryrefslogtreecommitdiffstats
path: root/browser/components/urlbar/UrlbarProviderQuickActions.sys.mjs
blob: 914dc983af7e0d547582f9ea380e80fef2821dbc (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
/* 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();