diff options
Diffstat (limited to 'browser/components/urlbar/UrlbarProviderSearchTips.sys.mjs')
-rw-r--r-- | browser/components/urlbar/UrlbarProviderSearchTips.sys.mjs | 629 |
1 files changed, 629 insertions, 0 deletions
diff --git a/browser/components/urlbar/UrlbarProviderSearchTips.sys.mjs b/browser/components/urlbar/UrlbarProviderSearchTips.sys.mjs new file mode 100644 index 0000000000..309795004a --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderSearchTips.sys.mjs @@ -0,0 +1,629 @@ +/* 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/. */ + +/** + * 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. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.sys.mjs", + DefaultBrowserCheck: "resource:///modules/BrowserGlue.sys.mjs", + ProfileAge: "resource://gre/modules/ProfileAge.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarProviderTopSites: "resource:///modules/UrlbarProviderTopSites.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "updateManager", () => { + return ( + Cc["@mozilla.org/updates/update-manager;1"] && + Cc["@mozilla.org/updates/update-manager;1"].getService(Ci.nsIUpdateManager) + ); +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "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", + PERSIST: "searchTip_persist", + 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; + +// Amount of time to wait before showing the persist tip after the +// onLocationChange event during the process of loading +// a default search engine results page. +const SHOW_PERSIST_TIP_DELAY_MS = 1500; + +// 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 && + lazy.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. + * + * @returns {{ NONE: string; ONBOARD: string; PERSIST: string; REDIRECT: string; }} + */ + get TIP_TYPE() { + return TIPS; + } + + get PRIORITY() { + // Search tips are prioritized over the Places and top sites providers. + return lazy.UrlbarProviderTopSites.PRIORITY + 1; + } + + get SHOW_PERSIST_TIP_DELAY_MS() { + return SHOW_PERSIST_TIP_DELAY_MS; + } + + /** + * 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. + * + * @returns {string} + */ + get name() { + return "UrlbarProviderSearchTips"; + } + + /** + * The type of the provider. + * + * @returns {UrlbarUtils.PROVIDER_TYPE} + */ + 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 && lazy.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. Extended classes should return a Promise resolved when the + * provider is done searching AND returning results. + * + * @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. + * @returns {Promise} + */ + 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(); + if (instance != this.queryInstance) { + return; + } + + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.TIP, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + type: tip, + buttons: [{ l10n: { id: "urlbar-search-tips-confirm" } }], + icon: defaultEngine.iconURI?.spec, + } + ); + + switch (tip) { + case TIPS.ONBOARD: + result.heuristic = true; + result.payload.titleL10n = { + id: "urlbar-search-tips-onboard", + args: { + engineName: defaultEngine.name, + }, + }; + break; + case TIPS.REDIRECT: + result.heuristic = false; + result.payload.titleL10n = { + id: "urlbar-search-tips-redirect-2", + args: { + engineName: defaultEngine.name, + }, + }; + break; + case TIPS.PERSIST: + result.heuristic = false; + result.payload.titleL10n = { + id: "urlbar-search-tips-persist", + }; + result.payload.icon = UrlbarUtils.ICON.TIP; + result.payload.buttons = [ + { l10n: { id: "urlbar-search-tips-confirm-short" } }, + ]; + break; + } + + 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. + * @param {window} window + * The browser window in which the tip is being displayed. + */ + #pickResult(result, window) { + let tip = result.payload.type; + switch (tip) { + case TIPS.PERSIST: + window.gURLBar.removeAttribute("suppress-focus-border"); + window.gURLBar.select(); + break; + default: + window.gURLBar.value = ""; + window.gURLBar.setPageProxyState("invalid"); + window.gURLBar.removeAttribute("suppress-focus-border"); + window.gURLBar.focus(); + break; + } + + // The user either clicked the tip's "Okay, Got It" button, or they clicked + // in 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. + lazy.UrlbarPrefs.set(`tipShownCount.${tip}`, MAX_SHOWN_COUNT); + } + + /** + * Called when the user starts and ends an engagement with the urlbar. For + * details on parameters, see UrlbarProvider.onEngagement(). + * + * @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 + * @param {UrlbarQueryContext} queryContext + * The engagement's query context. This is *not* guaranteed to be defined + * when `state` is "start". It will always be defined for "engagement" and + * "abandonment". + * @param {object} details + * This is defined only when `state` is "engagement" or "abandonment", and + * it describes the search string and picked result. + * @param {window} window + * The browser window where the engagement event took place. + */ + onEngagement(isPrivate, state, queryContext, details, window) { + // Ignore engagements on other results that didn't end the session. + let { result } = details; + if (result?.providerName != this.name && details.isSessionOngoing) { + return; + } + + if (result?.providerName == this.name) { + this.#pickResult(result, window); + } + + this.showedTipTypeInCurrentEngagement = TIPS.NONE; + } + + /** + * Called from `onLocationChange` in browser.js. + * + * @param {window} window + * The browser window where the location change happened. + * @param {nsIURI} uri + * The URI being navigated to. + * @param {nsIURI | null} originalUri + * The original URI being navigated to. + * @param {nsIWebProgress} webProgress + * The progress object, which can have event listeners added to it. + * @param {number} flags + * Load flags. See nsIWebProgressListener.idl for possible values. + */ + async onLocationChange(window, uri, originalUri, 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.sys.mjs to avoid the + // perf impact of loading Timer.sys.mjs, 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 ( + !lazy.cfrFeaturesUserPref || + (this.disableTipsForCurrentSession && + !lazy.UrlbarPrefs.get("searchTips.test.ignoreShowLimits")) + ) { + return; + } + + this._maybeShowTipForUrl(uri.spec, originalUri, window).catch(ex => + this.logger.error(ex) + ); + } + + /** + * Determines whether we should show a tip for the current tab, sets + * this.currentTip, and starts a search on an empty string. + * + * @param {string} urlStr + * The URL of the page being loaded, in string form. + * @param {nsIURI | null} originalUri + * The original URI of the page being loaded. + * @param {window} window + * The browser window in which the tip is being displayed. + */ + async _maybeShowTipForUrl(urlStr, originalUri, window) { + let instance = {}; + this._maybeShowTipForUrlInstance = instance; + + let ignoreShowLimits = lazy.UrlbarPrefs.get( + "searchTips.test.ignoreShowLimits" + ); + + // 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)); + + // Only show the persist tip if: the feature is enabled, + // it's been shown fewer than the maximum number of times + // a specific tip can be shown to the user, and the + // the url is a default SERP. + let shouldShowPersistTip = + lazy.UrlbarPrefs.isPersistedSearchTermsEnabled() && + (lazy.UrlbarPrefs.get(`tipShownCount.${TIPS.PERSIST}`) < + MAX_SHOWN_COUNT || + ignoreShowLimits) && + !!lazy.UrlbarSearchUtils.getSearchTermIfDefaultSerpUri( + originalUri ?? urlStr + ); + + if (isNewtab) { + tip = TIPS.ONBOARD; + } else if (isSearchHomepage) { + tip = TIPS.REDIRECT; + } else if (shouldShowPersistTip) { + tip = TIPS.PERSIST; + } else { + // No tip. + return; + } + + // If we've shown this type of tip the maximum number of times over all + // sessions, don't show it again. + let shownCount = lazy.UrlbarPrefs.get(`tipShownCount.${tip}`); + if (shownCount >= MAX_SHOWN_COUNT && !ignoreShowLimits) { + return; + } + + // Don't show a tip if the browser has been updated recently. + // Exception: TIPS.PERSIST should show immediately + // after the feature is enabled for users. + let date = await lastBrowserUpdateDate(); + if ( + tip != TIPS.PERSIST && + Date.now() - date <= LAST_UPDATE_THRESHOLD_MS && + !ignoreShowLimits + ) { + return; + } + + let tipDelay = + tip == TIPS.PERSIST ? SHOW_PERSIST_TIP_DELAY_MS : SHOW_TIP_DELAY_MS; + + // Start a search. + lazy.setTimeout(async () => { + if (this._maybeShowTipForUrlInstance != instance) { + return; + } + + // We don't want to interrupt a user's typed query with a Search Tip. + // See bugs 1613662 and 1619547. The persist search tip is an + // exception because the query is not erased. + if ( + window.gURLBar.getAttribute("pageproxystate") == "invalid" && + window.gURLBar.value != "" + ) { + return; + } + + // The tab that initiated the tip might not be in the same window + // as the one that is currently at the top. Only apply this search + // tip to a tab showing a search term. + if (tip == TIPS.PERSIST && !window.gBrowser.selectedBrowser.searchTerms) { + return; + } + + // Don't show a tip if the browser is already showing some other + // notification. + if ( + (!ignoreShowLimits && (await isBrowserShowingNotification(window))) || + this._maybeShowTipForUrlInstance != instance + ) { + return; + } + + // Don't show a tip if a request is in progress, and the URI associated + // with the request differs from the URI that triggered the search tip. + // One contraint with this approach is related to Bug 1797748: SERPs + // that use the History API to navigate between views will call + // onLocationChange without a request, and thus, no originalUri is + // available to check against, so the search tip and search terms may + // show on search pages outside of the default SERP. + let { documentRequest } = window.gBrowser.selectedBrowser.webProgress; + if ( + documentRequest instanceof Ci.nsIChannel && + documentRequest.originalURI?.spec != originalUri?.spec && + (!isNewtab || originalUri) + ) { + return; + } + + // At this point, we're showing a tip. + this.disableTipsForCurrentSession = true; + + // Store the new shown count. + lazy.UrlbarPrefs.set(`tipShownCount.${tip}`, shownCount + 1); + + this.currentTip = tip; + + let value = + tip == TIPS.PERSIST ? window.gBrowser.selectedBrowser.searchTerms : ""; + window.gURLBar.search(value, { focus: tip == TIPS.ONBOARD }); + }, tipDelay); + } +} + +async function isBrowserShowingNotification(window) { + // urlbar view and notification box (info bar) + if ( + window.gURLBar.view.isOpen || + window.gNotificationBox.currentNotification || + window.gBrowser.getNotificationBox().currentNotification + ) { + return true; + } + + // app menu notification doorhanger + if ( + lazy.AppMenuNotifications.activeNotification && + !lazy.AppMenuNotifications.activeNotification.dismissed && + !lazy.AppMenuNotifications.activeNotification.options.badgeOnly + ) { + return true; + } + + // PopupNotifications (e.g. Tracking Protection, Identity Box Doorhangers) + if (window.PopupNotifications.isPanelOpen) { + 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; + } + } + + // Other modals like spotlight messages or default browser prompt + // can be shown at startup + if (window.gDialogBox.isOpen) { + 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 lazy.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 (lazy.updateManager && lazy.updateManager.getUpdateCount()) { + let update = lazy.updateManager.getUpdateAt(0); + return update.installDate; + } + // Fall back to the profile age. + let age = await lazy.ProfileAge(); + return (await age.firstUse) || age.created; +} + +export var UrlbarProviderSearchTips = new ProviderSearchTips(); |