Merge mozilla-central to autoland. CLOSED TREE
[gecko.git] / toolkit / actors / AutoCompleteChild.sys.mjs
blob059088d897637bdcbf296d07cb36d90df269f795
1 /* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
2 /* This Source Code Form is subject to the terms of the Mozilla Public
3  * License, v. 2.0. If a copy of the MPL was not distributed with this
4  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6 /* eslint no-unused-vars: ["error", {args: "none"}] */
8 const lazy = {};
10 ChromeUtils.defineESModuleGetters(lazy, {
11   ContentDOMReference: "resource://gre/modules/ContentDOMReference.sys.mjs",
12   LayoutUtils: "resource://gre/modules/LayoutUtils.sys.mjs",
13   LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs",
14 });
16 const gFormFillController = Cc[
17   "@mozilla.org/satchel/form-fill-controller;1"
18 ].getService(Ci.nsIFormFillController);
20 let autoCompleteListeners = new Set();
22 export class AutoCompleteChild extends JSWindowActorChild {
23   constructor() {
24     super();
26     this._input = null;
27     this._popupOpen = false;
28   }
30   static addPopupStateListener(listener) {
31     autoCompleteListeners.add(listener);
32   }
34   static removePopupStateListener(listener) {
35     autoCompleteListeners.delete(listener);
36   }
38   receiveMessage(message) {
39     switch (message.name) {
40       case "AutoComplete:HandleEnter": {
41         this.selectedIndex = message.data.selectedIndex;
43         let controller = Cc[
44           "@mozilla.org/autocomplete/controller;1"
45         ].getService(Ci.nsIAutoCompleteController);
46         controller.handleEnter(message.data.isPopupSelection);
47         break;
48       }
50       case "AutoComplete:PopupClosed": {
51         this._popupOpen = false;
52         this.notifyListeners(message.name, message.data);
53         break;
54       }
56       case "AutoComplete:PopupOpened": {
57         this._popupOpen = true;
58         this.notifyListeners(message.name, message.data);
59         break;
60       }
62       case "AutoComplete:Focus": {
63         // XXX See bug 1582722
64         // Before bug 1573836, the messages here didn't match
65         // ("AutoComplete:Focus" versus "AutoComplete:RequestFocus")
66         // so this was never called. However this._input is actually a
67         // nsIAutoCompleteInput, which doesn't have a focus() method, so it
68         // wouldn't have worked anyway. So for now, I have just disabled this.
69         /*
70         if (this._input) {
71           this._input.focus();
72         }
73         */
74         break;
75       }
76     }
77   }
79   notifyListeners(messageName, data) {
80     for (let listener of autoCompleteListeners) {
81       try {
82         listener.popupStateChanged(messageName, data, this.contentWindow);
83       } catch (ex) {
84         console.error(ex);
85       }
86     }
87   }
89   get input() {
90     return this._input;
91   }
93   set selectedIndex(index) {
94     this.sendAsyncMessage("AutoComplete:SetSelectedIndex", { index });
95   }
97   get selectedIndex() {
98     // selectedIndex getter must be synchronous because we need the
99     // correct value when the controller is in controller::HandleEnter.
100     // We can't easily just let the parent inform us the new value every
101     // time it changes because not every action that can change the
102     // selectedIndex is trivial to catch (e.g. moving the mouse over the
103     // list).
104     let selectedIndexResult = Services.cpmm.sendSyncMessage(
105       "AutoComplete:GetSelectedIndex",
106       {
107         browsingContext: this.browsingContext,
108       }
109     );
111     if (
112       selectedIndexResult.length != 1 ||
113       !Number.isInteger(selectedIndexResult[0])
114     ) {
115       throw new Error("Invalid autocomplete selectedIndex");
116     }
117     return selectedIndexResult[0];
118   }
120   get popupOpen() {
121     return this._popupOpen;
122   }
124   openAutocompletePopup(input, element) {
125     if (this._popupOpen || !input || !element?.isConnected) {
126       return;
127     }
129     let rect = lazy.LayoutUtils.getElementBoundingScreenRect(element);
130     let window = element.ownerGlobal;
131     let dir = window.getComputedStyle(element).direction;
132     let results = this.getResultsFromController(input);
133     let formOrigin = lazy.LoginHelper.getLoginOrigin(
134       element.ownerDocument.documentURI
135     );
136     let inputElementIdentifier = lazy.ContentDOMReference.get(element);
138     this.sendAsyncMessage("AutoComplete:MaybeOpenPopup", {
139       results,
140       rect,
141       dir,
142       inputElementIdentifier,
143       formOrigin,
144     });
146     this._input = input;
147   }
149   closePopup() {
150     // We set this here instead of just waiting for the
151     // PopupClosed message to do it so that we don't end
152     // up in a state where the content thinks that a popup
153     // is open when it isn't (or soon won't be).
154     this._popupOpen = false;
155     this.sendAsyncMessage("AutoComplete:ClosePopup", {});
156   }
158   invalidate() {
159     if (this._popupOpen) {
160       let results = this.getResultsFromController(this._input);
161       this.sendAsyncMessage("AutoComplete:Invalidate", { results });
162     }
163   }
165   selectBy(reverse, page) {
166     Services.cpmm.sendSyncMessage("AutoComplete:SelectBy", {
167       browsingContext: this.browsingContext,
168       reverse,
169       page,
170     });
171   }
173   getResultsFromController(inputField) {
174     let results = [];
176     if (!inputField) {
177       return results;
178     }
180     let controller = inputField.controller;
181     if (!(controller instanceof Ci.nsIAutoCompleteController)) {
182       return results;
183     }
185     for (let i = 0; i < controller.matchCount; ++i) {
186       let result = {};
187       result.value = controller.getValueAt(i);
188       result.label = controller.getLabelAt(i);
189       result.comment = controller.getCommentAt(i);
190       result.style = controller.getStyleAt(i);
191       result.image = controller.getImageAt(i);
192       results.push(result);
193     }
195     return results;
196   }
198   getNoRollupOnEmptySearch(input) {
199     const providers = this.providersByInput(input);
200     return Array.from(providers).find(p => p.actorName == "LoginManager");
201   }
203   // Store the input to interested autocomplete providers mapping
204   #providersByInput = new WeakMap();
206   // This functions returns the interested providers that have called
207   // `markAsAutoCompletableField` for the given input and also the hard-coded
208   // autocomplete providers based on input type.
209   providersByInput(input) {
210     const providers = new Set(this.#providersByInput.get(input));
212     if (input.hasBeenTypePassword) {
213       providers.add(
214         input.ownerGlobal.windowGlobalChild.getActor("LoginManager")
215       );
216     } else {
217       // The current design is that FormHisotry doesn't call `markAsAutoCompletable`
218       // for every eligilbe input. Instead, when FormFillController receives a focus event,
219       // it would control the <input> if the <input> is eligible to show form history.
220       // Because of the design, we need to ask FormHistory whether to search for autocomplete entries
221       // for every startSearch call
222       providers.add(
223         input.ownerGlobal.windowGlobalChild.getActor("FormHistory")
224       );
225     }
226     return providers;
227   }
229   /**
230    * This API should be used by an autocomplete entry provider to mark an input field
231    * as eligible for autocomplete for its type.
232    * When users click on an autocompletable input, we will search autocomplete entries
233    * from all the providers that have called this API for the given <input>.
234    *
235    * An autocomplete provider should be a JSWindowActor and implements the following
236    * functions:
237    * - string actorName()
238    * - bool shouldSearchForAutoComplete(element);
239    * - jsval getAutoCompleteSearchOption(element);
240    * - jsval searchResultToAutoCompleteResult(searchString, element, record);
241    * See `FormAutofillChild` for example
242    *
243    * @param input - The HTML <input> element that is considered autocompletable by the
244    *                given provider
245    * @param provider - A module that provides autocomplete entries for a <input>, for example,
246    *                   FormAutofill provides address or credit card autocomplete entries,
247    *                   LoginManager provides logins entreis.
248    */
249   markAsAutoCompletableField(input, provider) {
250     gFormFillController.markAsAutoCompletableField(input);
252     let providers = this.#providersByInput.get(input);
253     if (!providers) {
254       providers = new Set();
255       this.#providersByInput.set(input, providers);
256     }
257     providers.add(provider);
258   }
260   // Record the current ongoing search request. This is used by stopSearch
261   // to prevent notifying the autocomplete controller after receiving search request
262   // results that were issued prior to the call to stop the search.
263   #ongoingSearches = new Set();
265   async startSearch(searchString, input, listener) {
266     // TODO: This should be removed once we implement triggering autocomplete
267     // from the parent.
268     this.lastProfileAutoCompleteFocusedInput = input;
270     // For all the autocomplete entry providers that previsouly marked
271     // this <input> as autocompletable, ask the provider whether we should
272     // search for autocomplete entries in the parent. This is because the current
273     // design doesn't rely on the provider constantly monitor the <input> and
274     // then mark/unmark an input. The provider generally calls the
275     // `markAsAutoCompletbleField` when it sees an <input> is eliglbe for autocomplete.
276     // Here we ask the provider to exam the <input> more detailedly to see
277     // whether we need to search for autocomplete entries at the time users
278     // click on the <input>
279     const providers = this.providersByInput(input);
280     const data = Array.from(providers)
281       .filter(p => p.shouldSearchForAutoComplete(input, searchString))
282       .map(p => ({
283         actorName: p.actorName,
284         options: p.getAutoCompleteSearchOption(input, searchString),
285       }));
287     let result = [];
289     // We don't return empty result when no provider requests seaching entries in the
290     // parent because for some special cases, the autocomplete entries are coming
291     // from the content. For example, <datalist>.
292     if (data.length) {
293       const promise = this.sendQuery("AutoComplete:StartSearch", {
294         searchString,
295         data,
296       });
297       this.#ongoingSearches.add(promise);
298       result = await promise.catch(e => {
299         this.#ongoingSearches.delete(promise);
300       });
301       result ||= [];
303       // If the search is stopped, don't report back.
304       if (!this.#ongoingSearches.delete(promise)) {
305         return;
306       }
307     }
309     for (const provider of providers) {
310       // Search result could be empty. However, an autocomplete provider might
311       // want to show an autoclmplete popup when there is no search result. For example,
312       // <datalist> for FormHisotry, insecure warning for LoginManager.
313       const searchResult = result.find(r => r.actorName == provider.actorName);
314       const acResult = provider.searchResultToAutoCompleteResult(
315         searchString,
316         input,
317         searchResult
318       );
320       // We have not yet supported showing autocomplete entries from multiple providers,
321       // Note: The prioty is defined in AutoCompleteParent.
322       if (acResult) {
323         this.lastProfileAutoCompleteResult = acResult;
324         listener.onSearchCompletion(acResult);
325         return;
326       }
327     }
328     this.lastProfileAutoCompleteResult = null;
329   }
331   stopSearch() {
332     this.lastProfileAutoCompleteResult = null;
333     this.#ongoingSearches.clear();
334   }
337 AutoCompleteChild.prototype.QueryInterface = ChromeUtils.generateQI([
338   "nsIAutoCompletePopup",