Bug 1944627 - update sidebar button checked state for non-revamped sidebar cases...
[gecko.git] / browser / components / urlbar / UrlbarSearchOneOffs.sys.mjs
blob60d9fa725cb77f8c7f071b85ca5db3f6a52794c4
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 { SearchOneOffs } from "resource:///modules/SearchOneOffs.sys.mjs";
7 const lazy = {};
9 ChromeUtils.defineESModuleGetters(lazy, {
10   UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
11   UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs",
12   UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
13 });
15 /**
16  * The one-off search buttons in the urlbar.
17  */
18 export class UrlbarSearchOneOffs extends SearchOneOffs {
19   /**
20    * Constructor.
21    *
22    * @param {UrlbarView} view
23    *   The parent UrlbarView.
24    */
25   constructor(view) {
26     super(view.panel.querySelector(".search-one-offs"));
27     this.view = view;
28     this.input = view.input;
29     lazy.UrlbarPrefs.addObserver(this);
30     // Override the SearchOneOffs.sys.mjs value for the Address Bar.
31     this.disableOneOffsHorizontalKeyNavigation = true;
32     this._webEngines = [];
33     this.addEventListener("rebuild", this);
34   }
36   /**
37    * Returns the local search mode one-off buttons.
38    *
39    * @returns {Array}
40    *   The local one-off buttons.
41    */
42   get localButtons() {
43     return this.getSelectableButtons(false).filter(b => b.source);
44   }
46   /**
47    * Invoked when Web provided search engines list changes.
48    *
49    * @param {Array} engines Array of Web provided search engines. Each engine
50    *        is defined as  { icon, name, tooltip, uri }.
51    */
52   updateWebEngines(engines) {
53     this._webEngines = engines;
54     this.invalidateCache();
55     if (this.view.isOpen) {
56       this._rebuild();
57     }
58   }
60   /**
61    * Enables (shows) or disables (hides) the one-offs.
62    *
63    * @param {boolean} enable
64    *   True to enable, false to disable.
65    */
66   enable(enable) {
67     if (lazy.UrlbarPrefs.getScotchBonnetPref("scotchBonnet.disableOneOffs")) {
68       enable = false;
69     }
70     if (enable) {
71       this.telemetryOrigin = "urlbar";
72       this.style.display = "";
73       this.textbox = this.view.input.inputField;
74       if (this.view.isOpen) {
75         this._rebuild();
76       }
77       this.view.controller.addQueryListener(this);
78     } else {
79       this.telemetryOrigin = null;
80       this.style.display = "none";
81       this.textbox = null;
82       this.view.controller.removeQueryListener(this);
83     }
84   }
86   /**
87    * Query listener method.  Delegates to the superclass.
88    */
89   onViewOpen() {
90     this._on_popupshowing();
91   }
93   #queryContext;
94   onQueryStarted(queryContext) {
95     this.#queryContext = queryContext;
96   }
98   onQueryFinished(queryContext) {
99     this.#buildQuickSuggestOptIn(queryContext);
101     if (
102       this.#quickSuggestOptInContainer &&
103       !this.#quickSuggestOptInContainer.hidden
104     ) {
105       this.#quickSuggestOptInProvider._recordGlean("impression");
106     }
107   }
109   #quickSuggestOptInContainer;
110   get #quickSuggestOptInProvider() {
111     return lazy.UrlbarProvidersManager.getProvider(
112       "UrlbarProviderQuickSuggestContextualOptIn"
113     );
114   }
116   #buildQuickSuggestOptIn(queryContext) {
117     let provider = this.#quickSuggestOptInProvider;
118     if (
119       !provider._shouldDisplayContextualOptIn(queryContext) ||
120       provider.isActive(queryContext)
121     ) {
122       if (this.#quickSuggestOptInContainer) {
123         this.#quickSuggestOptInContainer.hidden = true;
124       }
125       return;
126     }
128     if (this.#quickSuggestOptInContainer) {
129       this.#quickSuggestOptInContainer.hidden = false;
130       this.#udpateQuickSuggestOptInCopy();
131       return;
132     }
134     // The following is basically a copy of what UrlbarView generates for
135     // ProviderQuickSuggestContextualOptIn's view template. Gross but good
136     // enough for the experiment. Ultimately, if we decide to keep this UI at
137     // the bottom, and when we replace the one-off buttons footer with a better
138     // UI (e.g. search button), this can become a proper result again.
139     let parser = new DOMParser();
140     let doc = parser.parseFromString(
141       `
142 <div xmlns="http://www.w3.org/1999/xhtml" class="urlbarView-quickSuggestContextualOptIn-one-off-container">
143   <div class="urlbarView-row" role="presentation" type="dynamic">
144     <span class="urlbarView-row-inner">
145       <span class="urlbarView-dynamic-quickSuggestContextualOptIn-no-wrap urlbarView-no-wrap">
146         <img class="urlbarView-dynamic-quickSuggestContextualOptIn-icon urlbarView-favicon" src="chrome://branding/content/icon32.png" />
147         <span class="urlbarView-dynamic-quickSuggestContextualOptIn-text-container">
148           <strong class="urlbarView-dynamic-quickSuggestContextualOptIn-title"></strong>
149           <span class="urlbarView-dynamic-quickSuggestContextualOptIn-description">
150             <a class="urlbarView-dynamic-quickSuggestContextualOptIn-learn_more" data-l10n-name="learn-more-link" selectable="" name="learn_more" id="urlbarView-footer-quickSuggestContextualOptIn-learn_more"></a>
151           </span>
152         </span>
153       </span>
154     </span>
155     <span primary="" name="allow" class="urlbarView-button urlbarView-button-0" role="button" data-l10n-id="urlbar-firefox-suggest-contextual-opt-in-allow" id="urlbarView-footer-quickSuggestContextualOptIn-allow"></span>
156     <span name="dismiss" class="urlbarView-button urlbarView-button-1" role="button" data-l10n-id="urlbar-firefox-suggest-contextual-opt-in-dismiss" id="urlbarView-footer-quickSuggestContextualOptIn-dismiss"></span>
157   </div>
158 </div>
159       `,
160       "text/html"
161     );
162     this.#quickSuggestOptInContainer = this.document.importNode(
163       doc.body.firstElementChild,
164       true
165     );
167     // DOMParser normalizes attribute names to lowercase, so need to set this one after the fact.
168     this.#quickSuggestOptInContainer.firstElementChild.setAttribute(
169       "dynamicType",
170       "quickSuggestContextualOptIn"
171     );
173     this.container.appendChild(this.#quickSuggestOptInContainer);
174     this.#quickSuggestOptInContainer.addEventListener("keydown", this);
175     this.#udpateQuickSuggestOptInCopy();
176   }
178   #udpateQuickSuggestOptInCopy() {
179     let alternativeCopy = lazy.UrlbarPrefs.get(
180       "quicksuggest.contextualOptIn.sayHello"
181     );
182     this.document.l10n.setAttributes(
183       this.#quickSuggestOptInContainer.querySelector(
184         ".urlbarView-dynamic-quickSuggestContextualOptIn-title"
185       ),
186       alternativeCopy
187         ? "urlbar-firefox-suggest-contextual-opt-in-title-2"
188         : "urlbar-firefox-suggest-contextual-opt-in-title-1"
189     );
190     this.document.l10n.setAttributes(
191       this.#quickSuggestOptInContainer.querySelector(
192         ".urlbarView-dynamic-quickSuggestContextualOptIn-description"
193       ),
194       alternativeCopy
195         ? "urlbar-firefox-suggest-contextual-opt-in-description-2"
196         : "urlbar-firefox-suggest-contextual-opt-in-description-1"
197     );
198   }
200   #isQuickSuggestOptInElement(element) {
201     return (
202       this.#quickSuggestOptInContainer &&
203       element?.compareDocumentPosition(this.#quickSuggestOptInContainer) &
204         Node.DOCUMENT_POSITION_CONTAINS
205     );
206   }
208   #handleQuickSuggestOptInCommand(element) {
209     if (this.#isQuickSuggestOptInElement(element)) {
210       this.#quickSuggestOptInProvider._handleCommand(
211         element,
212         this.view.controller,
213         null,
214         this.#quickSuggestOptInContainer
215       );
216       return true;
217     }
218     return false;
219   }
221   /**
222    * Query listener method.  Delegates to the superclass.
223    */
224   onViewClose() {
225     this._on_popuphidden();
226   }
228   /**
229    * @returns {boolean}
230    *   True if the one-offs are connected to a view.
231    */
232   get hasView() {
233     // Return true if the one-offs are enabled.  We set style.display = "none"
234     // when they're disabled, and we hide the container when there are no
235     // engines to show.
236     return this.style.display != "none" && !this.container.hidden;
237   }
239   /**
240    * @returns {boolean}
241    *   True if the view is open.
242    */
243   get isViewOpen() {
244     return this.view.isOpen;
245   }
247   /**
248    * The selected one-off including the search-settings button.
249    *
250    * @param {DOMElement|null} button
251    *   The selected one-off button. Null if no one-off is selected.
252    */
253   set selectedButton(button) {
254     if (this.selectedButton == button) {
255       return;
256     }
258     if (this.#isQuickSuggestOptInElement(button)) {
259       this.#quickSuggestOptInProvider.onBeforeSelection(null, button);
260     }
262     super.selectedButton = button;
264     let expectedSearchMode;
265     if (button && button != this.view.oneOffSearchButtons.settingsButton) {
266       expectedSearchMode = {
267         engineName: button.engine?.name,
268         source: button.source,
269         entry: "oneoff",
270       };
271       this.input.searchMode = expectedSearchMode;
272     } else if (this.input.searchMode) {
273       // Restore the previous state. We do this only if we're in search mode, as
274       // an optimization in the common case of cycling through normal results.
275       this.input.restoreSearchModeState();
276     }
277   }
279   get selectedButton() {
280     return super.selectedButton;
281   }
283   getSelectableButtons(aIncludeNonEngineButtons) {
284     const buttons = super.getSelectableButtons(aIncludeNonEngineButtons);
286     if (
287       aIncludeNonEngineButtons &&
288       this.#quickSuggestOptInContainer &&
289       !this.#quickSuggestOptInContainer.hidden
290     ) {
291       buttons.push(
292         ...this.#quickSuggestOptInContainer.querySelectorAll(
293           "[role=button], [selectable]"
294         )
295       );
296     }
298     return buttons;
299   }
301   /**
302    * The selected index in the view or -1 if there is no selection.
303    *
304    * @returns {number}
305    */
306   get selectedViewIndex() {
307     return this.view.selectedRowIndex;
308   }
309   set selectedViewIndex(val) {
310     this.view.selectedRowIndex = val;
311   }
313   /**
314    * Closes the view.
315    */
316   closeView() {
317     if (this.view) {
318       this.view.close();
319     }
320   }
322   /**
323    * Called when a one-off is clicked.
324    *
325    * @param {event} event
326    *   The event that triggered the pick.
327    * @param {object} searchMode
328    *   Used by UrlbarInput.setSearchMode to enter search mode. See setSearchMode
329    *   documentation for details.
330    */
331   handleSearchCommand(event, searchMode) {
332     // The settings button and adding engines are a special case and executed
333     // immediately.
334     if (
335       this.selectedButton == this.view.oneOffSearchButtons.settingsButton ||
336       this.selectedButton.classList.contains(
337         "searchbar-engine-one-off-add-engine"
338       )
339     ) {
340       this.input.controller.engagementEvent.discard();
341       this.selectedButton.doCommand();
342       this.selectedButton = null;
343       return;
344     }
346     if (this.#handleQuickSuggestOptInCommand(this.selectedButton)) {
347       this.input.controller.engagementEvent.discard();
348       this.selectedButton = null;
349       return;
350     }
352     // We allow autofill in local but not remote search modes.
353     let startQueryParams = {
354       allowAutofill:
355         !searchMode.engineName &&
356         searchMode.source != lazy.UrlbarUtils.RESULT_SOURCE.SEARCH,
357       event,
358     };
360     let userTypedSearchString =
361       this.input.value && this.input.getAttribute("pageproxystate") != "valid";
362     let engine = Services.search.getEngineByName(searchMode.engineName);
364     let { where, params } = this._whereToOpen(event);
366     // Some key combinations should execute a search immediately. We handle
367     // these here, outside the switch statement.
368     if (
369       userTypedSearchString &&
370       engine &&
371       (event.shiftKey || where != "current")
372     ) {
373       this.input.handleNavigation({
374         event,
375         oneOffParams: {
376           openWhere: where,
377           openParams: params,
378           engine: this.selectedButton.engine,
379         },
380       });
381       this.selectedButton = null;
382       return;
383     }
385     // Handle opening search mode in either the current tab or in a new tab.
386     switch (where) {
387       case "current": {
388         this.input.searchMode = searchMode;
389         this.input.startQuery(startQueryParams);
390         break;
391       }
392       case "tab": {
393         // We set this.selectedButton when switching tabs. If we entered search
394         // mode preview here, it could be cleared when this.selectedButton calls
395         // setSearchMode.
396         searchMode.isPreview = false;
398         let newTab = this.input.window.gBrowser.addTrustedTab("about:newtab");
399         this.input.setSearchMode(searchMode, newTab.linkedBrowser);
400         if (userTypedSearchString) {
401           // Set the search string for the new tab.
402           newTab.linkedBrowser.userTypedValue = this.input.value;
403         }
404         if (!params?.inBackground) {
405           this.input.window.gBrowser.selectedTab = newTab;
406           newTab.ownerGlobal.gURLBar.startQuery(startQueryParams);
407         }
408         break;
409       }
410       default: {
411         this.input.searchMode = searchMode;
412         this.input.startQuery(startQueryParams);
413         this.input.select();
414         break;
415       }
416     }
418     this.selectedButton = null;
419   }
421   /**
422    * Sets the tooltip for a one-off button with an engine.  This should set
423    * either the `tooltiptext` attribute or the relevant l10n ID.
424    *
425    * @param {element} button
426    *   The one-off button.
427    */
428   setTooltipForEngineButton(button) {
429     let aliases = button.engine.aliases;
430     if (!aliases.length) {
431       super.setTooltipForEngineButton(button);
432       return;
433     }
434     this.document.l10n.setAttributes(
435       button,
436       "search-one-offs-engine-with-alias",
437       {
438         engineName: button.engine.name,
439         alias: aliases[0],
440       }
441     );
442   }
444   /**
445    * Overrides the willHide method in the superclass to account for the local
446    * search mode buttons.
447    *
448    * @returns {boolean}
449    *   True if we will hide the one-offs when they are requested.
450    */
451   async willHide() {
452     // We need to call super.willHide() even when we return false below because
453     // it has the necessary side effect of creating this._engineInfo.
454     let superWillHide = await super.willHide();
455     if (
456       lazy.UrlbarUtils.LOCAL_SEARCH_MODES.some(m =>
457         lazy.UrlbarPrefs.get(m.pref)
458       )
459     ) {
460       return false;
461     }
462     return superWillHide;
463   }
465   /**
466    * Called when a pref tracked by UrlbarPrefs changes.
467    *
468    * @param {string} changedPref
469    *   The name of the pref, relative to `browser.urlbar.` if the pref is in
470    *   that branch.
471    */
472   onPrefChanged(changedPref) {
473     // Invalidate the engine cache when the local-one-offs-related prefs change
474     // so that the one-offs rebuild themselves the next time the view opens.
475     if (
476       [...lazy.UrlbarUtils.LOCAL_SEARCH_MODES.map(m => m.pref)].includes(
477         changedPref
478       )
479     ) {
480       this.invalidateCache();
481     }
482   }
484   /**
485    * Overrides _getAddEngines to return engines that can be added.
486    *
487    * @returns {Array} engines
488    */
489   _getAddEngines() {
490     return this._webEngines;
491   }
493   /**
494    * Overrides _rebuildEngineList to add the local one-offs.
495    *
496    * @param {Array} engines
497    *    The search engines to add.
498    * @param {Array} addEngines
499    *        The engines that can be added.
500    */
501   async _rebuildEngineList(engines, addEngines) {
502     await super._rebuildEngineList(engines, addEngines);
504     for (let { source, pref, restrict } of lazy.UrlbarUtils
505       .LOCAL_SEARCH_MODES) {
506       if (!lazy.UrlbarPrefs.get(pref)) {
507         continue;
508       }
509       let name = lazy.UrlbarUtils.getResultSourceName(source);
510       let button = this.document.createXULElement("button");
511       button.id = `urlbar-engine-one-off-item-${name}`;
512       button.setAttribute("class", "searchbar-engine-one-off-item");
513       button.setAttribute("tabindex", "-1");
514       this.document.l10n.setAttributes(button, `search-one-offs-${name}`, {
515         restrict,
516       });
517       button.source = source;
518       this.buttons.appendChild(button);
519     }
520   }
522   /**
523    * Overrides the superclass's click listener to handle clicks on local
524    * one-offs in addition to engine one-offs.
525    *
526    * @param {event} event
527    *   The click event.
528    */
529   _on_click(event) {
530     // Ignore right clicks.
531     if (event.button == 2) {
532       return;
533     }
535     let button = event.originalTarget;
537     if (this.#handleQuickSuggestOptInCommand(button)) {
538       return;
539     }
541     if (!button.engine && !button.source) {
542       return;
543     }
545     this.selectedButton = button;
546     this.handleSearchCommand(event, {
547       engineName: button.engine?.name,
548       source: button.source,
549       entry: "oneoff",
550     });
551   }
553   /**
554    * Overrides the superclass's contextmenu listener to handle the context menu.
555    *
556    * @param {event} event
557    *   The contextmenu event.
558    */
559   _on_contextmenu(event) {
560     // Prevent the context menu from appearing.
561     event.preventDefault();
562   }
564   _on_rebuild() {
565     if (this.#queryContext) {
566       this.#buildQuickSuggestOptIn(this.#queryContext);
567     }
568   }