SearchModeSwitcher.sys.mjs (17308B)
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/. */ 4 5 const lazy = {}; 6 7 ChromeUtils.defineESModuleGetters(lazy, { 8 OpenSearchManager: 9 "moz-src:///browser/components/search/OpenSearchManager.sys.mjs", 10 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", 11 SearchUIUtils: "moz-src:///browser/components/search/SearchUIUtils.sys.mjs", 12 UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs", 13 UrlbarSearchUtils: 14 "moz-src:///browser/components/urlbar/UrlbarSearchUtils.sys.mjs", 15 UrlbarUtils: "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs", 16 }); 17 18 ChromeUtils.defineLazyGetter(lazy, "SearchModeSwitcherL10n", () => { 19 return new Localization(["browser/browser.ftl"]); 20 }); 21 ChromeUtils.defineLazyGetter(lazy, "searchModeNewBadge", () => { 22 return lazy.SearchModeSwitcherL10n.formatValue("urlbar-searchmode-new"); 23 }); 24 25 // The maximum number of openSearch engines available to install 26 // to display. 27 const MAX_OPENSEARCH_ENGINES = 3; 28 29 // Default icon used for engines that do not have icons loaded. 30 const DEFAULT_ENGINE_ICON = 31 "chrome://browser/skin/search-engine-placeholder@2x.png"; 32 33 /** 34 * Implements the SearchModeSwitcher in the urlbar. 35 */ 36 export class SearchModeSwitcher { 37 static DEFAULT_ICON = lazy.UrlbarUtils.ICON.SEARCH_GLASS; 38 static DEFAULT_ICON_KEYWORD_DISABLED = lazy.UrlbarUtils.ICON.GLOBE; 39 #popup; 40 #input; 41 #toolbarbutton; 42 43 /** 44 * @param {UrlbarInput} input 45 */ 46 constructor(input) { 47 this.#input = input; 48 49 this.QueryInterface = ChromeUtils.generateQI([ 50 "nsIObserver", 51 "nsISupportsWeakReference", 52 ]); 53 54 lazy.UrlbarPrefs.addObserver(this); 55 56 this.#popup = /** @type {XULPopupElement} */ ( 57 input.querySelector(".searchmode-switcher-popup") 58 ); 59 60 this.#toolbarbutton = input.querySelector(".searchmode-switcher"); 61 62 if (lazy.UrlbarPrefs.get("scotchBonnet.enableOverride")) { 63 this.#enableObservers(); 64 } 65 } 66 67 async #onPopupShowing() { 68 await this.#buildSearchModeList(); 69 this.#input.view.close({ showFocusBorder: false }); 70 71 if (this.#input.sapName == "urlbar") { 72 Glean.urlbarUnifiedsearchbutton.opened.add(1); 73 } 74 } 75 76 /** 77 * Close the SearchSwitcher popup. 78 */ 79 closePanel() { 80 this.#popup.hidePopup(); 81 } 82 83 #openPreferences(event) { 84 if ( 85 (event.type == "click" && event.button != 0) || 86 (event.type == "keypress" && 87 event.charCode != KeyEvent.DOM_VK_SPACE && 88 event.keyCode != KeyEvent.DOM_VK_RETURN) 89 ) { 90 return; // Left click, space or enter only 91 } 92 93 event.preventDefault(); 94 event.stopPropagation(); 95 96 this.#input.window.openPreferences("paneSearch"); 97 this.#popup.hidePopup(); 98 99 if (this.#input.sapName == "urlbar") { 100 Glean.urlbarUnifiedsearchbutton.picked.settings.add(1); 101 } 102 } 103 104 /** 105 * Exit the engine specific searchMode. 106 * 107 * @param {Event} event 108 * The event that triggered the searchMode exit. 109 */ 110 exitSearchMode(event) { 111 event.preventDefault(); 112 this.#input.searchMode = null; 113 // Update the result by the default engine. 114 this.#input.startQuery(); 115 } 116 117 /** 118 * Called when the value of the searchMode attribute on UrlbarInput is changed. 119 */ 120 onSearchModeChanged() { 121 if (!this.#input.window || this.#input.window.closed) { 122 return; 123 } 124 125 if (lazy.UrlbarPrefs.get("scotchBonnet.enableOverride")) { 126 this.updateSearchIcon(); 127 128 if ( 129 this.#input.searchMode?.engineName == "Perplexity" && 130 !lazy.UrlbarPrefs.get("perplexity.hasBeenInSearchMode") 131 ) { 132 lazy.UrlbarPrefs.set("perplexity.hasBeenInSearchMode", true); 133 } 134 } 135 } 136 137 handleEvent(event) { 138 if (event.type == "focus") { 139 this.#input.setUnifiedSearchButtonAvailability(true); 140 return; 141 } 142 if (event.type == "popupshowing") { 143 this.#toolbarbutton.setAttribute("aria-expanded", "true"); 144 this.#onPopupShowing(); 145 return; 146 } 147 if (event.type == "popuphiding") { 148 // This moves the focus to the urlbar when the popup is closed. 149 this.#input.document.commandDispatcher.focusedElement = 150 this.#input.inputField; 151 this.#toolbarbutton.setAttribute("aria-expanded", "false"); 152 return; 153 } 154 if (event.type == "keydown") { 155 if (this.#input.view.isOpen) { 156 // The urlbar view is open, which means the unified search button got 157 // focus by tab key from urlbar. 158 switch (event.keyCode) { 159 case KeyEvent.DOM_VK_TAB: { 160 // Move the focus to urlbar view to make cyclable. 161 this.#input.focus(); 162 this.#input.view.selectBy(1, { 163 reverse: event.shiftKey, 164 userPressedTab: true, 165 }); 166 event.preventDefault(); 167 return; 168 } 169 case KeyEvent.DOM_VK_ESCAPE: { 170 this.#input.view.close(); 171 this.#input.focus(); 172 event.preventDefault(); 173 return; 174 } 175 } 176 } 177 178 // Manually open the popup on down. 179 if (event.keyCode == KeyEvent.DOM_VK_DOWN) { 180 this.#popup.openPopup(null, { 181 triggerEvent: event, 182 }); 183 } 184 185 return; 186 } 187 188 let action = event.currentTarget.dataset.action ?? event.type; 189 190 switch (action) { 191 case "exitsearchmode": { 192 this.exitSearchMode(event); 193 break; 194 } 195 case "openpreferences": { 196 this.#openPreferences(event); 197 break; 198 } 199 } 200 } 201 202 observe(_subject, topic, data) { 203 if ( 204 !this.#input.window || 205 this.#input.window.closed || 206 // TODO bug 2005783 stop observing when input is disconnected. 207 !this.#input.isConnected 208 ) { 209 return; 210 } 211 212 switch (topic) { 213 case "browser-search-engine-modified": { 214 if ( 215 data === "engine-default" || 216 data === "engine-default-private" || 217 data === "engine-icon-changed" 218 ) { 219 this.updateSearchIcon(); 220 } 221 break; 222 } 223 } 224 } 225 226 /** 227 * Called when a urlbar pref changes. 228 * 229 * @param {string} pref 230 * The name of the pref relative to `browser.urlbar`. 231 */ 232 onPrefChanged(pref) { 233 if (!this.#input.window || this.#input.window.closed) { 234 return; 235 } 236 237 switch (pref) { 238 case "scotchBonnet.enableOverride": { 239 if (lazy.UrlbarPrefs.get("scotchBonnet.enableOverride")) { 240 this.#enableObservers(); 241 this.updateSearchIcon(); 242 } else { 243 this.#disableObservers(); 244 } 245 break; 246 } 247 case "keyword.enabled": { 248 if (lazy.UrlbarPrefs.get("scotchBonnet.enableOverride")) { 249 this.updateSearchIcon(); 250 } 251 break; 252 } 253 } 254 } 255 256 /** 257 * If the user presses Option+Up or Option+Down we open the engine list. 258 * 259 * @param {KeyboardEvent} event 260 * The key down event. 261 */ 262 handleKeyDown(event) { 263 if ( 264 (event.keyCode == KeyEvent.DOM_VK_UP || 265 event.keyCode == KeyEvent.DOM_VK_DOWN) && 266 event.altKey 267 ) { 268 this.#input.controller.focusOnUnifiedSearchButton(); 269 this.#popup.openPopup(null, { 270 triggerEvent: event, 271 }); 272 event.stopPropagation(); 273 event.preventDefault(); 274 return true; 275 } 276 return false; 277 } 278 279 async updateSearchIcon() { 280 let searchMode = this.#input.searchMode; 281 282 try { 283 await lazy.UrlbarSearchUtils.init(); 284 } catch { 285 console.error("Search service failed to init"); 286 } 287 288 let { label, icon } = await this.#getDisplayedEngineDetails( 289 this.#input.searchMode 290 ); 291 292 if (searchMode?.source != this.#input.searchMode?.source) { 293 return; 294 } 295 296 const inSearchMode = this.#input.searchMode; 297 if (!lazy.UrlbarPrefs.get("unifiedSearchButton.always")) { 298 const keywordEnabled = lazy.UrlbarPrefs.get("keyword.enabled"); 299 if ( 300 this.#input.sapName != "searchbar" && 301 !keywordEnabled && 302 !inSearchMode 303 ) { 304 icon = SearchModeSwitcher.DEFAULT_ICON_KEYWORD_DISABLED; 305 } 306 } else if (!inSearchMode) { 307 // Use default icon set in CSS. 308 icon = null; 309 } 310 311 let iconUrl = icon ? `url(${icon})` : null; 312 // Bug 1984069 - This uses an intermediate variable to keep documentation 313 // generation happy. 314 let element = /** @type {HTMLImageElement} */ ( 315 this.#input.querySelector(".searchmode-switcher-icon") 316 ); 317 element.style.listStyleImage = iconUrl; 318 319 if (label) { 320 this.#input.document.l10n.setAttributes( 321 this.#toolbarbutton, 322 "urlbar-searchmode-button2", 323 { engine: label } 324 ); 325 } else { 326 this.#input.document.l10n.setAttributes( 327 this.#toolbarbutton, 328 "urlbar-searchmode-button-no-engine" 329 ); 330 } 331 332 let labelEl = this.#input.querySelector(".searchmode-switcher-title"); 333 334 if (!inSearchMode) { 335 labelEl.replaceChildren(); 336 } else { 337 labelEl.textContent = label; 338 } 339 340 if ( 341 !lazy.UrlbarPrefs.get("keyword.enabled") && 342 this.#input.sapName != "searchbar" 343 ) { 344 this.#input.document.l10n.setAttributes( 345 this.#toolbarbutton, 346 "urlbar-searchmode-no-keyword" 347 ); 348 } 349 } 350 351 async #getSearchModeLabel(source) { 352 let mode = lazy.UrlbarUtils.LOCAL_SEARCH_MODES.find( 353 m => m.source == source 354 ); 355 let [str] = await lazy.SearchModeSwitcherL10n.formatMessages([ 356 { id: mode.uiLabel }, 357 ]); 358 return str.attributes[0].value; 359 } 360 361 async #getDisplayedEngineDetails(searchMode = null) { 362 if (!Services.search.hasSuccessfullyInitialized) { 363 return { label: null, icon: SearchModeSwitcher.DEFAULT_ICON }; 364 } 365 366 if (!searchMode || searchMode.engineName) { 367 let engine = searchMode 368 ? lazy.UrlbarSearchUtils.getEngineByName(searchMode.engineName) 369 : lazy.UrlbarSearchUtils.getDefaultEngine( 370 lazy.PrivateBrowsingUtils.isWindowPrivate(this.#input.window) 371 ); 372 let icon = (await engine.getIconURL()) ?? SearchModeSwitcher.DEFAULT_ICON; 373 return { label: engine.name, icon }; 374 } 375 376 let mode = lazy.UrlbarUtils.LOCAL_SEARCH_MODES.find( 377 m => m.source == searchMode.source 378 ); 379 return { 380 label: await this.#getSearchModeLabel(searchMode.source), 381 icon: mode.icon, 382 }; 383 } 384 385 /** 386 * Builds the popup and dispatches a rebuild event on the popup when finished. 387 */ 388 async #buildSearchModeList() { 389 // Remove all menuitems added. 390 for (let item of this.#popup.querySelectorAll( 391 ".searchmode-switcher-addEngine, .searchmode-switcher-installed, .searchmode-switcher-local" 392 )) { 393 item.remove(); 394 } 395 396 let browser = this.#input.window.gBrowser; 397 let separator = this.#popup.querySelector( 398 ".searchmode-switcher-popup-footer-separator" 399 ); 400 401 let openSearchEngines = lazy.OpenSearchManager.getEngines( 402 browser.selectedBrowser 403 ); 404 openSearchEngines = openSearchEngines.slice(0, MAX_OPENSEARCH_ENGINES); 405 406 for (let engine of openSearchEngines) { 407 let menuitem = this.#createButton(engine.title, engine.icon); 408 menuitem.classList.add("searchmode-switcher-addEngine"); 409 menuitem.addEventListener("command", e => { 410 this.#installOpenSearchEngine(e, engine); 411 }); 412 this.#popup.insertBefore(menuitem, separator); 413 } 414 415 // Add engines installed. 416 let engines = []; 417 try { 418 engines = await Services.search.getVisibleEngines(); 419 } catch { 420 console.error("Failed to fetch engines"); 421 } 422 423 for (let engine of engines) { 424 if (engine.hideOneOffButton) { 425 continue; 426 } 427 let icon = await engine.getIconURL(); 428 let menuitem = this.#createButton(engine.name, icon); 429 menuitem.classList.add("searchmode-switcher-installed"); 430 menuitem.setAttribute("label", engine.name); 431 432 if (engine.isNew() && engine.isAppProvided) { 433 menuitem.setAttribute("badge", await lazy.searchModeNewBadge); 434 menuitem.classList.add("badge-new"); 435 } 436 437 menuitem.addEventListener( 438 "command", 439 /** @param {KeyboardEvent} e */ e => { 440 this.search({ engine, openEngineHomePage: e.shiftKey }); 441 } 442 ); 443 this.#popup.insertBefore(menuitem, separator); 444 } 445 446 await this.#buildLocalSearchModeList(separator); 447 448 this.#popup.dispatchEvent(new Event("rebuild")); 449 } 450 451 /** 452 * Adds local options to the popup. 453 * 454 * @param {Element} separator 455 */ 456 async #buildLocalSearchModeList(separator) { 457 if (this.#input.sapName != "urlbar") { 458 return; 459 } 460 461 for (let { source, pref, restrict } of lazy.UrlbarUtils 462 .LOCAL_SEARCH_MODES) { 463 if (!lazy.UrlbarPrefs.get(pref)) { 464 continue; 465 } 466 if ( 467 source === lazy.UrlbarUtils.RESULT_SOURCE.HISTORY && 468 lazy.PrivateBrowsingUtils.permanentPrivateBrowsing 469 ) { 470 // Do not show the search history option in PBM. tor-browser#43864. 471 // Although, it can still be triggered with "^" restrict keyword or 472 // through an app menu item. See also mozilla bug 1980928. 473 continue; 474 } 475 let name = lazy.UrlbarUtils.getResultSourceName(source); 476 let { icon } = await this.#getDisplayedEngineDetails({ 477 source, 478 pref, 479 restrict, 480 }); 481 let menuitem = this.#createButton(name, icon); 482 menuitem.id = `search-button-${name}`; 483 menuitem.classList.add("searchmode-switcher-local"); 484 menuitem.addEventListener("command", () => { 485 this.search({ restrict }); 486 }); 487 488 this.#input.document.l10n.setAttributes( 489 menuitem, 490 `urlbar-searchmode-${name}`, 491 { 492 restrict, 493 } 494 ); 495 496 this.#popup.insertBefore(menuitem, separator); 497 } 498 } 499 500 search({ engine = null, restrict = null, openEngineHomePage = false } = {}) { 501 let search = ""; 502 /** @type {Parameters<UrlbarInput["search"]>[1]} */ 503 let opts = null; 504 if (engine) { 505 search = this.#input.value; 506 opts = { 507 searchEngine: engine, 508 searchModeEntry: "searchbutton", 509 }; 510 } else if (restrict) { 511 search = restrict + " " + this.#input.value; 512 opts = { searchModeEntry: "searchbutton" }; 513 } 514 515 if (openEngineHomePage) { 516 this.#input.openEngineHomePage(search, { 517 searchEngine: opts.searchEngine, 518 }); 519 } else { 520 this.#input.search(search, opts); 521 } 522 523 this.#popup.hidePopup(); 524 525 if (engine) { 526 if (this.#input.sapName == "urlbar") { 527 // TODO do we really need to distinguish here? 528 Glean.urlbarUnifiedsearchbutton.picked[ 529 engine.isConfigEngine ? "builtin_search" : "addon_search" 530 ].add(1); 531 } 532 } else if (restrict) { 533 if (this.#input.sapName == "urlbar") { 534 Glean.urlbarUnifiedsearchbutton.picked.local_search.add(1); 535 } 536 } else { 537 console.warn( 538 `Unexpected search: ${JSON.stringify({ engine, restrict, openEngineHomePage })}` 539 ); 540 } 541 } 542 543 #enableObservers() { 544 Services.obs.addObserver(this, "browser-search-engine-modified", true); 545 546 this.#toolbarbutton.addEventListener("focus", this); 547 this.#toolbarbutton.addEventListener("keydown", this); 548 549 this.#popup.addEventListener("popupshowing", this); 550 this.#popup.addEventListener("popuphiding", this); 551 552 let closebutton = this.#input.querySelector(".searchmode-switcher-close"); 553 closebutton.addEventListener("command", this); 554 555 let prefsbutton = this.#input.querySelector( 556 ".searchmode-switcher-popup-search-settings-button" 557 ); 558 prefsbutton.addEventListener("command", this); 559 } 560 561 #disableObservers() { 562 Services.obs.removeObserver(this, "browser-search-engine-modified"); 563 564 this.#toolbarbutton.removeEventListener("focus", this); 565 this.#toolbarbutton.removeEventListener("keydown", this); 566 567 this.#popup.removeEventListener("popupshowing", this); 568 this.#popup.removeEventListener("popuphiding", this); 569 570 let closebutton = this.#input.querySelector(".searchmode-switcher-close"); 571 closebutton.removeEventListener("command", this); 572 573 let prefsbutton = this.#input.querySelector( 574 ".searchmode-switcher-popup-search-settings-button" 575 ); 576 prefsbutton.removeEventListener("command", this); 577 } 578 579 #createButton(label, icon) { 580 let menuitem = this.#input.window.document.createXULElement("menuitem"); 581 menuitem.setAttribute("label", label); 582 menuitem.setAttribute("class", "menuitem-iconic"); 583 menuitem.setAttribute("image", icon ?? DEFAULT_ENGINE_ICON); 584 return menuitem; 585 } 586 587 async #installOpenSearchEngine(e, engine) { 588 let topic = "browser-search-engine-modified"; 589 590 let observer = engineObj => { 591 Services.obs.removeObserver(observer, topic); 592 let eng = Services.search.getEngineByName(engineObj.wrappedJSObject.name); 593 this.search({ 594 engine: eng, 595 openEngineHomePage: e.shiftKey, 596 }); 597 }; 598 Services.obs.addObserver(observer, topic); 599 600 await lazy.SearchUIUtils.addOpenSearchEngine( 601 engine.uri, 602 engine.icon, 603 this.#input.window.gBrowser.selectedBrowser.browsingContext 604 ); 605 } 606 }