summaryrefslogtreecommitdiffstats
path: root/toolkit/actors/AutoCompleteChild.sys.mjs
blob: 61d48e91249201ffd2b78c7b76b771f5ef969178 (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
/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* 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/. */

/* eslint no-unused-vars: ["error", {args: "none"}] */

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  ContentDOMReference: "resource://gre/modules/ContentDOMReference.sys.mjs",
  LayoutUtils: "resource://gre/modules/LayoutUtils.sys.mjs",
  LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs",
});

const gFormFillController = Cc[
  "@mozilla.org/satchel/form-fill-controller;1"
].getService(Ci.nsIFormFillController);

let autoCompleteListeners = new Set();

export class AutoCompleteChild extends JSWindowActorChild {
  constructor() {
    super();

    this._input = null;
    this._popupOpen = false;
  }

  static addPopupStateListener(listener) {
    autoCompleteListeners.add(listener);
  }

  static removePopupStateListener(listener) {
    autoCompleteListeners.delete(listener);
  }

  receiveMessage(message) {
    switch (message.name) {
      case "AutoComplete:HandleEnter": {
        this.selectedIndex = message.data.selectedIndex;

        let controller = Cc[
          "@mozilla.org/autocomplete/controller;1"
        ].getService(Ci.nsIAutoCompleteController);
        controller.handleEnter(message.data.isPopupSelection);
        break;
      }

      case "AutoComplete:PopupClosed": {
        this._popupOpen = false;
        this.notifyListeners(message.name, message.data);
        break;
      }

      case "AutoComplete:PopupOpened": {
        this._popupOpen = true;
        this.notifyListeners(message.name, message.data);
        break;
      }

      case "AutoComplete:Focus": {
        // XXX See bug 1582722
        // Before bug 1573836, the messages here didn't match
        // ("AutoComplete:Focus" versus "AutoComplete:RequestFocus")
        // so this was never called. However this._input is actually a
        // nsIAutoCompleteInput, which doesn't have a focus() method, so it
        // wouldn't have worked anyway. So for now, I have just disabled this.
        /*
        if (this._input) {
          this._input.focus();
        }
        */
        break;
      }
    }
  }

  notifyListeners(messageName, data) {
    for (let listener of autoCompleteListeners) {
      try {
        listener.popupStateChanged(messageName, data, this.contentWindow);
      } catch (ex) {
        console.error(ex);
      }
    }
  }

  get input() {
    return this._input;
  }

  set selectedIndex(index) {
    this.sendAsyncMessage("AutoComplete:SetSelectedIndex", { index });
  }

  get selectedIndex() {
    // selectedIndex getter must be synchronous because we need the
    // correct value when the controller is in controller::HandleEnter.
    // We can't easily just let the parent inform us the new value every
    // time it changes because not every action that can change the
    // selectedIndex is trivial to catch (e.g. moving the mouse over the
    // list).
    let selectedIndexResult = Services.cpmm.sendSyncMessage(
      "AutoComplete:GetSelectedIndex",
      {
        browsingContext: this.browsingContext,
      }
    );

    if (
      selectedIndexResult.length != 1 ||
      !Number.isInteger(selectedIndexResult[0])
    ) {
      throw new Error("Invalid autocomplete selectedIndex");
    }
    return selectedIndexResult[0];
  }

  get popupOpen() {
    return this._popupOpen;
  }

  openAutocompletePopup(input, element) {
    if (this._popupOpen || !input || !element?.isConnected) {
      return;
    }

    let rect = lazy.LayoutUtils.getElementBoundingScreenRect(element);
    let window = element.ownerGlobal;
    let dir = window.getComputedStyle(element).direction;
    let results = this.getResultsFromController(input);
    let formOrigin = lazy.LoginHelper.getLoginOrigin(
      element.ownerDocument.documentURI
    );
    let inputElementIdentifier = lazy.ContentDOMReference.get(element);

    this.sendAsyncMessage("AutoComplete:MaybeOpenPopup", {
      results,
      rect,
      dir,
      inputElementIdentifier,
      formOrigin,
      actorName: this.lastAutoCompleteProviderName,
    });

    this._input = input;
  }

  closePopup() {
    // We set this here instead of just waiting for the
    // PopupClosed message to do it so that we don't end
    // up in a state where the content thinks that a popup
    // is open when it isn't (or soon won't be).
    this._popupOpen = false;
    this.sendAsyncMessage("AutoComplete:ClosePopup", {});
  }

  invalidate() {
    if (this._popupOpen) {
      let results = this.getResultsFromController(this._input);
      this.sendAsyncMessage("AutoComplete:Invalidate", { results });
    }
  }

  selectBy(reverse, page) {
    Services.cpmm.sendSyncMessage("AutoComplete:SelectBy", {
      browsingContext: this.browsingContext,
      reverse,
      page,
    });
  }

  getResultsFromController(inputField) {
    let results = [];

    if (!inputField) {
      return results;
    }

    let controller = inputField.controller;
    if (!(controller instanceof Ci.nsIAutoCompleteController)) {
      return results;
    }

    for (let i = 0; i < controller.matchCount; ++i) {
      let result = {};
      result.value = controller.getValueAt(i);
      result.label = controller.getLabelAt(i);
      result.comment = controller.getCommentAt(i);
      result.style = controller.getStyleAt(i);
      result.image = controller.getImageAt(i);
      results.push(result);
    }

    return results;
  }

