diff options
Diffstat (limited to 'browser/components/urlbar/UrlbarProviderSearchTips.jsm')
-rw-r--r-- | browser/components/urlbar/UrlbarProviderSearchTips.jsm | 505 |
1 files changed, 505 insertions, 0 deletions
diff --git a/browser/components/urlbar/UrlbarProviderSearchTips.jsm b/browser/components/urlbar/UrlbarProviderSearchTips.jsm new file mode 100644 index 0000000000..3ee8194f2d --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderSearchTips.jsm @@ -0,0 +1,505 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * This module exports a provider that might show a tip when the user opens + * the newtab or starts an organic search with their default search engine. + */ + +var EXPORTED_SYMBOLS = ["UrlbarProviderSearchTips"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.jsm", + DefaultBrowserCheck: "resource:///modules/BrowserGlue.jsm", + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", + ProfileAge: "resource://gre/modules/ProfileAge.jsm", + Services: "resource://gre/modules/Services.jsm", + setTimeout: "resource://gre/modules/Timer.jsm", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm", + UrlbarProvider: "resource:///modules/UrlbarUtils.jsm", + UrlbarProviderTopSites: "resource:///modules/UrlbarProviderTopSites.jsm", + UrlbarResult: "resource:///modules/UrlbarResult.jsm", + UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", +}); + +XPCOMUtils.defineLazyGetter(this, "updateManager", () => { + return ( + Cc["@mozilla.org/updates/update-manager;1"] && + Cc["@mozilla.org/updates/update-manager;1"].getService(Ci.nsIUpdateManager) + ); +}); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "cfrFeaturesUserPref", + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features", + true +); + +// The possible tips to show. These names (except NONE) are used in the names +// of keys in the `urlbar.tips` keyed scalar telemetry (see telemetry.rst). +// Don't modify them unless you've considered that. If you do modify them or +// add new tips, then you are also adding new `urlbar.tips` keys and therefore +// need an expanded data collection review. +const TIPS = { + NONE: "", + ONBOARD: "searchTip_onboard", + REDIRECT: "searchTip_redirect", +}; + +// This maps engine names to regexes matching their homepages. We show the +// redirect tip on these pages. The Google domains are taken from +// https://ipfs.io/ipfs/QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco/wiki/List_of_Google_domains.html. +const SUPPORTED_ENGINES = new Map([ + ["Bing", { domainPath: /^www\.bing\.com\/$/ }], + [ + "DuckDuckGo", + { + domainPath: /^(start\.)?duckduckgo\.com\/$/, + prohibitedSearchParams: ["q"], + }, + ], + [ + "Google", + { + domainPath: /^www\.google\.(com|ac|ad|ae|com\.af|com\.ag|com\.ai|al|am|co\.ao|com\.ar|as|at|com\.au|az|ba|com\.bd|be|bf|bg|com\.bh|bi|bj|com\.bn|com\.bo|com\.br|bs|bt|co\.bw|by|com\.bz|ca|com\.kh|cc|cd|cf|cat|cg|ch|ci|co\.ck|cl|cm|cn|com\.co|co\.cr|com\.cu|cv|com\.cy|cz|de|dj|dk|dm|com\.do|dz|com\.ec|ee|com\.eg|es|com\.et|fi|com\.fj|fm|fr|ga|ge|gf|gg|com\.gh|com\.gi|gl|gm|gp|gr|com\.gt|gy|com\.hk|hn|hr|ht|hu|co\.id|iq|ie|co\.il|im|co\.in|io|is|it|je|com\.jm|jo|co\.jp|co\.ke|ki|kg|co\.kr|com\.kw|kz|la|com\.lb|com\.lc|li|lk|co\.ls|lt|lu|lv|com\.ly|co\.ma|md|me|mg|mk|ml|com\.mm|mn|ms|com\.mt|mu|mv|mw|com\.mx|com\.my|co\.mz|com\.na|ne|com\.nf|com\.ng|com\.ni|nl|no|com\.np|nr|nu|co\.nz|com\.om|com\.pk|com\.pa|com\.pe|com\.ph|pl|com\.pg|pn|com\.pr|ps|pt|com\.py|com\.qa|ro|rs|ru|rw|com\.sa|com\.sb|sc|se|com\.sg|sh|si|sk|com\.sl|sn|sm|so|st|sr|com\.sv|td|tg|co\.th|com\.tj|tk|tl|tm|to|tn|com\.tr|tt|com\.tw|co\.tz|com\.ua|co\.ug|co\.uk|com\.uy|co\.uz|com\.vc|co\.ve|vg|co\.vi|com\.vn|vu|ws|co\.za|co\.zm|co\.zw)\/(webhp)?$/, + }, + ], +]); + +// The maximum number of times we'll show a tip across all sessions. +const MAX_SHOWN_COUNT = 4; + +// Amount of time to wait before showing a tip after selecting a tab or +// navigating to a page where we should show a tip. +const SHOW_TIP_DELAY_MS = 200; + +// We won't show a tip if the browser has been updated in the past +// LAST_UPDATE_THRESHOLD_MS. +const LAST_UPDATE_THRESHOLD_MS = 24 * 60 * 60 * 1000; + +/** + * A provider that sometimes returns a tip result when the user visits the + * newtab page or their default search engine's homepage. + */ +class ProviderSearchTips extends UrlbarProvider { + constructor() { + super(); + + // Whether we should disable tips for the current browser session, for + // example because a tip was already shown. + this.disableTipsForCurrentSession = true; + for (let tip of Object.values(TIPS)) { + if (tip && UrlbarPrefs.get(`tipShownCount.${tip}`) < MAX_SHOWN_COUNT) { + this.disableTipsForCurrentSession = false; + break; + } + } + + // Whether and what kind of tip we've shown in the current engagement. + this.showedTipTypeInCurrentEngagement = TIPS.NONE; + + // Used to track browser windows we've seen. + this._seenWindows = new WeakSet(); + } + + /** + * Enum of the types of search tips. + */ + get TIP_TYPE() { + return TIPS; + } + + get PRIORITY() { + // Search tips are prioritized over the UnifiedComplete and top sites + // providers. + return UrlbarProviderTopSites.PRIORITY + 1; + } + + /** + * Unique name for the provider, used by the context to filter on providers. + * Not using a unique name will cause the newest registration to win. + */ + get name() { + return "UrlbarProviderSearchTips"; + } + + /** + * The type of the provider. + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.PROFILE; + } + + /** + * Whether this provider should be invoked for the given context. + * If this method returns false, the providers manager won't start a query + * with this provider, to save on resources. + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {boolean} Whether this provider should be invoked for the search. + */ + isActive(queryContext) { + return this.currentTip && cfrFeaturesUserPref; + } + + /** + * Gets the provider's priority. + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {number} The provider's priority for the given query. + */ + getPriority(queryContext) { + return this.PRIORITY; + } + + /** + * Starts querying. + * @param {UrlbarQueryContext} queryContext The query context object + * @param {function} addCallback Callback invoked by the provider to add a new + * result. A UrlbarResult should be passed to it. + * @note Extended classes should return a Promise resolved when the provider + * is done searching AND returning results. + */ + async startQuery(queryContext, addCallback) { + let instance = this.queryInstance; + + let tip = this.currentTip; + this.showedTipTypeInCurrentEngagement = this.currentTip; + this.currentTip = TIPS.NONE; + + let defaultEngine = await Services.search.getDefault(); + + let result = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TIP, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + type: tip, + buttonTextData: { id: "urlbar-search-tips-confirm" }, + icon: defaultEngine.iconURI?.spec, + } + ); + + switch (tip) { + case TIPS.ONBOARD: + result.heuristic = true; + result.payload.textData = { + id: "urlbar-search-tips-onboard", + args: { + engineName: defaultEngine.name, + }, + }; + break; + case TIPS.REDIRECT: + result.heuristic = false; + result.payload.textData = { + id: "urlbar-search-tips-redirect-2", + args: { + engineName: defaultEngine.name, + }, + }; + break; + } + + if (instance != this.queryInstance) { + return; + } + + Services.telemetry.keyedScalarAdd("urlbar.tips", `${tip}-shown`, 1); + + addCallback(this, result); + } + + /** + * Called when the tip is selected. + * @param {UrlbarResult} result + * The result that was picked. + */ + pickResult(result) { + let window = BrowserWindowTracker.getTopWindow(); + window.gURLBar.value = ""; + window.gURLBar.setPageProxyState("invalid"); + window.gURLBar.focus(); + } + + /** + * Called when the user starts and ends an engagement with the urlbar. + * + * @param {boolean} isPrivate True if the engagement is in a private context. + * @param {string} state The state of the engagement, one of: start, + * engagement, abandonment, discard. + */ + onEngagement(isPrivate, state) { + if ( + this.showedTipTypeInCurrentEngagement != TIPS.NONE && + state == "engagement" + ) { + // The user either clicked the tip's "Okay, Got It" button, or they + // engaged with the urlbar while the tip was showing. We treat both as the + // user's acknowledgment of the tip, and we don't show tips again in any + // session. Set the shown count to the max. + UrlbarPrefs.set( + `tipShownCount.${this.showedTipTypeInCurrentEngagement}`, + MAX_SHOWN_COUNT + ); + } + this.showedTipTypeInCurrentEngagement = TIPS.NONE; + } + + /** + * Called from `onLocationChange` in browser.js. + * @param {window} window + * The browser window where the location change happened. + * @param {URL} uri + * The URI being navigated to. + * @param {nsIWebProgress} webProgress + * @param {number} flags + * Load flags. See nsIWebProgressListener.idl for possible values. + */ + async onLocationChange(window, uri, webProgress, flags) { + let instance = (this._onLocationChangeInstance = {}); + + // If this is the first time we've seen this browser window, we take some + // precautions to avoid impacting ts_paint. + if (!this._seenWindows.has(window)) { + this._seenWindows.add(window); + + // First, wait until MozAfterPaint is fired in the current content window. + await window.gBrowserInit.firstContentWindowPaintPromise; + if (instance != this._onLocationChangeInstance) { + return; + } + + // Second, wait 500ms. ts_paint waits at most 500ms after MozAfterPaint + // before ending. We use XPCOM directly instead of Timer.jsm to avoid the + // perf impact of loading Timer.jsm, in case it's not already loaded. + await new Promise(resolve => { + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.initWithCallback(resolve, 500, Ci.nsITimer.TYPE_ONE_SHOT); + }); + if (instance != this._onLocationChangeInstance) { + return; + } + } + + // Ignore events that don't change the document. Google is known to do this. + // Also ignore changes in sub-frames. See bug 1623978. + if ( + flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT || + !webProgress.isTopLevel + ) { + return; + } + + // The UrlbarView is usually closed on location change when the input is + // blurred. Since we open the view to show the redirect tip without focusing + // the input, the view won't close in that case. We need to close it + // manually. + if (this.showedTipTypeInCurrentEngagement != TIPS.NONE) { + window.gURLBar.view.close(); + } + + // Check if we are supposed to show a tip for the current session. + if ( + !cfrFeaturesUserPref || + (this.disableTipsForCurrentSession && + !UrlbarPrefs.get("searchTips.test.ignoreShowLimits")) + ) { + return; + } + + this._maybeShowTipForUrl(uri.spec).catch(Cu.reportError); + } + + /** + * Determines whether we should show a tip for the current tab, sets + * this.currentTip, and starts a search on an empty string. + * @param {number} urlStr + * The URL of the page being loaded, in string form. + */ + async _maybeShowTipForUrl(urlStr) { + let instance = {}; + this._maybeShowTipForUrlInstance = instance; + + // Determine which tip we should show for the tab. Do this check first + // before the others below. It has less of a performance impact than the + // others, so in the common case where the URL is not one we're interested + // in, we can return immediately. + let tip; + let isNewtab = ["about:newtab", "about:home"].includes(urlStr); + let isSearchHomepage = !isNewtab && (await isDefaultEngineHomepage(urlStr)); + if (isNewtab) { + tip = TIPS.ONBOARD; + } else if (isSearchHomepage) { + tip = TIPS.REDIRECT; + } else { + // No tip. + return; + } + + let ignoreShowLimits = UrlbarPrefs.get("searchTips.test.ignoreShowLimits"); + + // If we've shown this type of tip the maximum number of times over all + // sessions, don't show it again. + let shownCount = UrlbarPrefs.get(`tipShownCount.${tip}`); + if (shownCount >= MAX_SHOWN_COUNT && !ignoreShowLimits) { + return; + } + + // Don't show a tip if the browser is already showing some other + // notification. + if ((await isBrowserShowingNotification()) && !ignoreShowLimits) { + return; + } + + // Don't show a tip if the browser has been updated recently. + let date = await lastBrowserUpdateDate(); + if (Date.now() - date <= LAST_UPDATE_THRESHOLD_MS && !ignoreShowLimits) { + return; + } + + // At this point, we're showing a tip. + this.disableTipsForCurrentSession = true; + + // Store the new shown count. + UrlbarPrefs.set(`tipShownCount.${tip}`, shownCount + 1); + + // Start a search. + setTimeout(() => { + if (this._maybeShowTipForUrlInstance != instance) { + return; + } + + let window = BrowserWindowTracker.getTopWindow(); + // We don't want to interrupt a user's typed query with a Search Tip. + // See bugs 1613662 and 1619547. + if ( + window.gURLBar.getAttribute("pageproxystate") == "invalid" && + window.gURLBar.value != "" + ) { + return; + } + + this.currentTip = tip; + window.gURLBar.search("", { focus: tip == TIPS.ONBOARD }); + }, SHOW_TIP_DELAY_MS); + } +} + +async function isBrowserShowingNotification() { + let window = BrowserWindowTracker.getTopWindow(); + + // urlbar view and notification box (info bar) + if ( + window.gURLBar.view.isOpen || + window.gHighPriorityNotificationBox.currentNotification || + window.gBrowser.getNotificationBox().currentNotification + ) { + return true; + } + + // app menu notification doorhanger + if ( + AppMenuNotifications.activeNotification && + !AppMenuNotifications.activeNotification.dismissed && + !AppMenuNotifications.activeNotification.options.badgeOnly + ) { + return true; + } + + // tracking protection and identity box doorhangers + if ( + ["tracking-protection-icon-container", "identity-box"].some( + id => window.document.getElementById(id).getAttribute("open") == "true" + ) + ) { + return true; + } + + // page action button panels + let pageActions = window.document.getElementById("page-action-buttons"); + if (pageActions) { + for (let child of pageActions.childNodes) { + if (child.getAttribute("open") == "true") { + return true; + } + } + } + + // toolbar button panels + let navbar = window.document.getElementById("nav-bar-customization-target"); + for (let node of navbar.querySelectorAll("toolbarbutton")) { + if (node.getAttribute("open") == "true") { + return true; + } + } + + // On startup, the default browser check normally opens after the Search Tip. + // As a result, we can't check for the prompt's presence, but we can check if + // it plans on opening. + const willPrompt = await DefaultBrowserCheck.willCheckDefaultBrowser( + /* isStartupCheck */ false + ); + if (willPrompt) { + return true; + } + + return false; +} + +/** + * Checks if the given URL is the homepage of the current default search engine. + * Returns false if the default engine is not listed in SUPPORTED_ENGINES. + * @param {string} urlStr + * The URL to check, in string form. + * + * @returns {boolean} + */ +async function isDefaultEngineHomepage(urlStr) { + let defaultEngine = await Services.search.getDefault(); + if (!defaultEngine) { + return false; + } + + let homepageMatches = SUPPORTED_ENGINES.get(defaultEngine.name); + if (!homepageMatches) { + return false; + } + + // The URL object throws if the string isn't a valid URL. + let url; + try { + url = new URL(urlStr); + } catch (e) { + return false; + } + + if (url.searchParams.has(homepageMatches.prohibitedSearchParams)) { + return false; + } + + // Strip protocol and query params. + urlStr = url.hostname.concat(url.pathname); + + return homepageMatches.domainPath.test(urlStr); +} + +async function lastBrowserUpdateDate() { + // Get the newest update in the update history. This isn't perfect + // because these dates are when updates are applied, not when the + // user restarts with the update. See bug 1595328. + if (updateManager && updateManager.getUpdateCount()) { + let update = updateManager.getUpdateAt(0); + return update.installDate; + } + // Fall back to the profile age. + let age = await ProfileAge(); + return (await age.firstUse) || age.created; +} + +var UrlbarProviderSearchTips = new ProviderSearchTips(); |