Merge mozilla-central to autoland. CLOSED TREE
[gecko.git] / toolkit / actors / AutoCompleteParent.sys.mjs
blob8cb51c8e58923e9f4a02a6ff00a7df0152e98e84
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
7 const lazy = {};
9 XPCOMUtils.defineLazyPreferenceGetter(
10   lazy,
11   "DELEGATE_AUTOCOMPLETE",
12   "toolkit.autocomplete.delegate",
13   false
16 ChromeUtils.defineESModuleGetters(lazy, {
17   GeckoViewAutocomplete: "resource://gre/modules/GeckoViewAutocomplete.sys.mjs",
18   setTimeout: "resource://gre/modules/Timer.sys.mjs",
19 });
21 const PREF_SECURITY_DELAY = "security.notification_enable_delay";
23 // Stores the actor that has the active popup, used by formfill
24 let currentActor = null;
26 let autoCompleteListeners = new Set();
28 function compareContext(message) {
29   if (
30     !currentActor ||
31     (currentActor.browsingContext != message.data.browsingContext &&
32       currentActor.browsingContext.top != message.data.browsingContext)
33   ) {
34     return false;
35   }
37   return true;
40 // These are two synchronous messages sent by the child.
41 // The browsingContext within the message data is either the one that has
42 // the active autocomplete popup or the top-level of the one that has
43 // the active autocomplete popup.
44 Services.ppmm.addMessageListener("AutoComplete:GetSelectedIndex", message => {
45   if (compareContext(message)) {
46     let actor = currentActor;
47     if (actor && actor.openedPopup) {
48       return actor.openedPopup.selectedIndex;
49     }
50   }
52   return -1;
53 });
55 Services.ppmm.addMessageListener("AutoComplete:SelectBy", message => {
56   if (compareContext(message)) {
57     let actor = currentActor;
58     if (actor && actor.openedPopup) {
59       actor.openedPopup.selectBy(message.data.reverse, message.data.page);
60     }
61   }
62 });
64 // AutoCompleteResultView is an abstraction around a list of results.
65 // It implements enough of nsIAutoCompleteController and
66 // nsIAutoCompleteInput to make the richlistbox popup work. Since only
67 // one autocomplete popup should be open at a time, this is a singleton.
68 var AutoCompleteResultView = {
69   // nsISupports
70   QueryInterface: ChromeUtils.generateQI([
71     "nsIAutoCompleteController",
72     "nsIAutoCompleteInput",
73   ]),
75   // Private variables
76   results: [],
78   // The AutoCompleteParent currently showing results or null otherwise.
79   currentActor: null,
81   // nsIAutoCompleteController
82   get matchCount() {
83     return this.results.length;
84   },
86   getValueAt(index) {
87     return this.results[index].value;
88   },
90   getFinalCompleteValueAt(index) {
91     return this.results[index].value;
92   },
94   getLabelAt(index) {
95     // Backwardly-used by richlist autocomplete - see getCommentAt.
96     // The label is used for secondary information.
97     return this.results[index].comment;
98   },
100   getCommentAt(index) {
101     // The richlist autocomplete popup uses comment for its main
102     // display of an item, which is why we're returning the label
103     // here instead.
104     return this.results[index].label;
105   },
107   getStyleAt(index) {
108     return this.results[index].style;
109   },
111   getImageAt(index) {
112     return this.results[index].image;
113   },
115   handleEnter(aIsPopupSelection) {
116     if (this.currentActor) {
117       this.currentActor.handleEnter(aIsPopupSelection);
118     }
119   },
121   stopSearch() {},
123   searchString: "",
125   // nsIAutoCompleteInput
126   get controller() {
127     return this;
128   },
130   get popup() {
131     return null;
132   },
134   _focus() {
135     if (this.currentActor) {
136       this.currentActor.requestFocus();
137     }
138   },
140   // Internal JS-only API
141   clearResults() {
142     this.currentActor = null;
143     this.results = [];
144   },
146   setResults(actor, results) {
147     this.currentActor = actor;
148     this.results = results;
149   },
152 export class AutoCompleteParent extends JSWindowActorParent {
153   didDestroy() {
154     if (this.openedPopup) {
155       this.openedPopup.closePopup();
156     }
157   }
159   static getCurrentActor() {
160     return currentActor;
161   }
163   static addPopupStateListener(listener) {
164     autoCompleteListeners.add(listener);
165   }
167   static removePopupStateListener(listener) {
168     autoCompleteListeners.delete(listener);
169   }
171   handleEvent(evt) {
172     switch (evt.type) {
173       case "popupshowing": {
174         this.sendAsyncMessage("AutoComplete:PopupOpened", {});
175         break;
176       }
178       case "popuphidden": {
179         let selectedIndex = this.openedPopup.selectedIndex;
180         let selectedRowComment =
181           selectedIndex != -1
182             ? AutoCompleteResultView.getCommentAt(selectedIndex)
183             : "";
184         let selectedRowStyle =
185           selectedIndex != -1
186             ? AutoCompleteResultView.getStyleAt(selectedIndex)
187             : "";
188         this.sendAsyncMessage("AutoComplete:PopupClosed", {
189           selectedRowComment,
190           selectedRowStyle,
191         });
192         AutoCompleteResultView.clearResults();
193         // adjustHeight clears the height from the popup so that
194         // we don't have a big shrink effect if we closed with a
195         // large list, and then open on a small one.
196         this.openedPopup.adjustHeight();
197         this.openedPopup = null;
198         currentActor = null;
199         evt.target.removeEventListener("popuphidden", this);
200         evt.target.removeEventListener("popupshowing", this);
201         break;
202       }
203     }
204   }
206   showPopupWithResults({ rect, dir, results }) {
207     if (!results.length || this.openedPopup) {
208       // We shouldn't ever be showing an empty popup, and if we
209       // already have a popup open, the old one needs to close before
210       // we consider opening a new one.
211       return;
212     }
214     let browser = this.browsingContext.top.embedderElement;
215     let window = browser.ownerGlobal;
216     // Also check window top in case this is a sidebar.
217     if (
218       Services.focus.activeWindow !== window.top &&
219       Services.focus.focusedWindow.top !== window.top
220     ) {
221       // We were sent a message from a window or tab that went into the
222       // background, so we'll ignore it for now.
223       return;
224     }
226     // Non-empty result styles
227     let resultStyles = new Set(results.map(r => r.style).filter(r => !!r));
228     currentActor = this;
229     this.openedPopup = browser.autoCompletePopup;
230     // the layout varies according to different result type
231     this.openedPopup.setAttribute("resultstyles", [...resultStyles].join(" "));
232     this.openedPopup.hidden = false;
233     // don't allow the popup to become overly narrow
234     this.openedPopup.style.setProperty(
235       "--panel-width",
236       Math.max(100, rect.width) + "px"
237     );
238     this.openedPopup.style.direction = dir;
240     AutoCompleteResultView.setResults(this, results);
241     this.openedPopup.view = AutoCompleteResultView;
242     this.openedPopup.selectedIndex = -1;
244     // Reset fields that were set from the last time the search popup was open
245     this.openedPopup.mInput = AutoCompleteResultView;
246     // Temporarily increase the maxRows as we don't want to show
247     // the scrollbar in login or form autofill popups.
248     if (
249       resultStyles.size &&
250       (resultStyles.has("autofill") || resultStyles.has("loginsFooter"))
251     ) {
252       this.openedPopup._normalMaxRows = this.openedPopup.maxRows;
253       this.openedPopup.mInput.maxRows = 10;
254     }
255     browser.constrainPopup(this.openedPopup);
256     this.openedPopup.addEventListener("popuphidden", this);
257     this.openedPopup.addEventListener("popupshowing", this);
258     this.openedPopup.openPopupAtScreenRect(
259       "after_start",
260       rect.left,
261       rect.top,
262       rect.width,
263       rect.height,
264       false,
265       false
266     );
267     this.openedPopup.invalidate();
268     this._maybeRecordTelemetryEvents(results);
270     // This is a temporary solution. We should replace it with
271     // proper meta information about the popup once such field
272     // becomes available.
273     let isCreditCard = results.some(result =>
274       result?.comment?.includes("cc-number")
275     );
277     if (isCreditCard) {
278       this.delayPopupInput();
279     }
280   }
282   /**
283    * @param {object[]} results - Non-empty array of autocomplete results.
284    */
285   _maybeRecordTelemetryEvents(results) {
286     let actor =
287       this.browsingContext.currentWindowGlobal.getActor("LoginManager");
288     actor.maybeRecordPasswordGenerationShownTelemetryEvent(results);
290     // Assume the result with the start time (loginsFooter) is last.
291     let lastResult = results[results.length - 1];
292     if (lastResult.style != "loginsFooter") {
293       return;
294     }
296     // The comment field of `loginsFooter` results have many additional pieces of
297     // information for telemetry purposes. After bug 1555209, this information
298     // can be passed to the parent process outside of nsIAutoCompleteResult APIs
299     // so we won't need this hack.
300     let rawExtraData = JSON.parse(lastResult.comment).telemetryEventData;
301     if (!rawExtraData.searchStartTimeMS) {
302       throw new Error("Invalid autocomplete search start time");
303     }
305     if (rawExtraData.stringLength > 1) {
306       // To reduce event volume, only record for lengths 0 and 1.
307       return;
308     }
310     let duration =
311       Services.telemetry.msSystemNow() - rawExtraData.searchStartTimeMS;
312     delete rawExtraData.searchStartTimeMS;
314     // Add counts by result style to rawExtraData.
315     results.reduce((accumulated, r) => {
316       // Ignore learn more as it is only added after importable logins.
317       // Do not track generic items in the telemetry.
318       if (r.style === "importableLearnMore" || r.style === "generic") {
319         return accumulated;
320       }
322       // Keys can be a maximum of 15 characters and values must be strings.
323       // Also treat both "loginWithOrigin" and "login" as "login" as extra_keys
324       // is limited to 10.
325       let truncatedStyle = r.style.substring(
326         0,
327         r.style === "loginWithOrigin" ? 5 : 15
328       );
329       accumulated[truncatedStyle] = (accumulated[truncatedStyle] || 0) + 1;
330       return accumulated;
331     }, rawExtraData);
333     // Convert extra values to strings since recordEvent requires that.
334     let extraStrings = Object.fromEntries(
335       Object.entries(rawExtraData).map(([key, val]) => {
336         let stringVal = "";
337         if (typeof val == "boolean") {
338           stringVal += val ? "1" : "0";
339         } else {
340           stringVal += val;
341         }
342         return [key, stringVal];
343       })
344     );
346     Services.telemetry.recordEvent(
347       "form_autocomplete",
348       "show",
349       "logins",
350       // Convert to a string
351       duration + "",
352       extraStrings
353     );
354   }
356   invalidate(results) {
357     if (!this.openedPopup) {
358       return;
359     }
361     if (!results.length) {
362       this.closePopup();
363     } else {
364       AutoCompleteResultView.setResults(this, results);
365       this.openedPopup.invalidate();
366       this._maybeRecordTelemetryEvents(results);
367     }
368   }
370   closePopup() {
371     if (this.openedPopup) {
372       // Note that hidePopup() closes the popup immediately,
373       // so popuphiding or popuphidden events will be fired
374       // and handled during this call.
375       this.openedPopup.hidePopup();
376     }
377   }
379   async receiveMessage(message) {
380     let browser = this.browsingContext.top.embedderElement;
382     if (
383       !browser ||
384       (!lazy.DELEGATE_AUTOCOMPLETE && !browser.autoCompletePopup)
385     ) {
386       // If there is no browser or popup, just make sure that the popup has been closed.
387       if (this.openedPopup) {
388         this.openedPopup.closePopup();
389       }
391       // Returning false to pacify ESLint, but this return value is
392       // ignored by the messaging infrastructure.
393       return false;
394     }
396     switch (message.name) {
397       case "AutoComplete:SetSelectedIndex": {
398         let { index } = message.data;
399         if (this.openedPopup) {
400           this.openedPopup.selectedIndex = index;
401         }
402         break;
403       }
405       case "AutoComplete:MaybeOpenPopup": {
406         let { results, rect, dir, inputElementIdentifier, formOrigin } =
407           message.data;
408         if (lazy.DELEGATE_AUTOCOMPLETE) {
409           lazy.GeckoViewAutocomplete.delegateSelection({
410             browsingContext: this.browsingContext,
411             options: results,
412             inputElementIdentifier,
413             formOrigin,
414           });
415         } else {
416           this.showPopupWithResults({ results, rect, dir });
417           this.notifyListeners();
418         }
419         break;
420       }
422       case "AutoComplete:Invalidate": {
423         let { results } = message.data;
424         this.invalidate(results);
425         break;
426       }
428       case "AutoComplete:ClosePopup": {
429         if (lazy.DELEGATE_AUTOCOMPLETE) {
430           lazy.GeckoViewAutocomplete.delegateDismiss();
431           break;
432         }
433         this.closePopup();
434         break;
435       }
437       case "AutoComplete:StartSearch": {
438         const { searchString, data } = message.data;
439         const result = await this.#startSearch(searchString, data);
440         return result;
441       }
442     }
443     // Returning false to pacify ESLint, but this return value is
444     // ignored by the messaging infrastructure.
445     return false;
446   }
448   // Imposes a brief period during which the popup will not respond to
449   // a click, so as to reduce the chances of a successful clickjacking
450   // attempt
451   delayPopupInput() {
452     if (!this.openedPopup) {
453       return;
454     }
455     const popupDelay = Services.prefs.getIntPref(PREF_SECURITY_DELAY);
457     // Mochitests set this to 0, and many will fail on integration
458     // if we make the popup items inactive, even briefly.
459     if (!popupDelay) {
460       return;
461     }
463     const items = Array.from(
464       this.openedPopup.getElementsByTagName("richlistitem")
465     );
466     items.forEach(item => (item.disabled = true));
468     lazy.setTimeout(
469       () => items.forEach(item => (item.disabled = false)),
470       popupDelay
471     );
472   }
474   notifyListeners() {
475     let window = this.browsingContext.top.embedderElement.ownerGlobal;
476     for (let listener of autoCompleteListeners) {
477       try {
478         listener(window);
479       } catch (ex) {
480         console.error(ex);
481       }
482     }
483   }
485   /**
486    * Despite its name, this handleEnter is only called when the user clicks on
487    * one of the items in the popup since the popup is rendered in the parent process.
488    * The real controller's handleEnter is called directly in the content process
489    * for other methods of completing a selection (e.g. using the tab or enter
490    * keys) since the field with focus is in that process.
491    * @param {boolean} aIsPopupSelection
492    */
493   handleEnter(aIsPopupSelection) {
494     if (this.openedPopup) {
495       this.sendAsyncMessage("AutoComplete:HandleEnter", {
496         selectedIndex: this.openedPopup.selectedIndex,
497         isPopupSelection: aIsPopupSelection,
498       });
499     }
500   }
502   // This defines the supported autocomplete providers and the prioity to show the autocomplete
503   // entry.
504   #AUTOCOMPLETE_PROVIDERS = ["FormAutofill", "LoginManager", "FormHistory"];
506   /**
507    * Search across multiple module to gather autocomplete entries for a given search string.
508    *
509    * @param {string} searchString
510    *                 The input string used to query autocomplete entries across different
511    *                 autocomplete providers.
512    * @param {Array<Object>} providers
513    *                        An array of objects where each object has a `name` used to identify the actor
514    *                        name of the provider and `options` that are passed to the `searchAutoCompleteEntries`
515    *                        method of the actor.
516    * @returns {Array<Object>} An array of results objects with `name` of the provider and `entries`
517    *          that are returned from the provider module's `searchAutoCompleteEntries` method.
518    */
519   async #startSearch(searchString, providers) {
520     for (const name of this.#AUTOCOMPLETE_PROVIDERS) {
521       const provider = providers.find(p => p.actorName == name);
522       if (!provider) {
523         continue;
524       }
525       const { actorName, options } = provider;
526       const actor =
527         this.browsingContext.currentWindowGlobal.getActor(actorName);
528       const entries = await actor?.searchAutoCompleteEntries(
529         searchString,
530         options
531       );
533       // We have not yet supported showing autocomplete entries from multiple providers,
534       if (entries) {
535         return [{ actorName, ...entries }];
536       }
537     }
538     return [];
539   }
541   stopSearch() {}
543   /**
544    * Sends a message to the browser that is requesting the input
545    * that the open popup should be focused.
546    */
547   requestFocus() {
548     // Bug 1582722 - See the response in AutoCompleteChild.sys.mjs for why this
549     // disabled.
550     /*
551     if (this.openedPopup) {
552       this.sendAsyncMessage("AutoComplete:Focus");
553     }
554     */
555   }