  getNoRollupOnEmptySearch(input) {
    const providers = this.providersByInput(input);
    return Array.from(providers).find(p => p.actorName == "LoginManager");
  }

  // Store the input to interested autocomplete providers mapping
  #providersByInput = new WeakMap();

  // This functions returns the interested providers that have called
  // `markAsAutoCompletableField` for the given input and also the hard-coded
  // autocomplete providers based on input type.
  providersByInput(input) {
    const providers = new Set(this.#providersByInput.get(input));

    if (input.hasBeenTypePassword) {
      providers.add(
        input.ownerGlobal.windowGlobalChild.getActor("LoginManager")
      );
    } else {
      // The current design is that FormHisotry doesn't call `markAsAutoCompletable`
      // for every eligilbe input. Instead, when FormFillController receives a focus event,
      // it would control the <input> if the <input> is eligible to show form history.
      // Because of the design, we need to ask FormHistory whether to search for autocomplete entries
      // for every startSearch call
      providers.add(
        input.ownerGlobal.windowGlobalChild.getActor("FormHistory")
      );
    }
    return providers;
  }

  /**
   * This API should be used by an autocomplete entry provider to mark an input field
   * as eligible for autocomplete for its type.
   * When users click on an autocompletable input, we will search autocomplete entries
   * from all the providers that have called this API for the given <input>.
   *
   * An autocomplete provider should be a JSWindowActor and implements the following
   * functions:
   * - string actorName()
   * - bool shouldSearchForAutoComplete(element);
   * - jsval getAutoCompleteSearchOption(element);
   * - jsval searchResultToAutoCompleteResult(searchString, element, record);
   * See `FormAutofillChild` for example
   *
   * @param input - The HTML <input> element that is considered autocompletable by the
   *                given provider
   * @param provider - A module that provides autocomplete entries for a <input>, for example,
   *                   FormAutofill provides address or credit card autocomplete entries,
   *                   LoginManager provides logins entreis.
   */
  markAsAutoCompletableField(input, provider) {
    gFormFillController.markAsAutoCompletableField(input);

    let providers = this.#providersByInput.get(input);
    if (!providers) {
      providers = new Set();
      this.#providersByInput.set(input, providers);
    }
    providers.add(provider);
  }

  // Record the current ongoing search request. This is used by stopSearch
  // to prevent notifying the autocomplete controller after receiving search request
  // results that were issued prior to the call to stop the search.
  #ongoingSearches = new Set();

  async startSearch(searchString, input, listener) {
    // TODO: This should be removed once we implement triggering autocomplete
    // from the parent.
    this.lastProfileAutoCompleteFocusedInput = input;

    // For all the autocomplete entry providers that previsouly marked
    // this <input> as autocompletable, ask the provider whether we should
    // search for autocomplete entries in the parent. This is because the current
    // design doesn't rely on the provider constantly monitor the <input> and
    // then mark/unmark an input. The provider generally calls the
    // `markAsAutoCompletbleField` when it sees an <input> is eliglbe for autocomplete.
    // Here we ask the provider to exam the <input> more detailedly to see
    // whether we need to search for autocomplete entries at the time users
    // click on the <input>
    const providers = this.providersByInput(input);
    const data = Array.from(providers)
      .filter(p => p.shouldSearchForAutoComplete(input, searchString))
      .map(p => ({
        actorName: p.actorName,
        options: p.getAutoCompleteSearchOption(input, searchString),
      }));

    let result = [];

    // We don't return empty result when no provider requests seaching entries in the
    // parent because for some special cases, the autocomplete entries are coming
    // from the content. For example, <datalist>.
    if (data.length) {
      const promise = this.sendQuery("AutoComplete:StartSearch", {
        searchString,
        data,
      });
      this.#ongoingSearches.add(promise);
      result = await promise.catch(e => {
        this.#ongoingSearches.delete(promise);
      });
      result ||= [];

      // If the search is stopped, don't report back.
      if (!this.#ongoingSearches.delete(promise)) {
        return;
      }
    }

    for (const provider of providers) {
      // Search result could be empty. However, an autocomplete provider might
      // want to show an autocomplete popup when there is no search result. For example,
      // <datalist> for FormHisotry, insecure warning for LoginManager.
      const searchResult = result.find(r => r.actorName == provider.actorName);
      const acResult = provider.searchResultToAutoCompleteResult(
        searchString,
        input,
        searchResult
      );

      // We have not yet supported showing autocomplete entries from multiple providers,
      // Note: The prioty is defined in AutoCompleteParent.
      if (acResult) {
        // `lastAutoCompleteProviderName` should be removed once we implement
        // the mapping of autocomplete entry to provider in the parent process.
        this.lastAutoCompleteProviderName = provider.actorName;

        this.lastProfileAutoCompleteResult = acResult;
        listener.onSearchCompletion(acResult);
        return;
      }
    }
    this.lastProfileAutoCompleteResult = null;
  }

  stopSearch() {
    this.lastProfileAutoCompleteResult = null;
    this.#ongoingSearches.clear();
  }

  selectEntry() {
    // we don't need to pass the selected index to the parent process because
    // the selected index is maintained in the parent.
    this.sendAsyncMessage("AutoComplete:SelectEntry");
  }
}

AutoCompleteChild.prototype.QueryInterface = ChromeUtils.generateQI([
  "nsIAutoCompletePopup",
]);