UrlbarProviderSearchTips.sys.mjs (15922B)
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 /** 6 * This module exports a provider that might show a tip when the user opens 7 * the newtab or starts an organic search with their default search engine. 8 */ 9 10 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 11 12 import { 13 UrlbarProvider, 14 UrlbarUtils, 15 } from "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs"; 16 17 const lazy = {}; 18 19 ChromeUtils.defineESModuleGetters(lazy, { 20 AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.sys.mjs", 21 DefaultBrowserCheck: 22 "moz-src:///browser/components/DefaultBrowserCheck.sys.mjs", 23 LaterRun: "resource:///modules/LaterRun.sys.mjs", 24 SearchStaticData: 25 "moz-src:///toolkit/components/search/SearchStaticData.sys.mjs", 26 UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs", 27 UrlbarProviderTopSites: 28 "moz-src:///browser/components/urlbar/UrlbarProviderTopSites.sys.mjs", 29 UrlbarResult: "moz-src:///browser/components/urlbar/UrlbarResult.sys.mjs", 30 setTimeout: "resource://gre/modules/Timer.sys.mjs", 31 }); 32 33 XPCOMUtils.defineLazyPreferenceGetter( 34 lazy, 35 "cfrFeaturesUserPref", 36 "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features", 37 true 38 ); 39 40 // The possible tips to show. 41 const TIPS = { 42 NONE: "", 43 ONBOARD: "searchTip_onboard", 44 REDIRECT: "searchTip_redirect", 45 }; 46 47 ChromeUtils.defineLazyGetter(lazy, "SUPPORTED_ENGINES", () => { 48 // Converts a list of Google domains to a pipe separated string of escaped TLDs. 49 // [www.google.com, ..., www.google.co.uk] => "com|...|co\.uk" 50 const googleTLDs = lazy.SearchStaticData.getAlternateDomains("www.google.com") 51 .map(str => str.slice("www.google.".length).replaceAll(".", "\\.")) 52 .join("|"); 53 54 // This maps engine names to regexes matching their homepages. We show the 55 // redirect tip on these pages. 56 return new Map([ 57 ["Bing", { domainPath: /^www\.bing\.com\/$/ }], 58 [ 59 "DuckDuckGo", 60 { 61 domainPath: /^(start\.)?duckduckgo\.com\/$/, 62 prohibitedSearchParams: ["q"], 63 }, 64 ], 65 [ 66 "Google", 67 { 68 domainPath: new RegExp(`^www\.google\.(?:${googleTLDs})\/(webhp)?$`), 69 }, 70 ], 71 ]); 72 }); 73 74 // The maximum number of times we'll show a tip across all sessions. 75 const MAX_SHOWN_COUNT = 4; 76 77 // Amount of time to wait before showing a tip after selecting a tab or 78 // navigating to a page where we should show a tip. 79 const SHOW_TIP_DELAY_MS = 200; 80 81 // We won't show a tip if the browser has been updated in the past 82 // LAST_UPDATE_THRESHOLD_HOURS. 83 const LAST_UPDATE_THRESHOLD_HOURS = 24; 84 85 /** 86 * A provider that sometimes returns a tip result when the user visits the 87 * newtab page or their default search engine's homepage. 88 * 89 * This class supports only one instance. 90 */ 91 export class UrlbarProviderSearchTips extends UrlbarProvider { 92 /** @type {?UrlbarProviderSearchTips} */ 93 static #instance = null; 94 95 constructor() { 96 super(); 97 if (UrlbarProviderSearchTips.#instance) { 98 throw new Error("Can only have one instance of UrlbarProviderSearchTips"); 99 } 100 UrlbarProviderSearchTips.#instance = this; 101 102 // Whether we should disable tips for the current browser session, for 103 // example because a tip was already shown. 104 this.disableTipsForCurrentSession = true; 105 for (let tip of Object.values(TIPS)) { 106 if ( 107 tip && 108 lazy.UrlbarPrefs.get(`tipShownCount.${tip}`) < MAX_SHOWN_COUNT 109 ) { 110 this.disableTipsForCurrentSession = false; 111 break; 112 } 113 } 114 115 // Whether and what kind of tip we've shown in the current engagement. 116 this.showedTipTypeInCurrentEngagement = TIPS.NONE; 117 118 // Used to track browser windows we've seen. 119 this._seenWindows = new WeakSet(); 120 } 121 122 /** 123 * Enum of the types of search tips. 124 * 125 * @returns {{ NONE: string; ONBOARD: string; REDIRECT: string; }} 126 */ 127 static get TIP_TYPE() { 128 return TIPS; 129 } 130 131 static get PRIORITY() { 132 // Search tips are prioritized over the Places and top sites providers. 133 return lazy.UrlbarProviderTopSites.PRIORITY + 1; 134 } 135 136 /** 137 * @returns {Values<typeof UrlbarUtils.PROVIDER_TYPE>} 138 */ 139 get type() { 140 return UrlbarUtils.PROVIDER_TYPE.PROFILE; 141 } 142 143 /** 144 * Whether this provider should be invoked for the given context. 145 * If this method returns false, the providers manager won't start a query 146 * with this provider, to save on resources. 147 */ 148 async isActive() { 149 return !!this.currentTip && lazy.cfrFeaturesUserPref; 150 } 151 152 /** 153 * Gets the provider's priority. 154 * 155 * @returns {number} The provider's priority for the given query. 156 */ 157 getPriority() { 158 return UrlbarProviderSearchTips.PRIORITY; 159 } 160 161 /** 162 * Starts querying. 163 * 164 * @param {UrlbarQueryContext} queryContext 165 * @param {(provider: UrlbarProvider, result: UrlbarResult) => void} addCallback 166 * Callback invoked by the provider to add a new result. 167 */ 168 async startQuery(queryContext, addCallback) { 169 let instance = this.queryInstance; 170 171 let tip = this.currentTip; 172 this.showedTipTypeInCurrentEngagement = this.currentTip; 173 this.currentTip = TIPS.NONE; 174 175 let defaultEngine = await Services.search.getDefault(); 176 let icon = await defaultEngine.getIconURL(); 177 if (instance != this.queryInstance) { 178 return; 179 } 180 181 let result; 182 switch (tip) { 183 case TIPS.ONBOARD: 184 result = this.#makeResult({ 185 tip, 186 icon, 187 titleL10n: { 188 id: "urlbar-search-tips-onboard", 189 args: { 190 engineName: defaultEngine.name, 191 }, 192 }, 193 heuristic: true, 194 }); 195 break; 196 case TIPS.REDIRECT: 197 result = this.#makeResult({ 198 tip, 199 icon, 200 titleL10n: { 201 id: "urlbar-search-tips-redirect-2", 202 args: { 203 engineName: defaultEngine.name, 204 }, 205 }, 206 }); 207 break; 208 } 209 addCallback(this, result); 210 } 211 212 /** 213 * Called when the tip is selected. 214 * 215 * @param {UrlbarResult} result 216 * The result that was picked. 217 * @param {window} window 218 * The browser window in which the tip is being displayed. 219 */ 220 #pickResult(result, window) { 221 window.gURLBar.value = ""; 222 window.gURLBar.setPageProxyState("invalid"); 223 window.gURLBar.removeAttribute("suppress-focus-border"); 224 window.gURLBar.focus(); 225 226 // The user either clicked the tip's "Okay, Got It" button, or they clicked 227 // in the urlbar while the tip was showing. We treat both as the user's 228 // acknowledgment of the tip, and we don't show tips again in any session. 229 // Set the shown count to the max. 230 lazy.UrlbarPrefs.set( 231 `tipShownCount.${result.payload.type}`, 232 MAX_SHOWN_COUNT 233 ); 234 } 235 236 onEngagement(queryContext, controller, details) { 237 this.#pickResult(details.result, controller.browserWindow); 238 } 239 240 onSearchSessionEnd() { 241 this.showedTipTypeInCurrentEngagement = TIPS.NONE; 242 } 243 244 /** 245 * Called from `onLocationChange` in browser.js. 246 * 247 * @param {window} window 248 * The browser window where the location change happened. 249 * @param {nsIURI} uri 250 * The URI being navigated to. 251 * @param {nsIWebProgress} webProgress 252 * The progress object, which can have event listeners added to it. 253 * @param {number} flags 254 * Load flags. See nsIWebProgressListener.idl for possible values. 255 */ 256 static async onLocationChange(window, uri, webProgress, flags) { 257 if (UrlbarProviderSearchTips.#instance) { 258 UrlbarProviderSearchTips.#instance.onLocationChange( 259 window, 260 uri, 261 webProgress, 262 flags 263 ); 264 } 265 } 266 267 /** 268 * Called by the static function with the same name. 269 * 270 * @param {window} window 271 * The browser window where the location change happened. 272 * @param {nsIURI} uri 273 * The URI being navigated to. 274 * @param {nsIWebProgress} webProgress 275 * The progress object, which can have event listeners added to it. 276 * @param {number} flags 277 * Load flags. See nsIWebProgressListener.idl for possible values. 278 */ 279 async onLocationChange(window, uri, webProgress, flags) { 280 let instance = (this._onLocationChangeInstance = {}); 281 282 // If this is the first time we've seen this browser window, we take some 283 // precautions to avoid impacting ts_paint. 284 if (!this._seenWindows.has(window)) { 285 this._seenWindows.add(window); 286 287 // First, wait until MozAfterPaint is fired in the current content window. 288 await window.gBrowserInit.firstContentWindowPaintPromise; 289 if (instance != this._onLocationChangeInstance) { 290 return; 291 } 292 293 // Second, wait 500ms. ts_paint waits at most 500ms after MozAfterPaint 294 // before ending. We use XPCOM directly instead of Timer.sys.mjs to avoid the 295 // perf impact of loading Timer.sys.mjs, in case it's not already loaded. 296 await new Promise(resolve => { 297 let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); 298 timer.initWithCallback(resolve, 500, Ci.nsITimer.TYPE_ONE_SHOT); 299 }); 300 if (instance != this._onLocationChangeInstance) { 301 return; 302 } 303 } 304 305 // Ignore events that don't change the document. Google is known to do this. 306 // Also ignore changes in sub-frames. See bug 1623978. 307 if ( 308 flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT || 309 !webProgress.isTopLevel 310 ) { 311 return; 312 } 313 314 // The UrlbarView is usually closed on location change when the input is 315 // blurred. Since we open the view to show the redirect tip without focusing 316 // the input, the view won't close in that case. We need to close it 317 // manually. 318 if (this.showedTipTypeInCurrentEngagement != TIPS.NONE) { 319 window.gURLBar.view.close(); 320 } 321 322 // Check if we are supposed to show a tip for the current session. 323 if ( 324 !lazy.cfrFeaturesUserPref || 325 (this.disableTipsForCurrentSession && 326 !lazy.UrlbarPrefs.get("searchTips.test.ignoreShowLimits")) 327 ) { 328 return; 329 } 330 331 this._maybeShowTipForUrl(uri.spec, window).catch(ex => 332 this.logger.error(ex) 333 ); 334 } 335 336 /** 337 * Determines whether we should show a tip for the current tab, sets 338 * this.currentTip, and starts a search on an empty string. 339 * 340 * @param {string} urlStr 341 * The URL of the page being loaded, in string form. 342 * @param {window} window 343 * The browser window in which the tip is being displayed. 344 */ 345 async _maybeShowTipForUrl(urlStr, window) { 346 let instance = {}; 347 this._maybeShowTipForUrlInstance = instance; 348 349 let ignoreShowLimits = lazy.UrlbarPrefs.get( 350 "searchTips.test.ignoreShowLimits" 351 ); 352 353 // Determine which tip we should show for the tab. Do this check first 354 // before the others below. It has less of a performance impact than the 355 // others, so in the common case where the URL is not one we're interested 356 // in, we can return immediately. 357 let tip; 358 let isNewtab = ["about:newtab", "about:home"].includes(urlStr); 359 let isSearchHomepage = !isNewtab && (await isDefaultEngineHomepage(urlStr)); 360 361 if (isNewtab) { 362 tip = TIPS.ONBOARD; 363 } else if (isSearchHomepage) { 364 tip = TIPS.REDIRECT; 365 } else { 366 // No tip. 367 return; 368 } 369 370 // If we've shown this type of tip the maximum number of times over all 371 // sessions, don't show it again. 372 let shownCount = lazy.UrlbarPrefs.get(`tipShownCount.${tip}`); 373 if (shownCount >= MAX_SHOWN_COUNT && !ignoreShowLimits) { 374 return; 375 } 376 377 // Don't show a tip if the browser has been updated recently. 378 let hoursSinceUpdate = Math.min( 379 lazy.LaterRun.hoursSinceInstall, 380 lazy.LaterRun.hoursSinceUpdate 381 ); 382 if (hoursSinceUpdate < LAST_UPDATE_THRESHOLD_HOURS && !ignoreShowLimits) { 383 return; 384 } 385 386 // Start a search. 387 lazy.setTimeout(async () => { 388 if (this._maybeShowTipForUrlInstance != instance) { 389 return; 390 } 391 392 // We don't want to interrupt a user's typed query with a Search Tip. 393 // See bugs 1613662 and 1619547. 394 if ( 395 window.gURLBar.getAttribute("pageproxystate") == "invalid" && 396 window.gURLBar.value != "" 397 ) { 398 return; 399 } 400 401 // Don't show a tip if the browser is already showing some other 402 // notification. 403 if ( 404 (!ignoreShowLimits && (await isBrowserShowingNotification(window))) || 405 this._maybeShowTipForUrlInstance != instance 406 ) { 407 return; 408 } 409 410 // At this point, we're showing a tip. 411 this.disableTipsForCurrentSession = true; 412 413 // Store the new shown count. 414 lazy.UrlbarPrefs.set(`tipShownCount.${tip}`, shownCount + 1); 415 416 this.currentTip = tip; 417 418 window.gURLBar.search("", { focus: tip == TIPS.ONBOARD }); 419 }, SHOW_TIP_DELAY_MS); 420 } 421 422 #makeResult({ tip, icon, titleL10n, heuristic = false }) { 423 return new lazy.UrlbarResult({ 424 type: UrlbarUtils.RESULT_TYPE.TIP, 425 source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, 426 heuristic, 427 payload: { 428 type: tip, 429 buttons: [{ l10n: { id: "urlbar-search-tips-confirm" } }], 430 icon, 431 titleL10n, 432 }, 433 }); 434 } 435 } 436 437 async function isBrowserShowingNotification(window) { 438 // urlbar view and notification box (info bar) 439 if ( 440 window.gURLBar.view.isOpen || 441 window.gNotificationBox.currentNotification || 442 window.gBrowser.getNotificationBox().currentNotification 443 ) { 444 return true; 445 } 446 447 // app menu notification doorhanger 448 if ( 449 lazy.AppMenuNotifications.activeNotification && 450 !lazy.AppMenuNotifications.activeNotification.dismissed && 451 !lazy.AppMenuNotifications.activeNotification.options.badgeOnly 452 ) { 453 return true; 454 } 455 456 // PopupNotifications (e.g. Tracking Protection, Identity Box Doorhangers) 457 if (window.PopupNotifications.isPanelOpen) { 458 return true; 459 } 460 461 // page action button panels 462 let pageActions = window.document.getElementById("page-action-buttons"); 463 if (pageActions) { 464 for (let child of pageActions.childNodes) { 465 if (child.getAttribute("open") == "true") { 466 return true; 467 } 468 } 469 } 470 471 // toolbar button panels 472 let navbar = window.document.getElementById("nav-bar-customization-target"); 473 for (let node of navbar.querySelectorAll("toolbarbutton")) { 474 if (node.getAttribute("open") == "true") { 475 return true; 476 } 477 } 478 479 // Other modals like spotlight messages or default browser prompt 480 // can be shown at startup 481 if (window.gDialogBox.isOpen) { 482 return true; 483 } 484 485 // On startup, the default browser check normally opens after the Search Tip. 486 // As a result, we can't check for the prompt's presence, but we can check if 487 // it plans on opening. 488 const willPrompt = await lazy.DefaultBrowserCheck.willCheckDefaultBrowser( 489 /* isStartupCheck */ false 490 ); 491 if (willPrompt) { 492 return true; 493 } 494 495 return false; 496 } 497 498 /** 499 * Checks if the given URL is the homepage of the current default search engine. 500 * Returns false if the default engine is not listed in SUPPORTED_ENGINES. 501 * 502 * @param {string} urlStr 503 * The URL to check, in string form. 504 * 505 * @returns {Promise<boolean>} 506 */ 507 async function isDefaultEngineHomepage(urlStr) { 508 let defaultEngine = await Services.search.getDefault(); 509 if (!defaultEngine) { 510 return false; 511 } 512 513 let homepageMatches = lazy.SUPPORTED_ENGINES.get(defaultEngine.name); 514 if (!homepageMatches) { 515 return false; 516 } 517 518 let url = URL.parse(urlStr); 519 if (!url) { 520 return false; 521 } 522 523 if (url.searchParams.has(homepageMatches.prohibitedSearchParams)) { 524 return false; 525 } 526 527 // Strip protocol and query params. 528 urlStr = url.hostname.concat(url.pathname); 529 530 return homepageMatches.domainPath.test(urlStr); 531 }