// 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.EXPORTED_SYMBOLS = ["UITour"]; const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; Cu.import("resource://gre/modules/AppConstants.jsm"); Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource://gre/modules/Preferences.jsm"); Cu.import("resource://gre/modules/Promise.jsm"); Cu.import("resource:///modules/RecentWindow.jsm"); Cu.import("resource://gre/modules/Task.jsm"); Cu.import("resource://gre/modules/TelemetryController.jsm"); Cu.import("resource://gre/modules/Timer.jsm"); Cu.importGlobalProperties(["URL"]); XPCOMUtils.defineLazyModuleGetter(this, "LightweightThemeManager", "resource://gre/modules/LightweightThemeManager.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "ResetProfile", "resource://gre/modules/ResetProfile.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "CustomizableUI", "resource:///modules/CustomizableUI.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "UITelemetry", "resource://gre/modules/UITelemetry.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "BrowserUITelemetry", "resource:///modules/BrowserUITelemetry.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils", "resource://gre/modules/PrivateBrowsingUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "ReaderMode", "resource://gre/modules/ReaderMode.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "ReaderParent", "resource:///modules/ReaderParent.jsm"); // See LOG_LEVELS in Console.jsm. Common examples: "All", "Info", "Warn", & "Error". const PREF_LOG_LEVEL = "browser.uitour.loglevel"; const PREF_SEENPAGEIDS = "browser.uitour.seenPageIDs"; const PREF_READERVIEW_TRIGGER = "browser.uitour.readerViewTrigger"; const PREF_SURVEY_DURATION = "browser.uitour.surveyDuration"; const BACKGROUND_PAGE_ACTIONS_ALLOWED = new Set([ "forceShowReaderIcon", "getConfiguration", "getTreatmentTag", "hideHighlight", "hideInfo", "hideMenu", "ping", "registerPageID", "setConfiguration", "setTreatmentTag", ]); const MAX_BUTTONS = 4; const BUCKET_NAME = "UITour"; const BUCKET_TIMESTEPS = [ 1 * 60 * 1000, // Until 1 minute after tab is closed/inactive. 3 * 60 * 1000, // Until 3 minutes after tab is closed/inactive. 10 * 60 * 1000, // Until 10 minutes after tab is closed/inactive. 60 * 60 * 1000, // Until 1 hour after tab is closed/inactive. ]; // Time after which seen Page IDs expire. const SEENPAGEID_EXPIRY = 8 * 7 * 24 * 60 * 60 * 1000; // 8 weeks. // Prefix for any target matching a search engine. const TARGET_SEARCHENGINE_PREFIX = "searchEngine-"; // Create a new instance of the ConsoleAPI so we can control the maxLogLevel with a pref. XPCOMUtils.defineLazyGetter(this, "log", () => { let ConsoleAPI = Cu.import("resource://gre/modules/Console.jsm", {}).ConsoleAPI; let consoleOptions = { maxLogLevelPref: PREF_LOG_LEVEL, prefix: "UITour", }; return new ConsoleAPI(consoleOptions); }); this.UITour = { url: null, seenPageIDs: null, // This map is not persisted and is used for // building the content source of a potential tour. pageIDsForSession: new Map(), pageIDSourceBrowsers: new WeakMap(), /* Map from browser chrome windows to a Set of s in which a tour is open (both visible and hidden) */ tourBrowsersByWindow: new WeakMap(), appMenuOpenForAnnotation: new Set(), availableTargetsCache: new WeakMap(), clearAvailableTargetsCache() { this.availableTargetsCache = new WeakMap(); }, _annotationPanelMutationObservers: new WeakMap(), highlightEffects: ["random", "wobble", "zoom", "color"], targets: new Map([ ["accountStatus", { query: (aDocument) => { // If the user is logged in, use the avatar element. let fxAFooter = aDocument.getElementById("PanelUI-footer-fxa"); if (fxAFooter.getAttribute("fxastatus")) { return aDocument.getElementById("PanelUI-fxa-avatar"); } // Otherwise use the sync setup icon. let statusButton = aDocument.getElementById("PanelUI-fxa-label"); return aDocument.getAnonymousElementByAttribute(statusButton, "class", "toolbarbutton-icon"); }, // This is a fake widgetName starting with the "PanelUI-" prefix so we know // to automatically open the appMenu when annotating this target. widgetName: "PanelUI-fxa-label", }], ["addons", {query: "#add-ons-button"}], ["appMenu", { addTargetListener: (aDocument, aCallback) => { let panelPopup = aDocument.getElementById("PanelUI-popup"); panelPopup.addEventListener("popupshown", aCallback); }, query: "#PanelUI-button", removeTargetListener: (aDocument, aCallback) => { let panelPopup = aDocument.getElementById("PanelUI-popup"); panelPopup.removeEventListener("popupshown", aCallback); }, }], ["backForward", { query: "#back-button", widgetName: "urlbar-container", }], ["bookmarks", {query: "#bookmarks-menu-button"}], ["controlCenter-trackingUnblock", controlCenterTrackingToggleTarget(true)], ["controlCenter-trackingBlock", controlCenterTrackingToggleTarget(false)], ["customize", { query: (aDocument) => { let customizeButton = aDocument.getElementById("PanelUI-customize"); return aDocument.getAnonymousElementByAttribute(customizeButton, "class", "toolbarbutton-icon"); }, widgetName: "PanelUI-customize", }], ["devtools", {query: "#developer-button"}], ["help", {query: "#PanelUI-help"}], ["home", {query: "#home-button"}], ["forget", { allowAdd: true, query: "#panic-button", widgetName: "panic-button", }], ["pocket", { allowAdd: true, query: "#pocket-button", widgetName: "pocket-button", }], ["privateWindow", {query: "#privatebrowsing-button"}], ["quit", {query: "#PanelUI-quit"}], ["readerMode-urlBar", {query: "#reader-mode-button"}], ["search", { infoPanelOffsetX: 18, infoPanelPosition: "after_start", query: "#searchbar", widgetName: "search-container", }], ["searchIcon", { query: (aDocument) => { let searchbar = aDocument.getElementById("searchbar"); return aDocument.getAnonymousElementByAttribute(searchbar, "anonid", "searchbar-search-button"); }, widgetName: "search-container", }], ["searchPrefsLink", { query: (aDocument) => { let element = null; let popup = aDocument.getElementById("PopupSearchAutoComplete"); if (popup.state != "open") return null; element = aDocument.getAnonymousElementByAttribute(popup, "anonid", "search-settings"); if (!element || !UITour.isElementVisible(element)) { return null; } return element; }, }], ["selectedTabIcon", { query: (aDocument) => { let selectedtab = aDocument.defaultView.gBrowser.selectedTab; let element = aDocument.getAnonymousElementByAttribute(selectedtab, "anonid", "tab-icon-image"); if (!element || !UITour.isElementVisible(element)) { return null; } return element; }, }], ["trackingProtection", { query: "#tracking-protection-icon", }], ["urlbar", { query: "#urlbar", widgetName: "urlbar-container", }], ["webide", {query: "#webide-button"}], ]), init: function() { log.debug("Initializing UITour"); // Lazy getter is initialized here so it can be replicated any time // in a test. delete this.seenPageIDs; Object.defineProperty(this, "seenPageIDs", { get: this.restoreSeenPageIDs.bind(this), configurable: true, }); delete this.url; XPCOMUtils.defineLazyGetter(this, "url", function () { return Services.urlFormatter.formatURLPref("browser.uitour.url"); }); // Clear the availableTargetsCache on widget changes. let listenerMethods = [ "onWidgetAdded", "onWidgetMoved", "onWidgetRemoved", "onWidgetReset", "onAreaReset", ]; CustomizableUI.addListener(listenerMethods.reduce((listener, method) => { listener[method] = () => this.clearAvailableTargetsCache(); return listener; }, {})); }, restoreSeenPageIDs: function() { delete this.seenPageIDs; if (UITelemetry.enabled) { let dateThreshold = Date.now() - SEENPAGEID_EXPIRY; try { let data = Services.prefs.getCharPref(PREF_SEENPAGEIDS); data = new Map(JSON.parse(data)); for (let [pageID, details] of data) { if (typeof pageID != "string" || typeof details != "object" || typeof details.lastSeen != "number" || details.lastSeen < dateThreshold) { data.delete(pageID); } } this.seenPageIDs = data; } catch (e) {} } if (!this.seenPageIDs) this.seenPageIDs = new Map(); this.persistSeenIDs(); return this.seenPageIDs; }, addSeenPageID: function(aPageID) { if (!UITelemetry.enabled) return; this.seenPageIDs.set(aPageID, { lastSeen: Date.now(), }); this.persistSeenIDs(); }, persistSeenIDs: function() { if (this.seenPageIDs.size === 0) { Services.prefs.clearUserPref(PREF_SEENPAGEIDS); return; } Services.prefs.setCharPref(PREF_SEENPAGEIDS, JSON.stringify([...this.seenPageIDs])); }, get _readerViewTriggerRegEx() { delete this._readerViewTriggerRegEx; let readerViewUITourTrigger = Services.prefs.getCharPref(PREF_READERVIEW_TRIGGER); return this._readerViewTriggerRegEx = new RegExp(readerViewUITourTrigger, "i"); }, onLocationChange: function(aLocation) { // The ReaderView tour page is expected to run in Reader View, // which disables JavaScript on the page. To get around that, we // automatically start a pre-defined tour on page load (for hysterical // raisins the ReaderView tour is known as "readinglist") let originalUrl = ReaderMode.getOriginalUrl(aLocation); if (this._readerViewTriggerRegEx.test(originalUrl)) { this.startSubTour("readinglist"); } }, onPageEvent: function(aMessage, aEvent) { let browser = aMessage.target; let window = browser.ownerGlobal; // Does the window have tabs? We need to make sure since windowless browsers do // not have tabs. if (!window.gBrowser) { // When using windowless browsers we don't have a valid |window|. If that's the case, // use the most recent window as a target for UITour functions (see Bug 1111022). window = Services.wm.getMostRecentWindow("navigator:browser"); } let messageManager = browser.messageManager; log.debug("onPageEvent:", aEvent.detail, aMessage); if (typeof aEvent.detail != "object") { log.warn("Malformed event - detail not an object"); return false; } let action = aEvent.detail.action; if (typeof action != "string" || !action) { log.warn("Action not defined"); return false; } let data = aEvent.detail.data; if (typeof data != "object") { log.warn("Malformed event - data not an object"); return false; } if ((aEvent.pageVisibilityState == "hidden" || aEvent.pageVisibilityState == "unloaded") && !BACKGROUND_PAGE_ACTIONS_ALLOWED.has(action)) { log.warn("Ignoring disallowed action from a hidden page:", action); return false; } switch (action) { case "registerPageID": { if (typeof data.pageID != "string") { log.warn("registerPageID: pageID must be a string"); break; } this.pageIDsForSession.set(data.pageID, {lastSeen: Date.now()}); // The rest is only relevant if Telemetry is enabled. if (!UITelemetry.enabled) { log.debug("registerPageID: Telemetry disabled, not doing anything"); break; } // We don't want to allow BrowserUITelemetry.BUCKET_SEPARATOR in the // pageID, as it could make parsing the telemetry bucket name difficult. if (data.pageID.includes(BrowserUITelemetry.BUCKET_SEPARATOR)) { log.warn("registerPageID: Invalid page ID specified"); break; } this.addSeenPageID(data.pageID); this.pageIDSourceBrowsers.set(browser, data.pageID); this.setTelemetryBucket(data.pageID); break; } case "showHeartbeat": { // Validate the input parameters. if (typeof data.message !== "string" || data.message === "") { log.error("showHeartbeat: Invalid message specified."); return false; } if (typeof data.thankyouMessage !== "string" || data.thankyouMessage === "") { log.error("showHeartbeat: Invalid thank you message specified."); return false; } if (typeof data.flowId !== "string" || data.flowId === "") { log.error("showHeartbeat: Invalid flowId specified."); return false; } if (data.engagementButtonLabel && typeof data.engagementButtonLabel != "string") { log.error("showHeartbeat: Invalid engagementButtonLabel specified"); return false; } let heartbeatWindow = window; if (data.privateWindowsOnly && !PrivateBrowsingUtils.isWindowPrivate(heartbeatWindow)) { heartbeatWindow = RecentWindow.getMostRecentBrowserWindow({ private: true }); if (!heartbeatWindow) { log.debug("showHeartbeat: No private window found"); return false; } } // Finally show the Heartbeat UI. this.showHeartbeat(heartbeatWindow, data); break; } case "showHighlight": { let targetPromise = this.getTarget(window, data.target); targetPromise.then(target => { if (!target.node) { log.error("UITour: Target could not be resolved: " + data.target); return; } let effect = undefined; if (this.highlightEffects.indexOf(data.effect) !== -1) { effect = data.effect; } this.showHighlight(window, target, effect); }).catch(log.error); break; } case "hideHighlight": { this.hideHighlight(window); break; } case "showInfo": { let targetPromise = this.getTarget(window, data.target, true); targetPromise.then(target => { if (!target.node) { log.error("UITour: Target could not be resolved: " + data.target); return; } let iconURL = null; if (typeof data.icon == "string") iconURL = this.resolveURL(browser, data.icon); let buttons = []; if (Array.isArray(data.buttons) && data.buttons.length > 0) { for (let buttonData of data.buttons) { if (typeof buttonData == "object" && typeof buttonData.label == "string" && typeof buttonData.callbackID == "string") { let callback = buttonData.callbackID; let button = { label: buttonData.label, callback: event => { this.sendPageCallback(messageManager, callback); }, }; if (typeof buttonData.icon == "string") button.iconURL = this.resolveURL(browser, buttonData.icon); if (typeof buttonData.style == "string") button.style = buttonData.style; buttons.push(button); if (buttons.length == MAX_BUTTONS) { log.warn("showInfo: Reached limit of allowed number of buttons"); break; } } } } let infoOptions = {}; if (typeof data.closeButtonCallbackID == "string") { infoOptions.closeButtonCallback = () => { this.sendPageCallback(messageManager, data.closeButtonCallbackID); }; } if (typeof data.targetCallbackID == "string") { infoOptions.targetCallback = details => { this.sendPageCallback(messageManager, data.targetCallbackID, details); }; } this.showInfo(window, target, data.title, data.text, iconURL, buttons, infoOptions); }).catch(log.error); break; } case "hideInfo": { this.hideInfo(window); break; } case "previewTheme": { this.previewTheme(data.theme); break; } case "resetTheme": { this.resetTheme(); break; } case "showMenu": { this.showMenu(window, data.name, () => { if (typeof data.showCallbackID == "string") this.sendPageCallback(messageManager, data.showCallbackID); }); break; } case "hideMenu": { this.hideMenu(window, data.name); break; } case "showNewTab": { this.showNewTab(window, browser); break; } case "getConfiguration": { if (typeof data.configuration != "string") { log.warn("getConfiguration: No configuration option specified"); return false; } this.getConfiguration(messageManager, window, data.configuration, data.callbackID); break; } case "setConfiguration": { if (typeof data.configuration != "string") { log.warn("setConfiguration: No configuration option specified"); return false; } this.setConfiguration(window, data.configuration, data.value); break; } case "openPreferences": { if (typeof data.pane != "string" && typeof data.pane != "undefined") { log.warn("openPreferences: Invalid pane specified"); return false; } window.openPreferences(data.pane); break; } case "showFirefoxAccounts": { // 'signup' is the only action that makes sense currently, so we don't // accept arbitrary actions just to be safe... let p = new URLSearchParams("action=signup&entrypoint=uitour"); // Call our helper to validate extraURLCampaignParams and populate URLSearchParams if (!this._populateCampaignParams(p, data.extraURLCampaignParams)) { log.warn("showFirefoxAccounts: invalid campaign args specified"); return false; } // We want to replace the current tab. browser.loadURI("about:accounts?" + p.toString()); break; } case "resetFirefox": { // Open a reset profile dialog window. if (ResetProfile.resetSupported()) { ResetProfile.openConfirmationDialog(window); } break; } case "addNavBarWidget": { // Add a widget to the toolbar let targetPromise = this.getTarget(window, data.name); targetPromise.then(target => { this.addNavBarWidget(target, messageManager, data.callbackID); }).catch(log.error); break; } case "setDefaultSearchEngine": { let enginePromise = this.selectSearchEngine(data.identifier); enginePromise.catch(Cu.reportError); break; } case "setTreatmentTag": { let name = data.name; let value = data.value; let string = Cc["@mozilla.org/supports-string;1"].createInstance(Ci.nsISupportsString); string.data = value; Services.prefs.setComplexValue("browser.uitour.treatment." + name, Ci.nsISupportsString, string); // The notification is only meant to be used in tests. UITourHealthReport.recordTreatmentTag(name, value) .then(() => this.notify("TreatmentTag:TelemetrySent")); break; } case "getTreatmentTag": { let name = data.name; let value; try { value = Services.prefs.getComplexValue("browser.uitour.treatment." + name, Ci.nsISupportsString).data; } catch (ex) {} this.sendPageCallback(messageManager, data.callbackID, { value: value }); break; } case "setSearchTerm": { let targetPromise = this.getTarget(window, "search"); targetPromise.then(target => { let searchbar = target.node; searchbar.value = data.term; searchbar.updateGoButtonVisibility(); }); break; } case "openSearchPanel": { let targetPromise = this.getTarget(window, "search"); targetPromise.then(target => { let searchbar = target.node; if (searchbar.textbox.open) { this.sendPageCallback(messageManager, data.callbackID); } else { let onPopupShown = () => { searchbar.textbox.popup.removeEventListener("popupshown", onPopupShown); this.sendPageCallback(messageManager, data.callbackID); }; searchbar.textbox.popup.addEventListener("popupshown", onPopupShown); searchbar.openSuggestionsPanel(); } }).then(null, Cu.reportError); break; } case "ping": { if (typeof data.callbackID == "string") this.sendPageCallback(messageManager, data.callbackID); break; } case "forceShowReaderIcon": { ReaderParent.forceShowReaderIcon(browser); break; } case "toggleReaderMode": { let targetPromise = this.getTarget(window, "readerMode-urlBar"); targetPromise.then(target => { ReaderParent.toggleReaderMode({target: target.node}); }); break; } case "closeTab": { // Find the element of the for which the event // was generated originally. If the browser where the UI tour is loaded // is windowless, just ignore the request to close the tab. The request // is also ignored if this is the only tab in the window. let tabBrowser = browser.ownerGlobal.gBrowser; if (tabBrowser && tabBrowser.browsers.length > 1) { tabBrowser.removeTab(tabBrowser.getTabForBrowser(browser)); } break; } } this.initForBrowser(browser, window); return true; }, initForBrowser(aBrowser, window) { let gBrowser = window.gBrowser; if (gBrowser) { gBrowser.tabContainer.addEventListener("TabSelect", this); } if (!this.tourBrowsersByWindow.has(window)) { this.tourBrowsersByWindow.set(window, new Set()); } this.tourBrowsersByWindow.get(window).add(aBrowser); Services.obs.addObserver(this, "message-manager-close", false); window.addEventListener("SSWindowClosing", this); }, handleEvent: function(aEvent) { log.debug("handleEvent: type =", aEvent.type, "event =", aEvent); switch (aEvent.type) { case "TabSelect": { let window = aEvent.target.ownerGlobal; // Teardown the browser of the tab we just switched away from. if (aEvent.detail && aEvent.detail.previousTab) { let previousTab = aEvent.detail.previousTab; let openTourWindows = this.tourBrowsersByWindow.get(window); if (openTourWindows.has(previousTab.linkedBrowser)) { this.teardownTourForBrowser(window, previousTab.linkedBrowser, false); } } break; } case "SSWindowClosing": { let window = aEvent.target; this.teardownTourForWindow(window); break; } } }, observe: function(aSubject, aTopic, aData) { log.debug("observe: aTopic =", aTopic); switch (aTopic) { // The browser message manager is disconnected when the is // destroyed and we want to teardown at that point. case "message-manager-close": { let winEnum = Services.wm.getEnumerator("navigator:browser"); while (winEnum.hasMoreElements()) { let window = winEnum.getNext(); if (window.closed) continue; let tourBrowsers = this.tourBrowsersByWindow.get(window); if (!tourBrowsers) continue; for (let browser of tourBrowsers) { let messageManager = browser.messageManager; if (aSubject != messageManager) { continue; } this.teardownTourForBrowser(window, browser, true); return; } } break; } } }, // Given a string that is a JSONified represenation of an object with // additional utm_* URL params that should be appended, validate and append // them to the passed URLSearchParams object. Returns true if the params // were validated and appended, and false if the request should be ignored. _populateCampaignParams: function(urlSearchParams, extraURLCampaignParams) { // We are extra paranoid about what params we allow to be appended. if (typeof extraURLCampaignParams == "undefined") { // no params, so it's all good. return true; } if (typeof extraURLCampaignParams != "string") { log.warn("_populateCampaignParams: extraURLCampaignParams is not a string"); return false; } let campaignParams; try { if (extraURLCampaignParams) { campaignParams = JSON.parse(extraURLCampaignParams); if (typeof campaignParams != "object") { log.warn("_populateCampaignParams: extraURLCampaignParams is not a stringified object"); return false; } } } catch (ex) { log.warn("_populateCampaignParams: extraURLCampaignParams is not a JSON object"); return false; } if (campaignParams) { // The regex that the name of each param must match - there's no // character restriction on the value - they will be escaped as necessary. let reSimpleString = /^[-_a-zA-Z0-9]*$/; for (let name in campaignParams) { let value = campaignParams[name]; if (typeof name != "string" || typeof value != "string" || !name.startsWith("utm_") || value.length == 0 || !reSimpleString.test(name)) { log.warn("_populateCampaignParams: invalid campaign param specified"); return false; } urlSearchParams.append(name, value); } } return true; }, setTelemetryBucket: function(aPageID) { let bucket = BUCKET_NAME + BrowserUITelemetry.BUCKET_SEPARATOR + aPageID; BrowserUITelemetry.setBucket(bucket); }, setExpiringTelemetryBucket: function(aPageID, aType) { let bucket = BUCKET_NAME + BrowserUITelemetry.BUCKET_SEPARATOR + aPageID + BrowserUITelemetry.BUCKET_SEPARATOR + aType; BrowserUITelemetry.setExpiringBucket(bucket, BUCKET_TIMESTEPS); }, // This is registered with UITelemetry by BrowserUITelemetry, so that UITour // can remain lazy-loaded on-demand. getTelemetry: function() { return { seenPageIDs: [...this.seenPageIDs.keys()], }; }, /** * Tear down a tour from a tab e.g. upon switching/closing tabs. */ teardownTourForBrowser: function(aWindow, aBrowser, aTourPageClosing = false) { log.debug("teardownTourForBrowser: aBrowser = ", aBrowser, aTourPageClosing); if (this.pageIDSourceBrowsers.has(aBrowser)) { let pageID = this.pageIDSourceBrowsers.get(aBrowser); this.setExpiringTelemetryBucket(pageID, aTourPageClosing ? "closed" : "inactive"); } let openTourBrowsers = this.tourBrowsersByWindow.get(aWindow); if (aTourPageClosing && openTourBrowsers) { openTourBrowsers.delete(aBrowser); } this.hideHighlight(aWindow); this.hideInfo(aWindow); // Ensure the menu panel is hidden before calling recreatePopup so popup events occur. this.hideMenu(aWindow, "appMenu"); this.hideMenu(aWindow, "controlCenter"); // Clean up panel listeners after calling hideMenu above. aWindow.PanelUI.panel.removeEventListener("popuphiding", this.hideAppMenuAnnotations); aWindow.PanelUI.panel.removeEventListener("ViewShowing", this.hideAppMenuAnnotations); aWindow.PanelUI.panel.removeEventListener("popuphidden", this.onPanelHidden); let controlCenterPanel = aWindow.gIdentityHandler._identityPopup; controlCenterPanel.removeEventListener("popuphidden", this.onPanelHidden); controlCenterPanel.removeEventListener("popuphiding", this.hideControlCenterAnnotations); this.resetTheme(); // If there are no more tour tabs left in the window, teardown the tour for the whole window. if (!openTourBrowsers || openTourBrowsers.size == 0) { this.teardownTourForWindow(aWindow); } }, /** * Tear down all tours for a ChromeWindow. */ teardownTourForWindow: function(aWindow) { log.debug("teardownTourForWindow"); aWindow.gBrowser.tabContainer.removeEventListener("TabSelect", this); aWindow.removeEventListener("SSWindowClosing", this); let openTourBrowsers = this.tourBrowsersByWindow.get(aWindow); if (openTourBrowsers) { for (let browser of openTourBrowsers) { if (this.pageIDSourceBrowsers.has(browser)) { let pageID = this.pageIDSourceBrowsers.get(browser); this.setExpiringTelemetryBucket(pageID, "closed"); } } } this.tourBrowsersByWindow.delete(aWindow); }, // This function is copied to UITourListener. isSafeScheme: function(aURI) { let allowedSchemes = new Set(["https", "about"]); if (!Services.prefs.getBoolPref("browser.uitour.requireSecure")) allowedSchemes.add("http"); if (!allowedSchemes.has(aURI.scheme)) { log.error("Unsafe scheme:", aURI.scheme); return false; } return true; }, resolveURL: function(aBrowser, aURL) { try { let uri = Services.io.newURI(aURL, null, aBrowser.currentURI); if (!this.isSafeScheme(uri)) return null; return uri.spec; } catch (e) {} return null; }, sendPageCallback: function(aMessageManager, aCallbackID, aData = {}) { let detail = {data: aData, callbackID: aCallbackID}; log.debug("sendPageCallback", detail); aMessageManager.sendAsyncMessage("UITour:SendPageCallback", detail); }, isElementVisible: function(aElement) { let targetStyle = aElement.ownerGlobal.getComputedStyle(aElement); return !aElement.ownerDocument.hidden && targetStyle.display != "none" && targetStyle.visibility == "visible"; }, getTarget: function(aWindow, aTargetName, aSticky = false) { log.debug("getTarget:", aTargetName); let deferred = Promise.defer(); if (typeof aTargetName != "string" || !aTargetName) { log.warn("getTarget: Invalid target name specified"); deferred.reject("Invalid target name specified"); return deferred.promise; } let targetObject = this.targets.get(aTargetName); if (!targetObject) { log.warn("getTarget: The specified target name is not in the allowed set"); deferred.reject("The specified target name is not in the allowed set"); return deferred.promise; } let targetQuery = targetObject.query; aWindow.PanelUI.ensureReady().then(() => { let node; if (typeof targetQuery == "function") { try { node = targetQuery(aWindow.document); } catch (ex) { log.warn("getTarget: Error running target query:", ex); node = null; } } else { node = aWindow.document.querySelector(targetQuery); } deferred.resolve({ addTargetListener: targetObject.addTargetListener, infoPanelOffsetX: targetObject.infoPanelOffsetX, infoPanelOffsetY: targetObject.infoPanelOffsetY, infoPanelPosition: targetObject.infoPanelPosition, node: node, removeTargetListener: targetObject.removeTargetListener, targetName: aTargetName, widgetName: targetObject.widgetName, allowAdd: targetObject.allowAdd, }); }).catch(log.error); return deferred.promise; }, targetIsInAppMenu: function(aTarget) { let placement = CustomizableUI.getPlacementOfWidget(aTarget.widgetName || aTarget.node.id); if (placement && placement.area == CustomizableUI.AREA_PANEL) { return true; } let targetElement = aTarget.node; // Use the widget for filtering if it exists since the target may be the icon inside. if (aTarget.widgetName) { targetElement = aTarget.node.ownerDocument.getElementById(aTarget.widgetName); } // Handle the non-customizable buttons at the bottom of the menu which aren't proper widgets. return targetElement.id.startsWith("PanelUI-") && targetElement.id != "PanelUI-button"; }, /** * Called before opening or after closing a highlight or info panel to see if * we need to open or close the appMenu to see the annotation's anchor. */ _setAppMenuStateForAnnotation: function(aWindow, aAnnotationType, aShouldOpenForHighlight, aCallback = null) { log.debug("_setAppMenuStateForAnnotation:", aAnnotationType); log.debug("_setAppMenuStateForAnnotation: Menu is expected to be:", aShouldOpenForHighlight ? "open" : "closed"); // If the panel is in the desired state, we're done. let panelIsOpen = aWindow.PanelUI.panel.state != "closed"; if (aShouldOpenForHighlight == panelIsOpen) { log.debug("_setAppMenuStateForAnnotation: Panel already in expected state"); if (aCallback) aCallback(); return; } // Don't close the menu if it wasn't opened by us (e.g. via showmenu instead). if (!aShouldOpenForHighlight && !this.appMenuOpenForAnnotation.has(aAnnotationType)) { log.debug("_setAppMenuStateForAnnotation: Menu not opened by us, not closing"); if (aCallback) aCallback(); return; } if (aShouldOpenForHighlight) { this.appMenuOpenForAnnotation.add(aAnnotationType); } else { this.appMenuOpenForAnnotation.delete(aAnnotationType); } // Actually show or hide the menu if (this.appMenuOpenForAnnotation.size) { log.debug("_setAppMenuStateForAnnotation: Opening the menu"); this.showMenu(aWindow, "appMenu", aCallback); } else { log.debug("_setAppMenuStateForAnnotation: Closing the menu"); this.hideMenu(aWindow, "appMenu"); if (aCallback) aCallback(); } }, previewTheme: function(aTheme) { let origin = Services.prefs.getCharPref("browser.uitour.themeOrigin"); let data = LightweightThemeManager.parseTheme(aTheme, origin); if (data) LightweightThemeManager.previewTheme(data); }, resetTheme: function() { LightweightThemeManager.resetPreview(); }, /** * Show the Heartbeat UI to request user feedback. This function reports back to the * caller using |notify|. The notification event name reflects the current status the UI * is in (either "Heartbeat:NotificationOffered", "Heartbeat:NotificationClosed", * "Heartbeat:LearnMore", "Heartbeat:Engaged", "Heartbeat:Voted", * "Heartbeat:SurveyExpired" or "Heartbeat:WindowClosed"). * When a "Heartbeat:Voted" event is notified * the data payload contains a |score| field which holds the rating picked by the user. * Please note that input parameters are already validated by the caller. * * @param aChromeWindow * The chrome window that the heartbeat notification is displayed in. * @param {Object} aOptions Options object. * @param {String} aOptions.message * The message, or question, to display on the notification. * @param {String} aOptions.thankyouMessage * The thank you message to display after user votes. * @param {String} aOptions.flowId * An identifier for this rating flow. Please note that this is only used to * identify the notification box. * @param {String} [aOptions.engagementButtonLabel=null] * The text of the engagement button to use instad of stars. If this is null * or invalid, rating stars are used. * @param {String} [aOptions.engagementURL=null] * The engagement URL to open in a new tab once user has engaged. If this is null * or invalid, no new tab is opened. * @param {String} [aOptions.learnMoreLabel=null] * The label of the learn more link. No link will be shown if this is null. * @param {String} [aOptions.learnMoreURL=null] * The learn more URL to open when clicking on the learn more link. No learn more * will be shown if this is an invalid URL. * @param {boolean} [aOptions.privateWindowsOnly=false] * Whether the heartbeat UI should only be targeted at a private window (if one exists). * No notifications should be fired when this is true. * @param {String} [aOptions.surveyId] * An ID for the survey, reflected in the Telemetry ping. * @param {Number} [aOptions.surveyVersion] * Survey's version number, reflected in the Telemetry ping. * @param {boolean} [aOptions.testing] * Whether this is a test survey, reflected in the Telemetry ping. */ showHeartbeat(aChromeWindow, aOptions) { // Initialize survey state let pingSent = false; let surveyResults = {}; let surveyEndTimer = null; /** * Accumulates survey events and submits to Telemetry after the survey ends. * * @param {String} aEventName * Heartbeat event name * @param {Object} aParams * Additional parameters and their values */ let maybeNotifyHeartbeat = (aEventName, aParams = {}) => { // Return if event occurred after the ping was sent if (pingSent) { log.warn("maybeNotifyHeartbeat: event occurred after ping sent:", aEventName, aParams); return; } // No Telemetry from private-window-only Heartbeats if (aOptions.privateWindowsOnly) { return; } let ts = Date.now(); let sendPing = false; switch (aEventName) { case "Heartbeat:NotificationOffered": surveyResults.flowId = aOptions.flowId; surveyResults.offeredTS = ts; break; case "Heartbeat:LearnMore": // record only the first click if (!surveyResults.learnMoreTS) { surveyResults.learnMoreTS = ts; } break; case "Heartbeat:Engaged": surveyResults.engagedTS = ts; break; case "Heartbeat:Voted": surveyResults.votedTS = ts; surveyResults.score = aParams.score; break; case "Heartbeat:SurveyExpired": surveyResults.expiredTS = ts; break; case "Heartbeat:NotificationClosed": // this is the final event in most surveys surveyResults.closedTS = ts; sendPing = true; break; case "Heartbeat:WindowClosed": surveyResults.windowClosedTS = ts; sendPing = true; break; default: log.error("maybeNotifyHeartbeat: unrecognized event:", aEventName); break; } aParams.timestamp = ts; aParams.flowId = aOptions.flowId; this.notify(aEventName, aParams); if (!sendPing) { return; } // Send the ping to Telemetry let payload = Object.assign({}, surveyResults); payload.version = 1; for (let meta of ["surveyId", "surveyVersion", "testing"]) { if (aOptions.hasOwnProperty(meta)) { payload[meta] = aOptions[meta]; } } log.debug("Sending payload to Telemetry: aEventName:", aEventName, "payload:", payload); TelemetryController.submitExternalPing("heartbeat", payload, { addClientId: true, addEnvironment: true, }); // only for testing this.notify("Heartbeat:TelemetrySent", payload); // Survey is complete, clear out the expiry timer & survey configuration if (surveyEndTimer) { clearTimeout(surveyEndTimer); surveyEndTimer = null; } pingSent = true; surveyResults = {}; }; let nb = aChromeWindow.document.getElementById("high-priority-global-notificationbox"); let buttons = null; if (aOptions.engagementButtonLabel) { buttons = [{ label: aOptions.engagementButtonLabel, callback: () => { // Let the consumer know user engaged. maybeNotifyHeartbeat("Heartbeat:Engaged"); userEngaged(new Map([ ["type", "button"], ["flowid", aOptions.flowId] ])); // Return true so that the notification bar doesn't close itself since // we have a thank you message to show. return true; }, }]; } let defaultIcon = "chrome://browser/skin/heartbeat-icon.svg"; let iconURL = defaultIcon; try { // Take the optional icon URL if specified if (aOptions.iconURL) { iconURL = new URL(aOptions.iconURL); // For now, only allow chrome URIs. if (iconURL.protocol != "chrome:") { iconURL = defaultIcon; throw new Error("Invalid protocol"); } } } catch (error) { log.error("showHeartbeat: Invalid icon URL specified."); } // Create the notification. Prefix its ID to decrease the chances of collisions. let notice = nb.appendNotification(aOptions.message, "heartbeat-" + aOptions.flowId, iconURL, nb.PRIORITY_INFO_HIGH, buttons, (aEventType) => { if (aEventType != "removed") { return; } // Let the consumer know the notification bar was closed. // This also happens after voting. maybeNotifyHeartbeat("Heartbeat:NotificationClosed"); }); // Get the elements we need to style. let messageImage = aChromeWindow.document.getAnonymousElementByAttribute(notice, "anonid", "messageImage"); let messageText = aChromeWindow.document.getAnonymousElementByAttribute(notice, "anonid", "messageText"); function userEngaged(aEngagementParams) { // Make the heartbeat icon pulse twice. notice.label = aOptions.thankyouMessage; messageImage.classList.remove("pulse-onshow"); messageImage.classList.add("pulse-twice"); // Remove all the children of the notice (rating container // and the flex). while (notice.firstChild) { notice.removeChild(notice.firstChild); } // Make sure that we have a valid URL. If we haven't, do not open the engagement page. let engagementURL = null; try { engagementURL = new URL(aOptions.engagementURL); } catch (error) { log.error("showHeartbeat: Invalid URL specified."); } // Just open the engagement tab if we have a valid engagement URL. if (engagementURL) { for (let [param, value] of aEngagementParams) { engagementURL.searchParams.append(param, value); } // Open the engagement URL in a new tab. aChromeWindow.gBrowser.selectedTab = aChromeWindow.gBrowser.addTab(engagementURL.toString(), { owner: aChromeWindow.gBrowser.selectedTab, relatedToCurrent: true }); } // Remove the notification bar after 3 seconds. aChromeWindow.setTimeout(() => { nb.removeNotification(notice); }, 3000); } // Create the fragment holding the rating UI. let frag = aChromeWindow.document.createDocumentFragment(); // Build the Heartbeat star rating. const numStars = aOptions.engagementButtonLabel ? 0 : 5; let ratingContainer = aChromeWindow.document.createElement("hbox"); ratingContainer.id = "star-rating-container"; for (let i = 0; i < numStars; i++) { // Create a star rating element. let ratingElement = aChromeWindow.document.createElement("toolbarbutton"); // Style it. let starIndex = numStars - i; ratingElement.className = "plain star-x"; ratingElement.id = "star" + starIndex; ratingElement.setAttribute("data-score", starIndex); // Add the click handler. ratingElement.addEventListener("click", function (evt) { let rating = Number(evt.target.getAttribute("data-score"), 10); // Let the consumer know user voted. maybeNotifyHeartbeat("Heartbeat:Voted", { score: rating }); // Append the score data to the engagement URL. userEngaged(new Map([ ["type", "stars"], ["score", rating], ["flowid", aOptions.flowId] ])); }.bind(this)); // Add it to the container. ratingContainer.appendChild(ratingElement); } frag.appendChild(ratingContainer); // Make sure the stars are not pushed to the right by the spacer. let rightSpacer = aChromeWindow.document.createElement("spacer"); rightSpacer.flex = 20; frag.appendChild(rightSpacer); messageText.flex = 0; // Collapse the space before the stars. let leftSpacer = messageText.nextSibling; leftSpacer.flex = 0; // Make sure that we have a valid learn more URL. let learnMoreURL = null; try { learnMoreURL = new URL(aOptions.learnMoreURL); } catch (error) { log.error("showHeartbeat: Invalid learnMore URL specified."); } // Add the learn more link. if (aOptions.learnMoreLabel && learnMoreURL) { let learnMore = aChromeWindow.document.createElement("label"); learnMore.className = "text-link"; learnMore.href = learnMoreURL.toString(); learnMore.setAttribute("value", aOptions.learnMoreLabel); learnMore.addEventListener("click", () => maybeNotifyHeartbeat("Heartbeat:LearnMore")); frag.appendChild(learnMore); } // Append the fragment and apply the styling. notice.appendChild(frag); notice.classList.add("heartbeat"); messageImage.classList.add("heartbeat", "pulse-onshow"); messageText.classList.add("heartbeat"); // Let the consumer know the notification was shown. maybeNotifyHeartbeat("Heartbeat:NotificationOffered"); // End the survey if the user quits, closes the window, or // hasn't responded before expiration. if (!aOptions.privateWindowsOnly) { function handleWindowClosed(aTopic) { maybeNotifyHeartbeat("Heartbeat:WindowClosed"); aChromeWindow.removeEventListener("SSWindowClosing", handleWindowClosed); } aChromeWindow.addEventListener("SSWindowClosing", handleWindowClosed); let surveyDuration = Services.prefs.getIntPref(PREF_SURVEY_DURATION) * 1000; surveyEndTimer = setTimeout(() => { maybeNotifyHeartbeat("Heartbeat:SurveyExpired"); nb.removeNotification(notice); }, surveyDuration); } }, /** * The node to which a highlight or notification(-popup) is anchored is sometimes * obscured because it may be inside an overflow menu. This function should figure * that out and offer the overflow chevron as an alternative. * * @param {Node} aAnchor The element that's supposed to be the anchor * @type {Node} */ _correctAnchor: function(aAnchor) { // If the target is in the overflow panel, just return the overflow button. if (aAnchor.getAttribute("overflowedItem")) { let doc = aAnchor.ownerDocument; let placement = CustomizableUI.getPlacementOfWidget(aAnchor.id); let areaNode = doc.getElementById(placement.area); return areaNode.overflowable._chevron; } return aAnchor; }, /** * @param aChromeWindow The chrome window that the highlight is in. Necessary since some targets * are in a sub-frame so the defaultView is not the same as the chrome * window. * @param aTarget The element to highlight. * @param aEffect (optional) The effect to use from UITour.highlightEffects or "none". * @see UITour.highlightEffects */ showHighlight: function(aChromeWindow, aTarget, aEffect = "none") { function showHighlightPanel() { let highlighter = aChromeWindow.document.getElementById("UITourHighlight"); let effect = aEffect; if (effect == "random") { // Exclude "random" from the randomly selected effects. let randomEffect = 1 + Math.floor(Math.random() * (this.highlightEffects.length - 1)); if (randomEffect == this.highlightEffects.length) randomEffect--; // On the order of 1 in 2^62 chance of this happening. effect = this.highlightEffects[randomEffect]; } // Toggle the effect attribute to "none" and flush layout before setting it so the effect plays. highlighter.setAttribute("active", "none"); aChromeWindow.getComputedStyle(highlighter).animationName; highlighter.setAttribute("active", effect); highlighter.parentElement.setAttribute("targetName", aTarget.targetName); highlighter.parentElement.hidden = false; let highlightAnchor = this._correctAnchor(aTarget.node); let targetRect = highlightAnchor.getBoundingClientRect(); let highlightHeight = targetRect.height; let highlightWidth = targetRect.width; let minDimension = Math.min(highlightHeight, highlightWidth); let maxDimension = Math.max(highlightHeight, highlightWidth); // If the dimensions are within 200% of each other (to include the bookmarks button), // make the highlight a circle with the largest dimension as the diameter. if (maxDimension / minDimension <= 3.0) { highlightHeight = highlightWidth = maxDimension; highlighter.style.borderRadius = "100%"; } else { highlighter.style.borderRadius = ""; } highlighter.style.height = highlightHeight + "px"; highlighter.style.width = highlightWidth + "px"; // Close a previous highlight so we can relocate the panel. if (highlighter.parentElement.state == "showing" || highlighter.parentElement.state == "open") { log.debug("showHighlight: Closing previous highlight first"); highlighter.parentElement.hidePopup(); } /* The "overlap" position anchors from the top-left but we want to centre highlights at their minimum size. */ let highlightWindow = aChromeWindow; let containerStyle = highlightWindow.getComputedStyle(highlighter.parentElement); let paddingTopPx = 0 - parseFloat(containerStyle.paddingTop); let paddingLeftPx = 0 - parseFloat(containerStyle.paddingLeft); let highlightStyle = highlightWindow.getComputedStyle(highlighter); let highlightHeightWithMin = Math.max(highlightHeight, parseFloat(highlightStyle.minHeight)); let highlightWidthWithMin = Math.max(highlightWidth, parseFloat(highlightStyle.minWidth)); let offsetX = paddingTopPx - (Math.max(0, highlightWidthWithMin - targetRect.width) / 2); let offsetY = paddingLeftPx - (Math.max(0, highlightHeightWithMin - targetRect.height) / 2); this._addAnnotationPanelMutationObserver(highlighter.parentElement); highlighter.parentElement.openPopup(highlightAnchor, "overlap", offsetX, offsetY); } // Prevent showing a panel at an undefined position. if (!this.isElementVisible(aTarget.node)) { log.warn("showHighlight: Not showing a highlight since the target isn't visible", aTarget); return; } this._setAppMenuStateForAnnotation(aChromeWindow, "highlight", this.targetIsInAppMenu(aTarget), showHighlightPanel.bind(this)); }, hideHighlight: function(aWindow) { let highlighter = aWindow.document.getElementById("UITourHighlight"); this._removeAnnotationPanelMutationObserver(highlighter.parentElement); highlighter.parentElement.hidePopup(); highlighter.removeAttribute("active"); this._setAppMenuStateForAnnotation(aWindow, "highlight", false); }, /** * Show an info panel. * * @param {ChromeWindow} aChromeWindow * @param {Node} aAnchor * @param {String} [aTitle=""] * @param {String} [aDescription=""] * @param {String} [aIconURL=""] * @param {Object[]} [aButtons=[]] * @param {Object} [aOptions={}] * @param {String} [aOptions.closeButtonCallback] * @param {String} [aOptions.targetCallback] */ showInfo(aChromeWindow, aAnchor, aTitle = "", aDescription = "", aIconURL = "", aButtons = [], aOptions = {}) { function showInfoPanel(aAnchorEl) { aAnchorEl.focus(); let document = aChromeWindow.document; let tooltip = document.getElementById("UITourTooltip"); let tooltipTitle = document.getElementById("UITourTooltipTitle"); let tooltipDesc = document.getElementById("UITourTooltipDescription"); let tooltipIcon = document.getElementById("UITourTooltipIcon"); let tooltipButtons = document.getElementById("UITourTooltipButtons"); if (tooltip.state == "showing" || tooltip.state == "open") { tooltip.hidePopup(); } tooltipTitle.textContent = aTitle || ""; tooltipDesc.textContent = aDescription || ""; tooltipIcon.src = aIconURL || ""; tooltipIcon.hidden = !aIconURL; while (tooltipButtons.firstChild) tooltipButtons.firstChild.remove(); for (let button of aButtons) { let isButton = button.style != "text"; let el = document.createElement(isButton ? "button" : "label"); el.setAttribute(isButton ? "label" : "value", button.label); if (isButton) { if (button.iconURL) el.setAttribute("image", button.iconURL); if (button.style == "link") el.setAttribute("class", "button-link"); if (button.style == "primary") el.setAttribute("class", "button-primary"); // Don't close the popup or call the callback for style=text as they // aren't links/buttons. let callback = button.callback; el.addEventListener("command", event => { tooltip.hidePopup(); callback(event); }); } tooltipButtons.appendChild(el); } tooltipButtons.hidden = !aButtons.length; let tooltipClose = document.getElementById("UITourTooltipClose"); let closeButtonCallback = (event) => { this.hideInfo(document.defaultView); if (aOptions && aOptions.closeButtonCallback) { aOptions.closeButtonCallback(); } }; tooltipClose.addEventListener("command", closeButtonCallback); let targetCallback = (event) => { let details = { target: aAnchor.targetName, type: event.type, }; aOptions.targetCallback(details); }; if (aOptions.targetCallback && aAnchor.addTargetListener) { aAnchor.addTargetListener(document, targetCallback); } tooltip.addEventListener("popuphiding", function tooltipHiding(event) { tooltip.removeEventListener("popuphiding", tooltipHiding); tooltipClose.removeEventListener("command", closeButtonCallback); if (aOptions.targetCallback && aAnchor.removeTargetListener) { aAnchor.removeTargetListener(document, targetCallback); } }); tooltip.setAttribute("targetName", aAnchor.targetName); tooltip.hidden = false; let alignment = "bottomcenter topright"; if (aAnchor.infoPanelPosition) { alignment = aAnchor.infoPanelPosition; } let { infoPanelOffsetX: xOffset, infoPanelOffsetY: yOffset } = aAnchor; this._addAnnotationPanelMutationObserver(tooltip); tooltip.openPopup(aAnchorEl, alignment, xOffset || 0, yOffset || 0); if (tooltip.state == "closed") { document.defaultView.addEventListener("endmodalstate", function endModalStateHandler() { document.defaultView.removeEventListener("endmodalstate", endModalStateHandler); tooltip.openPopup(aAnchorEl, alignment); }, false); } } // Prevent showing a panel at an undefined position. if (!this.isElementVisible(aAnchor.node)) { log.warn("showInfo: Not showing since the target isn't visible", aAnchor); return; } this._setAppMenuStateForAnnotation(aChromeWindow, "info", this.targetIsInAppMenu(aAnchor), showInfoPanel.bind(this, this._correctAnchor(aAnchor.node))); }, isInfoOnTarget(aChromeWindow, aTargetName) { let document = aChromeWindow.document; let tooltip = document.getElementById("UITourTooltip"); return tooltip.getAttribute("targetName") == aTargetName && tooltip.state != "closed"; }, hideInfo: function(aWindow) { let document = aWindow.document; let tooltip = document.getElementById("UITourTooltip"); this._removeAnnotationPanelMutationObserver(tooltip); tooltip.hidePopup(); this._setAppMenuStateForAnnotation(aWindow, "info", false); let tooltipButtons = document.getElementById("UITourTooltipButtons"); while (tooltipButtons.firstChild) tooltipButtons.firstChild.remove(); }, showMenu: function(aWindow, aMenuName, aOpenCallback = null) { log.debug("showMenu:", aMenuName); function openMenuButton(aMenuBtn) { if (!aMenuBtn || !aMenuBtn.boxObject || aMenuBtn.open) { if (aOpenCallback) aOpenCallback(); return; } if (aOpenCallback) aMenuBtn.addEventListener("popupshown", onPopupShown); aMenuBtn.boxObject.openMenu(true); } function onPopupShown(event) { this.removeEventListener("popupshown", onPopupShown); aOpenCallback(event); } if (aMenuName == "appMenu") { aWindow.PanelUI.panel.setAttribute("noautohide", "true"); // If the popup is already opened, don't recreate the widget as it may cause a flicker. if (aWindow.PanelUI.panel.state != "open") { this.recreatePopup(aWindow.PanelUI.panel); } aWindow.PanelUI.panel.addEventListener("popuphiding", this.hideAppMenuAnnotations); aWindow.PanelUI.panel.addEventListener("ViewShowing", this.hideAppMenuAnnotations); aWindow.PanelUI.panel.addEventListener("popuphidden", this.onPanelHidden); if (aOpenCallback) { aWindow.PanelUI.panel.addEventListener("popupshown", onPopupShown); } aWindow.PanelUI.show(); } else if (aMenuName == "bookmarks") { let menuBtn = aWindow.document.getElementById("bookmarks-menu-button"); openMenuButton(menuBtn); } else if (aMenuName == "controlCenter") { let popup = aWindow.gIdentityHandler._identityPopup; // Add the listener even if the panel is already open since it will still // only get registered once even if it was UITour that opened it. popup.addEventListener("popuphiding", this.hideControlCenterAnnotations); popup.addEventListener("popuphidden", this.onPanelHidden); popup.setAttribute("noautohide", true); this.clearAvailableTargetsCache(); if (popup.state == "open") { if (aOpenCallback) { aOpenCallback(); } return; } this.recreatePopup(popup); // Open the control center if (aOpenCallback) { popup.addEventListener("popupshown", onPopupShown); } aWindow.document.getElementById("identity-box").click(); } else if (aMenuName == "pocket") { this.getTarget(aWindow, "pocket").then(Task.async(function* onPocketTarget(target) { let widgetGroupWrapper = CustomizableUI.getWidget(target.widgetName); if (widgetGroupWrapper.type != "view" || !widgetGroupWrapper.viewId) { log.error("Can't open the pocket menu without a view"); return; } let placement = CustomizableUI.getPlacementOfWidget(target.widgetName); if (!placement || !placement.area) { log.error("Can't open the pocket menu without a placement"); return; } if (placement.area == CustomizableUI.AREA_PANEL) { // Open the appMenu and wait for it if it's not already opened or showing a subview. yield new Promise((resolve, reject) => { if (aWindow.PanelUI.panel.state != "closed") { if (aWindow.PanelUI.multiView.showingSubView) { reject("A subview is already showing"); return; } resolve(); return; } aWindow.PanelUI.panel.addEventListener("popupshown", function onShown() { aWindow.PanelUI.panel.removeEventListener("popupshown", onShown); resolve(); }); aWindow.PanelUI.show(); }); } let widgetWrapper = widgetGroupWrapper.forWindow(aWindow); aWindow.PanelUI.showSubView(widgetGroupWrapper.viewId, widgetWrapper.anchor, placement.area); })).catch(log.error); } }, hideMenu: function(aWindow, aMenuName) { log.debug("hideMenu:", aMenuName); function closeMenuButton(aMenuBtn) { if (aMenuBtn && aMenuBtn.boxObject) aMenuBtn.boxObject.openMenu(false); } if (aMenuName == "appMenu") { aWindow.PanelUI.hide(); } else if (aMenuName == "bookmarks") { let menuBtn = aWindow.document.getElementById("bookmarks-menu-button"); closeMenuButton(menuBtn); } else if (aMenuName == "controlCenter") { let panel = aWindow.gIdentityHandler._identityPopup; panel.hidePopup(); } }, showNewTab: function(aWindow, aBrowser) { aWindow.openLinkIn("about:newtab", "current", {targetBrowser: aBrowser}); }, hideAnnotationsForPanel: function(aEvent, aTargetPositionCallback) { let win = aEvent.target.ownerGlobal; let annotationElements = new Map([ // [annotationElement (panel), method to hide the annotation] [win.document.getElementById("UITourHighlightContainer"), UITour.hideHighlight.bind(UITour)], [win.document.getElementById("UITourTooltip"), UITour.hideInfo.bind(UITour)], ]); annotationElements.forEach((hideMethod, annotationElement) => { if (annotationElement.state != "closed") { let targetName = annotationElement.getAttribute("targetName"); UITour.getTarget(win, targetName).then((aTarget) => { // Since getTarget is async, we need to make sure that the target hasn't // changed since it may have just moved to somewhere outside of the app menu. if (annotationElement.getAttribute("targetName") != aTarget.targetName || annotationElement.state == "closed" || !aTargetPositionCallback(aTarget)) { return; } hideMethod(win); }).catch(log.error); } }); UITour.appMenuOpenForAnnotation.clear(); }, hideAppMenuAnnotations: function(aEvent) { UITour.hideAnnotationsForPanel(aEvent, UITour.targetIsInAppMenu); }, hideControlCenterAnnotations(aEvent) { UITour.hideAnnotationsForPanel(aEvent, (aTarget) => { return aTarget.targetName.startsWith("controlCenter-"); }); }, onPanelHidden: function(aEvent) { aEvent.target.removeAttribute("noautohide"); UITour.recreatePopup(aEvent.target); UITour.clearAvailableTargetsCache(); }, recreatePopup: function(aPanel) { // After changing popup attributes that relate to how the native widget is created // (e.g. @noautohide) we need to re-create the frame/widget for it to take effect. if (aPanel.hidden) { // If the panel is already hidden, we don't need to recreate it but flush // in case someone just hid it. aPanel.clientWidth; // flush return; } aPanel.hidden = true; aPanel.clientWidth; // flush aPanel.hidden = false; }, getConfiguration: function(aMessageManager, aWindow, aConfiguration, aCallbackID) { switch (aConfiguration) { case "appinfo": let props = ["defaultUpdateChannel", "version"]; let appinfo = {}; props.forEach(property => appinfo[property] = Services.appinfo[property]); // Identifier of the partner repack, as stored in preference "distribution.id" // and included in Firefox and other update pings. Note this is not the same as // Services.appinfo.distributionID (value of MOZ_DISTRIBUTION_ID is set at build time). let distribution = "default"; try { distribution = Services.prefs.getDefaultBranch("distribution.").getCharPref("id"); } catch (e) {} appinfo["distribution"] = distribution; let isDefaultBrowser = null; try { let shell = aWindow.getShellService(); if (shell) { isDefaultBrowser = shell.isDefaultBrowser(false); } } catch (e) {} appinfo["defaultBrowser"] = isDefaultBrowser; let canSetDefaultBrowserInBackground = true; if (AppConstants.isPlatformAndVersionAtLeast("win", "6.2") || AppConstants.isPlatformAndVersionAtLeast("macosx", "10.10")) { canSetDefaultBrowserInBackground = false; } else if (AppConstants.platform == "linux") { // The ShellService may not exist on some versions of Linux. try { aWindow.getShellService(); } catch (e) { canSetDefaultBrowserInBackground = null; } } appinfo["canSetDefaultBrowserInBackground"] = canSetDefaultBrowserInBackground; this.sendPageCallback(aMessageManager, aCallbackID, appinfo); break; case "availableTargets": this.getAvailableTargets(aMessageManager, aWindow, aCallbackID); break; case "search": case "selectedSearchEngine": Services.search.init(rv => { let data; if (Components.isSuccessCode(rv)) { let engines = Services.search.getVisibleEngines(); data = { searchEngineIdentifier: Services.search.defaultEngine.identifier, engines: engines.filter((engine) => engine.identifier) .map((engine) => TARGET_SEARCHENGINE_PREFIX + engine.identifier) }; } else { data = {engines: [], searchEngineIdentifier: ""}; } this.sendPageCallback(aMessageManager, aCallbackID, data); }); break; case "sync": this.sendPageCallback(aMessageManager, aCallbackID, { setup: Services.prefs.prefHasUserValue("services.sync.username"), desktopDevices: Preferences.get("services.sync.clients.devices.desktop", 0), mobileDevices: Preferences.get("services.sync.clients.devices.mobile", 0), totalDevices: Preferences.get("services.sync.numClients", 0), }); break; case "canReset": this.sendPageCallback(aMessageManager, aCallbackID, ResetProfile.resetSupported()); break; default: log.error("getConfiguration: Unknown configuration requested: " + aConfiguration); break; } }, setConfiguration: function(aWindow, aConfiguration, aValue) { switch (aConfiguration) { case "defaultBrowser": // Ignore aValue in this case because the default browser can only // be set, not unset. try { let shell = aWindow.getShellService(); if (shell) { shell.setDefaultBrowser(true, false); } } catch (e) {} break; default: log.error("setConfiguration: Unknown configuration requested: " + aConfiguration); break; } }, getAvailableTargets: function(aMessageManager, aChromeWindow, aCallbackID) { Task.spawn(function*() { let window = aChromeWindow; let data = this.availableTargetsCache.get(window); if (data) { log.debug("getAvailableTargets: Using cached targets list", data.targets.join(",")); this.sendPageCallback(aMessageManager, aCallbackID, data); return; } let promises = []; for (let targetName of this.targets.keys()) { promises.push(this.getTarget(window, targetName)); } let targetObjects = yield Promise.all(promises); let targetNames = []; for (let targetObject of targetObjects) { if (targetObject.node) targetNames.push(targetObject.targetName); } data = { targets: targetNames, }; this.availableTargetsCache.set(window, data); this.sendPageCallback(aMessageManager, aCallbackID, data); }.bind(this)).catch(err => { log.error(err); this.sendPageCallback(aMessageManager, aCallbackID, { targets: [], }); }); }, startSubTour: function (aFeature) { if (aFeature != "string") { log.error("startSubTour: No feature option specified"); return; } if (aFeature == "readinglist") { ReaderParent.showReaderModeInfoPanel(browser); } else { log.error("startSubTour: Unknown feature option specified"); return; } }, addNavBarWidget: function (aTarget, aMessageManager, aCallbackID) { if (aTarget.node) { log.error("addNavBarWidget: can't add a widget already present:", aTarget); return; } if (!aTarget.allowAdd) { log.error("addNavBarWidget: not allowed to add this widget:", aTarget); return; } if (!aTarget.widgetName) { log.error("addNavBarWidget: can't add a widget without a widgetName property:", aTarget); return; } CustomizableUI.addWidgetToArea(aTarget.widgetName, CustomizableUI.AREA_NAVBAR); this.sendPageCallback(aMessageManager, aCallbackID); }, _addAnnotationPanelMutationObserver: function(aPanelEl) { if (AppConstants.platform == "linux") { let observer = this._annotationPanelMutationObservers.get(aPanelEl); if (observer) { return; } let win = aPanelEl.ownerGlobal; observer = new win.MutationObserver(this._annotationMutationCallback); this._annotationPanelMutationObservers.set(aPanelEl, observer); let observerOptions = { attributeFilter: ["height", "width"], attributes: true, }; observer.observe(aPanelEl, observerOptions); } }, _removeAnnotationPanelMutationObserver: function(aPanelEl) { if (AppConstants.platform == "linux") { let observer = this._annotationPanelMutationObservers.get(aPanelEl); if (observer) { observer.disconnect(); this._annotationPanelMutationObservers.delete(aPanelEl); } } }, /** * Workaround for Ubuntu panel craziness in bug 970788 where incorrect sizes get passed to * nsXULPopupManager::PopupResized and lead to incorrect width and height attributes getting * set on the panel. */ _annotationMutationCallback: function(aMutations) { for (let mutation of aMutations) { // Remove both attributes at once and ignore remaining mutations to be proccessed. mutation.target.removeAttribute("width"); mutation.target.removeAttribute("height"); return; } }, selectSearchEngine(aID) { return new Promise((resolve, reject) => { Services.search.init((rv) => { if (!Components.isSuccessCode(rv)) { reject("selectSearchEngine: search service init failed: " + rv); return; } let engines = Services.search.getVisibleEngines(); for (let engine of engines) { if (engine.identifier == aID) { Services.search.defaultEngine = engine; resolve(); return; } } reject("selectSearchEngine could not find engine with given ID"); return; }); }); }, notify(eventName, params) { let winEnum = Services.wm.getEnumerator("navigator:browser"); while (winEnum.hasMoreElements()) { let window = winEnum.getNext(); if (window.closed) continue; let openTourBrowsers = this.tourBrowsersByWindow.get(window); if (!openTourBrowsers) continue; for (let browser of openTourBrowsers) { let messageManager = browser.messageManager; if (!messageManager) { log.error("notify: Trying to notify a browser without a messageManager", browser); continue; } let detail = { event: eventName, params: params, }; messageManager.sendAsyncMessage("UITour:SendPageNotification", detail); } } }, }; function controlCenterTrackingToggleTarget(aUnblock) { return { infoPanelPosition: "rightcenter topleft", query(aDocument) { let popup = aDocument.defaultView.gIdentityHandler._identityPopup; if (popup.state != "open") { return null; } let buttonId = null; if (aUnblock) { if (PrivateBrowsingUtils.isWindowPrivate(aDocument.defaultView)) { buttonId = "tracking-action-unblock-private"; } else { buttonId = "tracking-action-unblock"; } } else { buttonId = "tracking-action-block"; } let element = aDocument.getElementById(buttonId); return UITour.isElementVisible(element) ? element : null; }, }; } this.UITour.init(); /** * UITour Health Report */ /** * Public API to be called by the UITour code */ const UITourHealthReport = { recordTreatmentTag: function(tag, value) { return TelemetryController.submitExternalPing("uitour-tag", { version: 1, tagName: tag, tagValue: value, }, { addClientId: true, addEnvironment: true, }); } };